diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index 2eac98992c6..7c9ee2235f2 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -2,15 +2,15 @@ "build": { "launchpad": { "baseline": { - "brotliBytes": 842334, + "brotliBytes": 844445, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 841151, + "gzipBytes": 843488, "imageRawBytes": 156376, - "initialRawBytes": 1961176, - "largestAssetRawBytes": 746400, + "initialRawBytes": 1971897, + "largestAssetRawBytes": 746121, "otherAssetRawBytes": 0, - "ownedRawBytes": 70960, + "ownedRawBytes": 73641, "requestCount": 42, "scriptRequestCount": 26, "sourceOwners": [ @@ -203,17 +203,20 @@ "source:src/platform/ui/EmptyState.tsx", "source:src/platform/ui/Field.tsx", "source:src/platform/ui/Group.tsx", + "source:src/platform/ui/InputShell.tsx", "source:src/platform/ui/InvokeMark.tsx", "source:src/platform/ui/JsonPreview.tsx", "source:src/platform/ui/Menu.tsx", "source:src/platform/ui/MiddleTruncate.tsx", "source:src/platform/ui/PageShell.tsx", "source:src/platform/ui/Panel.tsx", + "source:src/platform/ui/Popover.tsx", "source:src/platform/ui/RenameDialog.tsx", "source:src/platform/ui/ResizableTextarea.tsx", "source:src/platform/ui/Row.tsx", "source:src/platform/ui/Scrollable.tsx", "source:src/platform/ui/SegmentTabs.tsx", + "source:src/platform/ui/SegmentedControl.tsx", "source:src/platform/ui/Select.tsx", "source:src/platform/ui/Slider.tsx", "source:src/platform/ui/StatusWidgetChip.tsx", @@ -234,6 +237,7 @@ "source:src/platform/ui/theme/system.ts", "source:src/platform/ui/theme/themes.ts", "source:src/platform/ui/toaster.tsx", + "source:src/platform/ui/useScrollAreaPhantomHeal.ts", "source:src/workbench/canvasSchemaVersion.ts", "source:src/workbench/components/WorkbenchSplashScreen.tsx", "source:src/workbench/hotkeys/catalog.ts", @@ -255,6 +259,7 @@ "source:src/workbench/launchpad/pages/ProjectsPage.tsx", "source:src/workbench/launchpad/projects/NewProjectButton.tsx", "source:src/workbench/launchpad/projects/ProjectActionsMenu.tsx", + "source:src/workbench/launchpad/projects/ProjectActionsMenuHost.tsx", "source:src/workbench/launchpad/projects/ProjectCard.tsx", "source:src/workbench/launchpad/projects/ProjectCompatibilityBadge.tsx", "source:src/workbench/launchpad/projects/ProjectCover.tsx", @@ -291,15 +296,15 @@ ] }, "limits": { - "brotliBytes": 850758, + "brotliBytes": 852890, "cssRawBytes": 2181, "fontRawBytes": 221675, - "gzipBytes": 849563, + "gzipBytes": 851923, "imageRawBytes": 157940, - "initialRawBytes": 1980788, - "largestAssetRawBytes": 753864, + "initialRawBytes": 1991616, + "largestAssetRawBytes": 753583, "otherAssetRawBytes": 0, - "ownedRawBytes": 70960, + "ownedRawBytes": 73641, "requestCount": 42, "scriptRequestCount": 26 }, @@ -309,15 +314,15 @@ }, "editor": { "baseline": { - "brotliBytes": 1111442, + "brotliBytes": 1116539, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 1106815, + "gzipBytes": 1111706, "imageRawBytes": 156376, - "initialRawBytes": 2777788, - "largestAssetRawBytes": 746400, + "initialRawBytes": 2798377, + "largestAssetRawBytes": 746121, "otherAssetRawBytes": 0, - "ownedRawBytes": 115249, + "ownedRawBytes": 120853, "requestCount": 98, "scriptRequestCount": 82, "sourceOwners": [ @@ -668,17 +673,20 @@ "source:src/platform/ui/EmptyState.tsx", "source:src/platform/ui/Field.tsx", "source:src/platform/ui/Group.tsx", + "source:src/platform/ui/InputShell.tsx", "source:src/platform/ui/InvokeMark.tsx", "source:src/platform/ui/JsonPreview.tsx", "source:src/platform/ui/Menu.tsx", "source:src/platform/ui/MiddleTruncate.tsx", "source:src/platform/ui/PageShell.tsx", "source:src/platform/ui/Panel.tsx", + "source:src/platform/ui/Popover.tsx", "source:src/platform/ui/RenameDialog.tsx", "source:src/platform/ui/ResizableTextarea.tsx", "source:src/platform/ui/Row.tsx", "source:src/platform/ui/Scrollable.tsx", "source:src/platform/ui/SegmentTabs.tsx", + "source:src/platform/ui/SegmentedControl.tsx", "source:src/platform/ui/Select.tsx", "source:src/platform/ui/Slider.tsx", "source:src/platform/ui/StatusWidgetChip.tsx", @@ -699,6 +707,7 @@ "source:src/platform/ui/theme/system.ts", "source:src/platform/ui/theme/themes.ts", "source:src/platform/ui/toaster.tsx", + "source:src/platform/ui/useScrollAreaPhantomHeal.ts", "source:src/workbench/WorkbenchContext.tsx", "source:src/workbench/WorkbenchRuntime.tsx", "source:src/workbench/WorkbenchSessionController.tsx", @@ -786,6 +795,7 @@ "source:src/workbench/launchpad/pages/ProjectsPage.tsx", "source:src/workbench/launchpad/projects/NewProjectButton.tsx", "source:src/workbench/launchpad/projects/ProjectActionsMenu.tsx", + "source:src/workbench/launchpad/projects/ProjectActionsMenuHost.tsx", "source:src/workbench/launchpad/projects/ProjectCard.tsx", "source:src/workbench/launchpad/projects/ProjectCompatibilityBadge.tsx", "source:src/workbench/launchpad/projects/ProjectCover.tsx", @@ -928,7 +938,6 @@ "source:src/workbench/widgets/queue/manifest.ts", "source:src/workbench/widgets/server-status/manifest.ts", "source:src/workbench/widgets/upscale/manifest.ts", - "source:src/workbench/widgets/version-status/manifest.ts", "source:src/workbench/widgets/video/manifest.ts", "source:src/workbench/widgets/workflow/manifest.ts", "source:src/workbench/workbenchState.ts", @@ -936,15 +945,15 @@ ] }, "limits": { - "brotliBytes": 1122557, + "brotliBytes": 1127705, "cssRawBytes": 2181, "fontRawBytes": 221675, - "gzipBytes": 1117884, + "gzipBytes": 1122824, "imageRawBytes": 157940, - "initialRawBytes": 2805566, - "largestAssetRawBytes": 753864, + "initialRawBytes": 2826361, + "largestAssetRawBytes": 753583, "otherAssetRawBytes": 0, - "ownedRawBytes": 115249, + "ownedRawBytes": 120853, "requestCount": 98, "scriptRequestCount": 82 }, diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 2834af3cd20..18586073a13 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -1,5 +1,5 @@ { - "browserExecutable": "/home/lstein/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome", + "browserExecutable": "/home/toast/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome", "capturedAt": "2026-09-03", "routes": [ { @@ -25,13 +25,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 143.19999980926514, + "domContentLoadedMedianMs": 116, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 143.39999961853027, - "longestTaskMaxMs": 72, + "loadMedianMs": 116.1000000089407, + "longestTaskMaxMs": 68, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -40,25 +40,25 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 0, - "largestAssetRawBytes": 746400, - "otherRawBytes": 164980, + "largestAssetRawBytes": 746121, + "otherRawBytes": 165640, "requestCount": 38, - "scriptRawBytes": 1639192, + "scriptRawBytes": 1651131, "scriptRequestCount": 34, - "totalRawBytes": 1829995 + "totalRawBytes": 1842594 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 0, - "largestAssetRawBytes": 753864, - "otherRawBytes": 166630, + "largestAssetRawBytes": 753583, + "otherRawBytes": 167297, "requestCount": 38, - "scriptRawBytes": 1655584, + "scriptRawBytes": 1667643, "scriptRequestCount": 34, - "totalRawBytes": 1848295 + "totalRawBytes": 1861020 }, - "routeReadyMedianMs": 300.19999980926514, + "routeReadyMedianMs": 259.40000000596046, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "empty" }, @@ -85,13 +85,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 145.5, + "domContentLoadedMedianMs": 113.20000000298023, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 145.69999980926514, - "longestTaskMaxMs": 74, + "loadMedianMs": 113.20000000298023, + "longestTaskMaxMs": 68, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -100,25 +100,25 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 0, - "largestAssetRawBytes": 746400, - "otherRawBytes": 164980, + "largestAssetRawBytes": 746121, + "otherRawBytes": 165640, "requestCount": 38, - "scriptRawBytes": 1639192, + "scriptRawBytes": 1651131, "scriptRequestCount": 34, - "totalRawBytes": 1829995 + "totalRawBytes": 1842594 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 0, - "largestAssetRawBytes": 753864, - "otherRawBytes": 166630, + "largestAssetRawBytes": 753583, + "otherRawBytes": 167297, "requestCount": 38, - "scriptRawBytes": 1655584, + "scriptRawBytes": 1667643, "scriptRequestCount": 34, - "totalRawBytes": 1848295 + "totalRawBytes": 1861020 }, - "routeReadyMedianMs": 323, + "routeReadyMedianMs": 268.5, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "representative" }, @@ -145,40 +145,40 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 148.60000038146973, + "domContentLoadedMedianMs": 116.3999999910593, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 148.80000019073486, - "longestTaskMaxMs": 110, + "loadMedianMs": 116.59999999403954, + "longestTaskMaxMs": 100, "owner": "workbench", - "projectSwitchMedianMs": 48, + "projectSwitchMedianMs": 32.6000000089407, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 156376, - "largestAssetRawBytes": 746400, - "otherRawBytes": 164980, + "largestAssetRawBytes": 746121, + "otherRawBytes": 165640, "requestCount": 116, - "scriptRawBytes": 3321674, + "scriptRawBytes": 3349525, "scriptRequestCount": 111, - "totalRawBytes": 3668853 + "totalRawBytes": 3697364 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, - "largestAssetRawBytes": 753864, - "otherRawBytes": 166630, + "largestAssetRawBytes": 753583, + "otherRawBytes": 167297, "requestCount": 116, - "scriptRawBytes": 3354891, + "scriptRawBytes": 3383021, "scriptRequestCount": 111, - "totalRawBytes": 3705542 + "totalRawBytes": 3734338 }, - "routeReadyMedianMs": 733.2000007629395, + "routeReadyMedianMs": 684.0999999940395, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -205,40 +205,40 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 137.19999980926514, + "domContentLoadedMedianMs": 119.79999999701977, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 137.30000019073486, - "longestTaskMaxMs": 190, + "loadMedianMs": 119.90000000596046, + "longestTaskMaxMs": 149, "owner": "workbench", - "projectSwitchMedianMs": 80.39999961853027, + "projectSwitchMedianMs": 46.900000005960464, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 156376, - "largestAssetRawBytes": 746400, - "otherRawBytes": 164980, + "largestAssetRawBytes": 746121, + "otherRawBytes": 165640, "requestCount": 116, - "scriptRawBytes": 3321674, + "scriptRawBytes": 3349525, "scriptRequestCount": 111, - "totalRawBytes": 3668853 + "totalRawBytes": 3697364 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, - "largestAssetRawBytes": 753864, - "otherRawBytes": 166630, + "largestAssetRawBytes": 753583, + "otherRawBytes": 167297, "requestCount": 116, - "scriptRawBytes": 3354891, + "scriptRawBytes": 3383021, "scriptRequestCount": 111, - "totalRawBytes": 3705542 + "totalRawBytes": 3734338 }, - "routeReadyMedianMs": 482.8999996185303, + "routeReadyMedianMs": 451.70000000298023, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" }, @@ -247,31 +247,31 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 315113, + "largestAssetRawBytes": 314528, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 482582, + "scriptRawBytes": 481849, "scriptRequestCount": 7, - "totalRawBytes": 482582 + "totalRawBytes": 481849 }, "activatedResourceLimits": { "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 318265, + "largestAssetRawBytes": 317674, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 487408, + "scriptRawBytes": 486668, "scriptRequestCount": 7, - "totalRawBytes": 487408 + "totalRawBytes": 486668 }, - "domContentLoadedMedianMs": 128, + "domContentLoadedMedianMs": 121.8999999910593, "id": "editor-canvas", - "layoutAckMedianMs": 6.90000057220459, - "layoutReturnSwitchMedianMs": 54.80000019073486, - "layoutSwitchMedianMs": 99.90000057220459, - "loadMedianMs": 128.20000076293945, - "longestTaskMaxMs": 140, + "layoutAckMedianMs": 2.5999999940395355, + "layoutReturnSwitchMedianMs": 54.5, + "layoutSwitchMedianMs": 92.30000001192093, + "loadMedianMs": 122, + "longestTaskMaxMs": 145, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -280,25 +280,25 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 156376, - "largestAssetRawBytes": 746400, - "otherRawBytes": 164980, + "largestAssetRawBytes": 746121, + "otherRawBytes": 165640, "requestCount": 123, - "scriptRawBytes": 3804256, + "scriptRawBytes": 3831374, "scriptRequestCount": 118, - "totalRawBytes": 4151435 + "totalRawBytes": 4179213 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, - "largestAssetRawBytes": 753864, - "otherRawBytes": 166630, + "largestAssetRawBytes": 753583, + "otherRawBytes": 167297, "requestCount": 123, - "scriptRawBytes": 3842299, + "scriptRawBytes": 3869688, "scriptRequestCount": 118, - "totalRawBytes": 4192950 + "totalRawBytes": 4221006 }, - "routeReadyMedianMs": 1198.1999998092651, + "routeReadyMedianMs": 1082.800000011921, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "empty" }, @@ -307,31 +307,31 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 315113, + "largestAssetRawBytes": 314528, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 482582, + "scriptRawBytes": 481849, "scriptRequestCount": 7, - "totalRawBytes": 482582 + "totalRawBytes": 481849 }, "activatedResourceLimits": { "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 318265, + "largestAssetRawBytes": 317674, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 487408, + "scriptRawBytes": 486668, "scriptRequestCount": 7, - "totalRawBytes": 487408 + "totalRawBytes": 486668 }, - "domContentLoadedMedianMs": 141.80000019073486, + "domContentLoadedMedianMs": 121.70000000298023, "id": "editor-canvas", - "layoutAckMedianMs": 8.200000762939453, - "layoutReturnSwitchMedianMs": 45.40000057220459, - "layoutSwitchMedianMs": 98.89999961853027, - "loadMedianMs": 142.0999994277954, - "longestTaskMaxMs": 192, + "layoutAckMedianMs": 3.3999999910593033, + "layoutReturnSwitchMedianMs": 46.20000000298023, + "layoutSwitchMedianMs": 81, + "loadMedianMs": 121.79999999701977, + "longestTaskMaxMs": 193, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -340,25 +340,25 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 156376, - "largestAssetRawBytes": 746400, - "otherRawBytes": 164980, + "largestAssetRawBytes": 746121, + "otherRawBytes": 165640, "requestCount": 123, - "scriptRawBytes": 3804256, + "scriptRawBytes": 3831374, "scriptRequestCount": 118, - "totalRawBytes": 4151435 + "totalRawBytes": 4179213 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, - "largestAssetRawBytes": 753864, - "otherRawBytes": 166630, + "largestAssetRawBytes": 753583, + "otherRawBytes": 167297, "requestCount": 123, - "scriptRawBytes": 3842299, + "scriptRawBytes": 3869688, "scriptRequestCount": 118, - "totalRawBytes": 4192950 + "totalRawBytes": 4221006 }, - "routeReadyMedianMs": 1211.1999998092651, + "routeReadyMedianMs": 1155.7999999970198, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "representative" }, @@ -370,9 +370,9 @@ "largestAssetRawBytes": 173980, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 364243, + "scriptRawBytes": 364370, "scriptRequestCount": 6, - "totalRawBytes": 379656 + "totalRawBytes": 379783 }, "activatedResourceLimits": { "cssRawBytes": 15568, @@ -381,17 +381,17 @@ "largestAssetRawBytes": 175720, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 367886, + "scriptRawBytes": 368014, "scriptRequestCount": 6, - "totalRawBytes": 383453 + "totalRawBytes": 383581 }, - "domContentLoadedMedianMs": 130.9000005722046, + "domContentLoadedMedianMs": 121.70000000298023, "id": "editor-workflow", - "layoutAckMedianMs": 6.699999809265137, - "layoutReturnSwitchMedianMs": 32.09999942779541, - "layoutSwitchMedianMs": 92.5, - "loadMedianMs": 131.10000038146973, - "longestTaskMaxMs": 114, + "layoutAckMedianMs": 2.9000000059604645, + "layoutReturnSwitchMedianMs": 33.099999994039536, + "layoutSwitchMedianMs": 93.5, + "loadMedianMs": 121.70000000298023, + "longestTaskMaxMs": 105, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -400,25 +400,25 @@ "cssRawBytes": 17572, "fontRawBytes": 23664, "imageRawBytes": 156376, - "largestAssetRawBytes": 746400, - "otherRawBytes": 164980, + "largestAssetRawBytes": 746121, + "otherRawBytes": 165640, "requestCount": 123, - "scriptRawBytes": 3685917, + "scriptRawBytes": 3713895, "scriptRequestCount": 117, - "totalRawBytes": 4048509 + "totalRawBytes": 4077147 }, "resourceLimits": { "cssRawBytes": 17748, "fontRawBytes": 23901, "imageRawBytes": 157940, - "largestAssetRawBytes": 753864, - "otherRawBytes": 166630, + "largestAssetRawBytes": 753583, + "otherRawBytes": 167297, "requestCount": 123, - "scriptRawBytes": 3722777, + "scriptRawBytes": 3751034, "scriptRequestCount": 117, - "totalRawBytes": 4088995 + "totalRawBytes": 4117919 }, - "routeReadyMedianMs": 1158.0999994277954, + "routeReadyMedianMs": 1064.1000000089407, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "empty" }, @@ -430,9 +430,9 @@ "largestAssetRawBytes": 173980, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 364243, + "scriptRawBytes": 364370, "scriptRequestCount": 6, - "totalRawBytes": 379656 + "totalRawBytes": 379783 }, "activatedResourceLimits": { "cssRawBytes": 15568, @@ -441,17 +441,17 @@ "largestAssetRawBytes": 175720, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 367886, + "scriptRawBytes": 368014, "scriptRequestCount": 6, - "totalRawBytes": 383453 + "totalRawBytes": 383581 }, - "domContentLoadedMedianMs": 138.89999961853027, + "domContentLoadedMedianMs": 122.79999999701977, "id": "editor-workflow", - "layoutAckMedianMs": 13.40000057220459, - "layoutReturnSwitchMedianMs": 35, - "layoutSwitchMedianMs": 149.60000038146973, - "loadMedianMs": 139, - "longestTaskMaxMs": 370, + "layoutAckMedianMs": 3.5, + "layoutReturnSwitchMedianMs": 39.5, + "layoutSwitchMedianMs": 124.20000000298023, + "loadMedianMs": 123, + "longestTaskMaxMs": 372, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -460,25 +460,25 @@ "cssRawBytes": 17572, "fontRawBytes": 23664, "imageRawBytes": 156376, - "largestAssetRawBytes": 746400, - "otherRawBytes": 164980, + "largestAssetRawBytes": 746121, + "otherRawBytes": 165640, "requestCount": 123, - "scriptRawBytes": 3685917, + "scriptRawBytes": 3713895, "scriptRequestCount": 117, - "totalRawBytes": 4048509 + "totalRawBytes": 4077147 }, "resourceLimits": { "cssRawBytes": 17748, "fontRawBytes": 23901, "imageRawBytes": 157940, - "largestAssetRawBytes": 753864, - "otherRawBytes": 166630, + "largestAssetRawBytes": 753583, + "otherRawBytes": 167297, "requestCount": 123, - "scriptRawBytes": 3722777, + "scriptRawBytes": 3751034, "scriptRequestCount": 117, - "totalRawBytes": 4088995 + "totalRawBytes": 4117919 }, - "routeReadyMedianMs": 1298, + "routeReadyMedianMs": 1210.5, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "representative" }, @@ -505,13 +505,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 164.10000038146973, + "domContentLoadedMedianMs": 118.3999999910593, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 31.40000057220459, - "loadMedianMs": 164.19999980926514, - "longestTaskMaxMs": 104, + "layoutSwitchMedianMs": 26.200000002980232, + "loadMedianMs": 118.5, + "longestTaskMaxMs": 102, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -520,25 +520,25 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 156376, - "largestAssetRawBytes": 746400, - "otherRawBytes": 164980, + "largestAssetRawBytes": 746121, + "otherRawBytes": 165640, "requestCount": 116, - "scriptRawBytes": 3321674, + "scriptRawBytes": 3349525, "scriptRequestCount": 111, - "totalRawBytes": 3668853 + "totalRawBytes": 3697364 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, - "largestAssetRawBytes": 753864, - "otherRawBytes": 166630, + "largestAssetRawBytes": 753583, + "otherRawBytes": 167297, "requestCount": 116, - "scriptRawBytes": 3354891, + "scriptRawBytes": 3383021, "scriptRequestCount": 111, - "totalRawBytes": 3705542 + "totalRawBytes": 3734338 }, - "routeReadyMedianMs": 1423.6999998092651, + "routeReadyMedianMs": 1262.7000000029802, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -565,13 +565,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 154.30000019073486, + "domContentLoadedMedianMs": 119.90000000596046, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 97.80000019073486, - "loadMedianMs": 154.4000005722046, - "longestTaskMaxMs": 191, + "layoutSwitchMedianMs": 31.299999997019768, + "loadMedianMs": 120, + "longestTaskMaxMs": 130, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -580,25 +580,25 @@ "cssRawBytes": 2159, "fontRawBytes": 23664, "imageRawBytes": 156376, - "largestAssetRawBytes": 746400, - "otherRawBytes": 164980, + "largestAssetRawBytes": 746121, + "otherRawBytes": 165640, "requestCount": 116, - "scriptRawBytes": 3321674, + "scriptRawBytes": 3349525, "scriptRequestCount": 111, - "totalRawBytes": 3668853 + "totalRawBytes": 3697364 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, - "largestAssetRawBytes": 753864, - "otherRawBytes": 166630, + "largestAssetRawBytes": 753583, + "otherRawBytes": 167297, "requestCount": 116, - "scriptRawBytes": 3354891, + "scriptRawBytes": 3383021, "scriptRequestCount": 111, - "totalRawBytes": 3705542 + "totalRawBytes": 3734338 }, - "routeReadyMedianMs": 1579.7000007629395, + "routeReadyMedianMs": 1359.2000000029802, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" } @@ -833,17 +833,20 @@ "source:src/platform/ui/EmptyState.tsx", "source:src/platform/ui/Field.tsx", "source:src/platform/ui/Group.tsx", + "source:src/platform/ui/InputShell.tsx", "source:src/platform/ui/InvokeMark.tsx", "source:src/platform/ui/JsonPreview.tsx", "source:src/platform/ui/Menu.tsx", "source:src/platform/ui/MiddleTruncate.tsx", "source:src/platform/ui/PageShell.tsx", "source:src/platform/ui/Panel.tsx", + "source:src/platform/ui/Popover.tsx", "source:src/platform/ui/RenameDialog.tsx", "source:src/platform/ui/ResizableTextarea.tsx", "source:src/platform/ui/Row.tsx", "source:src/platform/ui/Scrollable.tsx", "source:src/platform/ui/SegmentTabs.tsx", + "source:src/platform/ui/SegmentedControl.tsx", "source:src/platform/ui/Select.tsx", "source:src/platform/ui/Slider.tsx", "source:src/platform/ui/StatusWidgetChip.tsx", @@ -864,6 +867,7 @@ "source:src/platform/ui/theme/system.ts", "source:src/platform/ui/theme/themes.ts", "source:src/platform/ui/toaster.tsx", + "source:src/platform/ui/useScrollAreaPhantomHeal.ts", "source:src/workbench/canvasSchemaVersion.ts", "source:src/workbench/components/WorkbenchSplashScreen.tsx", "source:src/workbench/hotkeys/catalog.ts", @@ -886,6 +890,7 @@ "source:src/workbench/launchpad/pages/ProjectsPage.tsx", "source:src/workbench/launchpad/projects/NewProjectButton.tsx", "source:src/workbench/launchpad/projects/ProjectActionsMenu.tsx", + "source:src/workbench/launchpad/projects/ProjectActionsMenuHost.tsx", "source:src/workbench/launchpad/projects/ProjectCard.tsx", "source:src/workbench/launchpad/projects/ProjectCompatibilityBadge.tsx", "source:src/workbench/launchpad/projects/ProjectCover.tsx", @@ -1165,6 +1170,7 @@ "source:src/features/generation/ui/GenerateGuidanceSection.tsx", "source:src/features/generation/ui/GenerateHeaderActions.tsx", "source:src/features/generation/ui/GenerateModelCard.tsx", + "source:src/features/generation/ui/GeneratePresetsPopover.tsx", "source:src/features/generation/ui/GenerateRenderSection.tsx", "source:src/features/generation/ui/GenerateSettingsForm.tsx", "source:src/features/generation/ui/GenerateWidgetView.tsx", @@ -1412,6 +1418,7 @@ "source:src/platform/ui/EmptyState.tsx", "source:src/platform/ui/Field.tsx", "source:src/platform/ui/Group.tsx", + "source:src/platform/ui/InputShell.tsx", "source:src/platform/ui/InvokeMark.tsx", "source:src/platform/ui/JsonPreview.tsx", "source:src/platform/ui/Menu.tsx", @@ -1420,11 +1427,13 @@ "source:src/platform/ui/PageShell.tsx", "source:src/platform/ui/Panel.tsx", "source:src/platform/ui/Picker.tsx", + "source:src/platform/ui/Popover.tsx", "source:src/platform/ui/RenameDialog.tsx", "source:src/platform/ui/ResizableTextarea.tsx", "source:src/platform/ui/Row.tsx", "source:src/platform/ui/Scrollable.tsx", "source:src/platform/ui/SegmentTabs.tsx", + "source:src/platform/ui/SegmentedControl.tsx", "source:src/platform/ui/Select.tsx", "source:src/platform/ui/Slider.tsx", "source:src/platform/ui/SliderNumberField.tsx", @@ -1449,6 +1458,7 @@ "source:src/platform/ui/theme/system.ts", "source:src/platform/ui/theme/themes.ts", "source:src/platform/ui/toaster.tsx", + "source:src/platform/ui/useScrollAreaPhantomHeal.ts", "source:src/workbench/WorkbenchContext.tsx", "source:src/workbench/WorkbenchRuntime.tsx", "source:src/workbench/WorkbenchSessionController.tsx", @@ -1690,6 +1700,7 @@ "source:src/workbench/launchpad/pages/ProjectsPage.tsx", "source:src/workbench/launchpad/projects/NewProjectButton.tsx", "source:src/workbench/launchpad/projects/ProjectActionsMenu.tsx", + "source:src/workbench/launchpad/projects/ProjectActionsMenuHost.tsx", "source:src/workbench/launchpad/projects/ProjectCard.tsx", "source:src/workbench/launchpad/projects/ProjectCompatibilityBadge.tsx", "source:src/workbench/launchpad/projects/ProjectCover.tsx", @@ -1869,7 +1880,6 @@ "source:src/workbench/widgets/server-status/implementation.ts", "source:src/workbench/widgets/server-status/manifest.ts", "source:src/workbench/widgets/upscale/manifest.ts", - "source:src/workbench/widgets/version-status/manifest.ts", "source:src/workbench/widgets/video/manifest.ts", "source:src/workbench/widgets/workflow/manifest.ts", "source:src/workbench/workbenchState.ts", @@ -2124,6 +2134,7 @@ "source:src/features/generation/ui/GenerateGuidanceSection.tsx", "source:src/features/generation/ui/GenerateHeaderActions.tsx", "source:src/features/generation/ui/GenerateModelCard.tsx", + "source:src/features/generation/ui/GeneratePresetsPopover.tsx", "source:src/features/generation/ui/GenerateRenderSection.tsx", "source:src/features/generation/ui/GenerateSettingsForm.tsx", "source:src/features/generation/ui/GenerateWidgetView.tsx", @@ -2375,6 +2386,7 @@ "source:src/platform/ui/Group.tsx", "source:src/platform/ui/HsvBoxPicker.tsx", "source:src/platform/ui/HsvWheelPicker.tsx", + "source:src/platform/ui/InputShell.tsx", "source:src/platform/ui/InvokeMark.tsx", "source:src/platform/ui/JsonPreview.tsx", "source:src/platform/ui/Menu.tsx", @@ -2383,11 +2395,13 @@ "source:src/platform/ui/PageShell.tsx", "source:src/platform/ui/Panel.tsx", "source:src/platform/ui/Picker.tsx", + "source:src/platform/ui/Popover.tsx", "source:src/platform/ui/RenameDialog.tsx", "source:src/platform/ui/ResizableTextarea.tsx", "source:src/platform/ui/Row.tsx", "source:src/platform/ui/Scrollable.tsx", "source:src/platform/ui/SegmentTabs.tsx", + "source:src/platform/ui/SegmentedControl.tsx", "source:src/platform/ui/Select.tsx", "source:src/platform/ui/Slider.tsx", "source:src/platform/ui/SliderNumberField.tsx", @@ -2413,6 +2427,7 @@ "source:src/platform/ui/theme/system.ts", "source:src/platform/ui/theme/themes.ts", "source:src/platform/ui/toaster.tsx", + "source:src/platform/ui/useScrollAreaPhantomHeal.ts", "source:src/workbench/WorkbenchContext.tsx", "source:src/workbench/WorkbenchRuntime.tsx", "source:src/workbench/WorkbenchSessionController.tsx", @@ -2658,6 +2673,7 @@ "source:src/workbench/launchpad/pages/ProjectsPage.tsx", "source:src/workbench/launchpad/projects/NewProjectButton.tsx", "source:src/workbench/launchpad/projects/ProjectActionsMenu.tsx", + "source:src/workbench/launchpad/projects/ProjectActionsMenuHost.tsx", "source:src/workbench/launchpad/projects/ProjectCard.tsx", "source:src/workbench/launchpad/projects/ProjectCompatibilityBadge.tsx", "source:src/workbench/launchpad/projects/ProjectCover.tsx", @@ -2972,7 +2988,6 @@ "source:src/workbench/widgets/server-status/implementation.ts", "source:src/workbench/widgets/server-status/manifest.ts", "source:src/workbench/widgets/upscale/manifest.ts", - "source:src/workbench/widgets/version-status/manifest.ts", "source:src/workbench/widgets/video/manifest.ts", "source:src/workbench/widgets/workflow/manifest.ts", "source:src/workbench/workbenchState.ts", @@ -3234,6 +3249,7 @@ "source:src/features/generation/ui/GenerateGuidanceSection.tsx", "source:src/features/generation/ui/GenerateHeaderActions.tsx", "source:src/features/generation/ui/GenerateModelCard.tsx", + "source:src/features/generation/ui/GeneratePresetsPopover.tsx", "source:src/features/generation/ui/GenerateRenderSection.tsx", "source:src/features/generation/ui/GenerateSettingsForm.tsx", "source:src/features/generation/ui/GenerateWidgetView.tsx", @@ -3545,6 +3561,7 @@ "source:src/platform/ui/EmptyState.tsx", "source:src/platform/ui/Field.tsx", "source:src/platform/ui/Group.tsx", + "source:src/platform/ui/InputShell.tsx", "source:src/platform/ui/InvokeMark.tsx", "source:src/platform/ui/JsonPreview.tsx", "source:src/platform/ui/Menu.tsx", @@ -3553,11 +3570,13 @@ "source:src/platform/ui/PageShell.tsx", "source:src/platform/ui/Panel.tsx", "source:src/platform/ui/Picker.tsx", + "source:src/platform/ui/Popover.tsx", "source:src/platform/ui/RenameDialog.tsx", "source:src/platform/ui/ResizableTextarea.tsx", "source:src/platform/ui/Row.tsx", "source:src/platform/ui/Scrollable.tsx", "source:src/platform/ui/SegmentTabs.tsx", + "source:src/platform/ui/SegmentedControl.tsx", "source:src/platform/ui/Select.tsx", "source:src/platform/ui/Slider.tsx", "source:src/platform/ui/SliderNumberField.tsx", @@ -3582,6 +3601,7 @@ "source:src/platform/ui/theme/system.ts", "source:src/platform/ui/theme/themes.ts", "source:src/platform/ui/toaster.tsx", + "source:src/platform/ui/useScrollAreaPhantomHeal.ts", "source:src/workbench/WorkbenchContext.tsx", "source:src/workbench/WorkbenchRuntime.tsx", "source:src/workbench/WorkbenchSessionController.tsx", @@ -3823,6 +3843,7 @@ "source:src/workbench/launchpad/pages/ProjectsPage.tsx", "source:src/workbench/launchpad/projects/NewProjectButton.tsx", "source:src/workbench/launchpad/projects/ProjectActionsMenu.tsx", + "source:src/workbench/launchpad/projects/ProjectActionsMenuHost.tsx", "source:src/workbench/launchpad/projects/ProjectCard.tsx", "source:src/workbench/launchpad/projects/ProjectCompatibilityBadge.tsx", "source:src/workbench/launchpad/projects/ProjectCover.tsx", @@ -4002,7 +4023,6 @@ "source:src/workbench/widgets/server-status/implementation.ts", "source:src/workbench/widgets/server-status/manifest.ts", "source:src/workbench/widgets/upscale/manifest.ts", - "source:src/workbench/widgets/version-status/manifest.ts", "source:src/workbench/widgets/video/manifest.ts", "source:src/workbench/widgets/workflow/manifest.ts", "source:src/workbench/workbenchState.ts", diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 17154e7b0b5..2503f962338 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -396,6 +396,7 @@ "remaining_one": "{{count}} image remaining. Open queue", "remaining_other": "{{count}} images remaining. Open queue", "route": "{{source}} to {{destination}} · {{graph}}", + "shiftCancelsAll": "Shift-click cancels all", "waitingForProgress": "Backend accepted batch; waiting for progress event.", "waitingToStart": "Waiting to start" }, @@ -1444,6 +1445,7 @@ "language": "Language", "languageDescription": "Interface language. New workbench copy falls back to English until translators add coverage.", "tabs": { + "about": "About", "appearance": "Appearance", "behavior": "Behavior", "developer": "Developer", @@ -1454,6 +1456,14 @@ "workspace": "Workspace", "imageMap": "Image Map" }, + "about": { + "title": "About Invoke", + "description": "Version and system information for this Invoke server.", + "github": "GitHub", + "discord": "Discord", + "loading": "Loading system information…", + "systemInformation": "System information" + }, "imageMapVocabulary": { "title": "Cluster Label Vocabulary", "description": "Supplementary terms the image map can use when labeling clusters, alongside its built-in vocabulary. This is a server-wide list.", @@ -1511,6 +1521,8 @@ "unshade": "Unshade" }, "loadingLabel": "Loading {{label}}", + "moveToLeftSide": "Move to left side", + "moveToRightSide": "Move to right side", "removeWidget": "Remove {{label}}", "panelLabel": "{{region}} widget panel", "resizePanel": "Resize {{region}} widget panel", @@ -1620,7 +1632,6 @@ "queueStatus": "Queue status", "serverStatus": "Server Status", "upscale": "Upscale", - "versionStatus": "Version", "workflow": "Workflow", "video": "Video", "color": "Color", @@ -1834,25 +1845,29 @@ "noPromptHistoryYet": "No prompt history yet.", "noPromptTriggersAvailable": "No prompt triggers available.", "noSupportedModels": "No compatible generation model was found on the backend.", - "noTextLlmInstalled": "No Text LLM installed", - "noVisionModelInstalled": "No vision model installed", "offCount_one": "{{count}} off", "offCount_other": "{{count}} off", "openModelManager": "Open Model Manager", "override": "Override", "positivePrompt": "Positive prompt", + "presetDeleteBody": "Delete the preset \"{{name}}\"? This cannot be undone.", + "presetDeleteTitle": "Delete preset?", "presetModelMissing": "This preset's model is not installed.", "presetName": "Preset name", "presets": "Presets", + "presetsEmpty": "No presets yet. Save the current settings to reuse them later.", + "presetsNoMatches": "No presets match your search.", "presetUnreadable": "This preset could not be read.", "promptHistory": "Prompt history", "promptHistoryEntries": "Prompt history entries", "promptHistoryKeyboardHelp": "Alt+Up/Down switches between prompts while focused.", "recentSeeds": "Recent seeds", + "renamePresetNamed": "Rename preset {{name}}", + "renamePresetTitle": "Rename preset", "resetToModelDefault": "Reset to model default", - "savePreset": "Save as preset…", "savePresetAction": "Save", "savePresetTitle": "Save preset", + "searchPresets": "Search presets", "secondsPerRun": "~{{value}}s/run", "sizePreviewHandle": "Drag to resize the generation size", "switchModelBody": "Switching to {{name}} turns off or removes: {{labels}}.", @@ -3224,6 +3239,7 @@ "emptyBoardUploadHint": "Drop media here or click to upload", "exportProjectFromBoard": "Export project (.invk)", "expandStarredItems": "Expand starred items", + "filterAndSortBoards": "Filter & sort boards", "generationProgress": "Generation progress", "generationProgressPercent": "Generation {{percentage}}%", "imageCount_one": "{{count}} image", @@ -3302,10 +3318,10 @@ "settingsGridSize": "Grid Size", "settingsThumbnails": "Thumbnails", "showPendingItems": "Show pending items", - "sortBoards": "Sort boards", "sortBoardsAscending": "Sort boards ascending", "sortBoardsBy": "Sort by", "sortBoardsDescending": "Sort boards descending", + "sortBy": "Sort by", "starImage": "Star {{name}}", "starredItems": "Starred", "starSelection": "Star selection", @@ -3348,11 +3364,6 @@ "label": "Server Status", "labelWithError": "{{label}}: {{error}}" }, - "versionStatus": { - "chipLabel": "Version 7.0", - "description": "Invoke V7 shell version 7.0.", - "label": "Version" - }, "video": { "accelerator": "{{label}} (fast)", "acceleratorHelp": "Runs the {{label}} distillation LoRA at {{steps}} steps. Slower but sometimes higher-quality results with it off.", diff --git a/invokeai/frontend/webv2/scripts/widget-sources.mjs b/invokeai/frontend/webv2/scripts/widget-sources.mjs index 50b451242ab..b84caa2e2cd 100644 --- a/invokeai/frontend/webv2/scripts/widget-sources.mjs +++ b/invokeai/frontend/webv2/scripts/widget-sources.mjs @@ -18,7 +18,6 @@ export const WIDGET_SOURCES = new Map([ ['src/workbench/widgets/project/implementation.ts', 'project'], ['src/workbench/widgets/queue-status/implementation.ts', 'queue-status'], ['src/workbench/widgets/server-status/implementation.ts', 'server-status'], - ['src/workbench/widgets/version-status/implementation.ts', 'version-status'], ['src/features/gallery/widget.ts', 'gallery'], ['src/features/generation/widget.ts', 'generate'], ['src/features/queue/ui/index.ts', 'queue'], diff --git a/invokeai/frontend/webv2/src/app/GenerationUiAdapter.tsx b/invokeai/frontend/webv2/src/app/GenerationUiAdapter.tsx index 71d28af0750..c1756f94687 100644 --- a/invokeai/frontend/webv2/src/app/GenerationUiAdapter.tsx +++ b/invokeai/frontend/webv2/src/app/GenerationUiAdapter.tsx @@ -159,9 +159,25 @@ export const GenerationUiAdapterProvider = ({ children }: { children: ReactNode // Hash navigation, matching the app.selectModelsTab command. Going through // useNavigate or the models UI store would pull either the router hooks or // the store into the editor/launchpad initial bundles (architecture budget). - // The manager opens on Add Models by default, which is where this link wants to land. - openManager: () => { - window.location.hash = `#/models?project=${encodeURIComponent(project.activeProjectId)}`; + // The manager opens on Add Models by default, which is where this link + // wants to land; a model type seeds its catalog filter through the same + // dynamic-import seam `WorkflowUiAdapter.openAddModels` uses, before the + // navigation, so the first paint is already filtered. + openManager: (options) => { + const navigateToManager = () => { + window.location.hash = `#/models?project=${encodeURIComponent(project.activeProjectId)}`; + }; + const modelType = options?.modelType; + + if (modelType === undefined) { + navigateToManager(); + return; + } + + void import('@features/models/launchpad').then(({ requestAddModelsTypeFilter }) => { + requestAddModelsTypeFilter(modelType); + navigateToManager(); + }); }, status: modelsStatus, }), diff --git a/invokeai/frontend/webv2/src/features/gallery/core/settings.ts b/invokeai/frontend/webv2/src/features/gallery/core/settings.ts index 04b7a7b7525..20f29b54569 100644 --- a/invokeai/frontend/webv2/src/features/gallery/core/settings.ts +++ b/invokeai/frontend/webv2/src/features/gallery/core/settings.ts @@ -65,7 +65,7 @@ export const DEFAULT_GALLERY_SETTINGS: GallerySettings = { showArchivedBoards: false, showDateBoards: false, showImageDimensions: false, - showOtherProjectBoards: true, + showOtherProjectBoards: false, showPendingItems: true, starredFirst: isGalleryStarredFirst('infinite'), thumbnailFit: 'square', diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardFilters.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardFilters.tsx index 70a179317f6..817c769df7b 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardFilters.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardFilters.tsx @@ -1,17 +1,17 @@ import type { GalleryBoardOrderBy, GalleryOrderDir } from '@features/gallery/core/types'; import { HStack, Icon, Input, InputGroup, Menu, Portal } from '@chakra-ui/react'; +import { DEFAULT_GALLERY_SETTINGS } from '@features/gallery/core/settings'; import { CloseButton, IconButton } from '@platform/ui/Button'; import { MenuContent } from '@platform/ui/Menu'; import { Tooltip } from '@platform/ui/Tooltip'; import { ArchiveIcon, - ArrowUpDownIcon, CalendarIcon, CheckIcon, - EyeIcon, FolderIcon, SearchIcon, + SlidersHorizontalIcon, type LucideIcon, } from 'lucide-react'; import { useCallback, useMemo, type ChangeEvent, type KeyboardEvent, type Ref } from 'react'; @@ -24,15 +24,10 @@ const SEARCH_START_ELEMENT = ; const SORT_MENU_POSITIONING = { placement: 'bottom-end' } as const; /** - * Board search plus the two list controls: what the panel shows, and how it is - * ordered. Both are icon-only in both layouts — the wide sidebar has no room - * for labels, and giving the stacked layout labelled variants would be exactly - * the size-conditional behaviour this redesign exists to remove. - * - * Visibility is one menu rather than a toggle button per group. Two buttons - * was already a row of unlabelled icons whose meaning you had to hover for, - * and other-project boards make three; a menu names each one and has room for - * the next. + * Board search plus one options menu covering both list controls: what the + * panel shows, and how it is ordered. A single icon-only trigger — the wide + * sidebar has no room for labels, and two adjacent unlabelled icon buttons + * read as noise you had to hover to tell apart. */ export const GalleryBoardFilters = ({ ref, @@ -50,11 +45,13 @@ export const GalleryBoardFilters = ({ const { t } = useTranslation(); const { actions, gallery } = useGalleryWidget(); const { boardOrderBy, boardOrderDir, showArchivedBoards, showDateBoards, showOtherProjectBoards } = gallery.settings; - const sortTriggerIds = useMenuTriggerIds(); - const visibilityTriggerIds = useMenuTriggerIds(); - // Any non-default lifts the trigger out of `fg.muted`, so a filtered panel is - // legible without opening the menu. - const isVisibilityFiltered = showArchivedBoards || showDateBoards || !showOtherProjectBoards; + const menuTriggerIds = useMenuTriggerIds(); + // Any non-default visibility lifts the trigger out of `fg.muted`, so a + // filtered panel is legible without opening the menu. + const isVisibilityFiltered = + showArchivedBoards !== DEFAULT_GALLERY_SETTINGS.showArchivedBoards || + showDateBoards !== DEFAULT_GALLERY_SETTINGS.showDateBoards || + showOtherProjectBoards !== DEFAULT_GALLERY_SETTINGS.showOtherProjectBoards; const handleSearchChange = useCallback( (event: ChangeEvent) => onSearchChange(event.currentTarget.value), @@ -121,16 +118,16 @@ export const GalleryBoardFilters = ({ onKeyDown={handleSearchKeyDown} /> - - + + - + @@ -161,21 +158,7 @@ export const GalleryBoardFilters = ({ onSelect={handleToggleOtherProjectBoards} /> - - - - - - - - - - - - - - - + {t('widgets.gallery.sortBoardsBy')} diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardMenu.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardMenu.tsx index 637919fb15e..0e340f84012 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardMenu.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardMenu.tsx @@ -181,8 +181,8 @@ export const GalleryBoardMenu = ({ onKeyDown={handleRenameKeyDown} /> - - - + + + + + + diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryQueuePlaceholderCell.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryQueuePlaceholderCell.tsx index 0af92e3f3cb..1986049b72a 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryQueuePlaceholderCell.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryQueuePlaceholderCell.tsx @@ -37,7 +37,6 @@ export const GalleryQueuePlaceholderCell = ({ bg="bg" borderColor={isSelected ? 'accent.solid' : 'border.subtle'} borderWidth={isSelected ? '2px' : '1px'} - cursor="pointer" h="full" overflow="hidden" position="relative" diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GallerySearchField.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GallerySearchField.tsx index 2b819faa491..3376befbfa5 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GallerySearchField.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GallerySearchField.tsx @@ -2,7 +2,7 @@ import type { DateTokenParse } from '@platform/search/dateTokens'; import { Box, Icon, Input } from '@chakra-ui/react'; import { parseDateTokens } from '@platform/search/dateTokens'; -import { formControlInteraction } from '@theme/recipes'; +import { InputShell } from '@platform/ui/InputShell'; import { SearchIcon } from 'lucide-react'; import { useCallback, useMemo, useRef, type ReactNode } from 'react'; @@ -52,13 +52,10 @@ export const getGallerySearchSegments = (value: string, parse: DateTokenParse): return segments; }; -const FIELD_CSS = { - ...formControlInteraction, - _focusWithin: { borderColor: 'accent.solid', outline: '1px solid {colors.accent.solid}' }, -} as const; - const PLACEHOLDER_PROPS = { color: 'fg.subtle' } as const; +const SEARCH_START_ELEMENT = ; + const MIRROR_CHIP_CSS = { borderRadius: 'sm', marginInline: '-0.5', @@ -105,78 +102,64 @@ export const GallerySearchField = ({ }, []); return ( - - - - {/* Flex-centred: Chakra reads a scale number in `lineHeight` as a - unitless multiplier, which drops the text out of the field. */} - - + {/* Flex-centred: Chakra reads a scale number in `lineHeight` as a + unitless multiplier, which drops the text out of the field. */} + - {endElement} - + + ); }; diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryStackedLayout.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryStackedLayout.tsx index ebf90e83a51..f7a1c963b3d 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryStackedLayout.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryStackedLayout.tsx @@ -4,7 +4,8 @@ import { GALLERY_BOARD_PANEL_MIN_HEIGHT_PX, GALLERY_MIN_GRID_HEIGHT_PX, } from '@features/gallery/core/settings'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { segmentTabsPanelId, segmentTabsTabId } from '@platform/ui'; +import { useCallback, useId, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { GalleryBoardsPanel } from './GalleryBoardsPanel'; @@ -26,6 +27,7 @@ export const GalleryStackedLayout = () => { const { t } = useTranslation(); const { actions, gallery } = useGalleryWidget(); const { boardPanelCollapsed, boardPanelHeightPx } = gallery.settings; + const viewTabsIdBase = useId(); const [dragHeightPx, setDragHeightPx] = useState(null); const [containerContentHeightPx, setContainerContentHeightPx] = useState(null); const [controlsHeightPx, setControlsHeightPx] = useState(null); @@ -121,7 +123,7 @@ export const GalleryStackedLayout = () => { )} - + { - + diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx index 36922c7e316..2a750b6cb0a 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryThumbnail.tsx @@ -52,7 +52,6 @@ const PREVIEW_IMAGE_STYLE = { const THUMBNAIL_BUTTON_STYLE = { background: 'transparent', border: 0, - cursor: 'pointer', display: 'block', height: '100%', inset: 0, diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryViewTabs.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryViewTabs.browser.test.tsx index f744b40e242..710fce90e47 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryViewTabs.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryViewTabs.browser.test.tsx @@ -1,6 +1,6 @@ /* oxlint-disable react-perf/jsx-no-new-object-as-prop */ import { ChakraProvider } from '@chakra-ui/react'; -import { getContrastRatio } from '@platform/ui/theme/contrastRatio.testing'; +import { compositeColors, getContrastRatio } from '@platform/ui/theme/contrastRatio.testing'; import { system } from '@theme/system'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; @@ -24,8 +24,7 @@ afterEach(async () => { const renderViewTabs = async (): Promise<{ counts: HTMLElement[]; - indicator: HTMLElement; - items: HTMLElement[]; + tabs: HTMLElement[]; }> => { host = document.createElement('div'); document.body.append(host); @@ -44,34 +43,20 @@ const renderViewTabs = async (): Promise<{ root?.render( - + ); }); - // Match the release accessibility journey: wait until Ark has measured the - // moving indicator and removed its initial checked-item fallback styling. - await act( - () => - new Promise((resolve) => { - requestAnimationFrame(() => - requestAnimationFrame(() => { - setTimeout(resolve, 200); - }) - ); - }) - ); - - const items = [...host.querySelectorAll('[data-part="item"]')]; + const tabs = [...host.querySelectorAll('[role="tab"]')]; return { - // The count is the only span in an item whose text is purely numeric. - counts: items.map((item) => - [...item.querySelectorAll('span')].find((span) => /^\d+$/.test(span.textContent!.trim()))! + // The count is the only span in a tab whose text is purely numeric. + counts: tabs.map((tab) => + [...tab.querySelectorAll('span')].find((span) => /^\d+$/.test(span.textContent!.trim()))! ), - indicator: host.querySelector('[data-part="indicator"]')!, - items, + tabs, }; }; @@ -82,19 +67,19 @@ describe('GalleryViewTabs', () => { expect(counts.map((count) => count.textContent)).toEqual(['148', '7']); }); - it('keeps the count readable against the selected segment itself', async () => { - const { counts, items } = await renderViewTabs(); - const checkedIndex = items.findIndex((item) => item.dataset.state === 'checked'); - const count = counts[checkedIndex]!; + it('keeps the count readable against the shown tab itself', async () => { + const { counts, tabs } = await renderViewTabs(); + const shownIndex = tabs.findIndex((tab) => tab.getAttribute('aria-selected') === 'true'); + const count = counts[shownIndex]!; const style = getComputedStyle(count); - // Axe evaluates the selected item's own background rather than a moving - // indicator sibling behind it, so that local surface must carry the fill. - const ratio = getContrastRatio( - style.color, - getComputedStyle(items[checkedIndex]!).backgroundColor, - Number(style.opacity) + // The shown fill is translucent, so measure against what it composites to + // over the surface beneath. + const effectiveTabBg = compositeColors( + getComputedStyle(tabs[shownIndex]!).backgroundColor, + getComputedStyle(document.body).backgroundColor ); + const ratio = getContrastRatio(style.color, effectiveTabBg, Number(style.opacity)); expect(ratio).toBeGreaterThanOrEqual(4.5); }); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryViewTabs.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryViewTabs.tsx index 1f2f9a1c34a..b1162888a38 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryViewTabs.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryViewTabs.tsx @@ -1,7 +1,8 @@ import type { GalleryView } from '@features/gallery/core/types'; -import { SegmentGroup, Text } from '@chakra-ui/react'; -import { useCallback } from 'react'; +import { Text } from '@chakra-ui/react'; +import { SegmentTabs } from '@platform/ui'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { getGalleryCountForView } from './galleryBoardLabels'; @@ -11,60 +12,61 @@ const GALLERY_VIEW_TABS = [ { labelKey: 'common.media', value: 'images' }, { labelKey: 'common.assets', value: 'assets' }, ] satisfies { labelKey: string; value: GalleryView }[]; -const CHECKED_VIEW_TAB_STYLES = { bg: 'accent.solid', color: 'accent.contrast' } as const; /** * Media / Assets, each carrying the selected board's count for that view so - * the split is legible before you switch. - * - * A segmented control rather than a tablist: this chooses which items the grid - * queries and owns no panel of its own. Real tabs publish `aria-controls` - * pointing at a tabpanel, and there is none to point at — the grid is a - * sibling slot each layout shell places independently. + * the split is legible before you switch. The same `SegmentTabs` strip the + * layer panes use; each layout shell wires the tabpanel by putting + * `segmentTabsPanelId(idBase)` on its grid container. */ -export const GalleryViewTabs = () => { +export const GalleryViewTabs = ({ idBase }: { idBase: string }) => { const { t } = useTranslation(); const { actions, gallery } = useGalleryWidget(); const selectedBoard = gallery.boards.find((board) => board.id === gallery.selectedBoardId); + // SegmentTabs re-fires selecting the active tab (its collapsible-toggle + // affordance); a same-view write would only dirty the widget values. const handleViewChange = useCallback( - (event: { value: string | null }) => { - if (event.value) { - actions.setView(event.value as GalleryView); + (value: GalleryView) => { + if (value !== gallery.galleryView) { + actions.setView(value); } }, - [actions] + [actions, gallery.galleryView] ); - return ( - - - {GALLERY_VIEW_TABS.map(({ labelKey, value }) => { + const tabs = useMemo( + () => + GALLERY_VIEW_TABS.map(({ labelKey, value }) => { const count = selectedBoard ? getGalleryCountForView(selectedBoard, value) : null; - return ( - - - + return { + id: value, + label: ( + {t(labelKey)} {count === null ? null : ( - // Dimmed from the item's own text colour rather than pinned to - // `fg.muted`: the checked item swaps to `accent.contrast`, and a - // fixed muted grey is unreadable on the accent fill. 0.8 is the - // dimmest that still clears 4.5:1 in both states. + // Dimmed from the tab's own text colour, so it tracks the + // shown/idle swap; 0.8 stays comfortably legible on both. {count} )} - - - ); - })} - + + ), + }; + }), + [selectedBoard, t] + ); + + return ( + ); }; diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWideLayout.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWideLayout.tsx index 7c31c70cb60..a295caa1d9f 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWideLayout.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryWideLayout.tsx @@ -1,6 +1,7 @@ import { Box, Flex, HStack, Spacer, Stack } from '@chakra-ui/react'; import { GALLERY_BOARD_PANEL_MAX_WIDTH_PX, GALLERY_BOARD_PANEL_MIN_WIDTH_PX } from '@features/gallery/core/settings'; -import { useCallback, useState } from 'react'; +import { segmentTabsPanelId, segmentTabsTabId } from '@platform/ui'; +import { useCallback, useId, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { GalleryBoardsPanel } from './GalleryBoardsPanel'; @@ -24,6 +25,7 @@ export const GalleryWideLayout = () => { const { t } = useTranslation(); const { actions, gallery } = useGalleryWidget(); const { boardPanelCollapsed, boardPanelWidthPx } = gallery.settings; + const viewTabsIdBase = useId(); const [dragWidthPx, setDragWidthPx] = useState(null); const displayWidthPx = dragWidthPx ?? boardPanelWidthPx; @@ -64,7 +66,7 @@ export const GalleryWideLayout = () => { looking at the grid — find, order, add, configure — collects at the end, so the eye has one place to go. */} - + @@ -76,7 +78,17 @@ export const GalleryWideLayout = () => { onUploadFiles={actions.uploadFiles} /> - + diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardGroups.test.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardGroups.test.ts index f6a548ea1a2..52434402650 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardGroups.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardGroups.test.ts @@ -71,6 +71,21 @@ describe('getGalleryBoardGroups', () => { expect(groups.yourBoards.map((board) => board.id)).toEqual(['none', 'mine', 'dogs', 'cats']); }); + /** The fetched list can lag a rename; the live project name wins for the open project's row. */ + it('shows the open project board under the live project name, and searches by it', () => { + const groups = groupsOf({ projectBoardId: 'mine', projectName: 'Renamed' }); + + expect(groups.yourBoards.find((board) => board.id === 'mine')?.name).toBe('Renamed'); + expect( + groupsOf({ projectBoardId: 'mine', projectName: 'Renamed', searchTerm: 'renamed' }).yourBoards + ).toContainEqual(expect.objectContaining({ id: 'mine' })); + expect( + groupsOf({ projectBoardId: 'mine', projectName: 'Renamed', searchTerm: 'My project' }).yourBoards.map( + (board) => board.id + ) + ).not.toContain('mine'); + }); + it('filters every section by a case-insensitive substring match', () => { const groups = groupsOf({ searchTerm: 'DOG' }); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardGroups.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardGroups.ts index 6d05717c041..fdcb920fbce 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardGroups.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryBoardGroups.ts @@ -46,8 +46,10 @@ export const getGalleryBoardGroups = ({ const matchesSearch = (name: string) => !normalizedSearchTerm || name.toLowerCase().includes(normalizedSearchTerm); const uncategorizedBoard = boards.find((board) => board.kind === 'uncategorized') ?? null; - const projectBoard = projectBoardId ? (boards.find((board) => board.id === projectBoardId) ?? null) : null; - const projectRowName = projectBoard?.name ?? projectName; + const fetchedProjectBoard = projectBoardId ? (boards.find((board) => board.id === projectBoardId) ?? null) : null; + // The board renames with its project server-side, but the fetched list can lag + // a rename — the live project name is authoritative for the open project's row. + const projectBoard = fetchedProjectBoard ? { ...fetchedProjectBoard, name: projectName } : null; const matchesBoardSearch = (board: GalleryBoard) => matchesSearch(getGalleryBoardLabel(board, t)); // The open project's own board is never "another project's", whatever the @@ -75,7 +77,7 @@ export const getGalleryBoardGroups = ({ const hasAnyMatch = yourBoards.length > 0 || dateBoards.length > 0 || archivedBoards.length > 0; const hasExactMatch = boards.some((board) => getGalleryBoardLabel(board, t).toLowerCase() === normalizedSearchTerm) || - projectRowName.toLowerCase() === normalizedSearchTerm; + projectName.toLowerCase() === normalizedSearchTerm; return { archivedBoards, diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryGridLayout.test.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryGridLayout.test.ts index 9570a73c6d0..0644ac1b37d 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryGridLayout.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryGridLayout.test.ts @@ -8,6 +8,7 @@ import { buildGalleryGridRows, GALLERY_GRID_GAP_PX, GALLERY_STARRED_HEADER_HEIGHT_PX, + GALLERY_STARRED_SEPARATOR_HEIGHT_PX, getGalleryCellSizePx, getGalleryColumnCount, getGalleryGridRowHeightPx, @@ -124,14 +125,16 @@ describe('buildGalleryGridRows', () => { expect(rows.map((row) => row.kind)).toEqual(['starred-header', 'cells', 'starred-gap', 'cells']); expect(rows[0]?.kind === 'starred-header' && rows[0].itemCount).toBe(1); expect(rows[1]?.kind === 'cells' && rows[1].section).toBe('starred'); + expect(rows[2]?.kind === 'starred-gap' && rows[2].withSeparator).toBe(true); expect(rows[3]?.kind === 'cells' && rows[3].section).toBe('regular'); }); - it('keeps the header but drops the starred rows while collapsed', () => { + it('keeps the header but drops the starred rows and the separator while collapsed', () => { const items = [createImageItem('starred-1', true), createImageItem('regular-1')]; const rows = buildRows({ isStarredOpen: false, items }); expect(rows.map((row) => row.kind)).toEqual(['starred-header', 'starred-gap', 'cells']); + expect(rows[1]?.kind === 'starred-gap' && rows[1].withSeparator).toBe(false); }); it('keeps regular row keys stable across a starred collapse so their cells are not recreated', () => { @@ -198,11 +201,16 @@ describe('buildGalleryGridRows', () => { describe('getGalleryGridRowHeightPx', () => { it('sizes chrome rows by their own constants and cell rows by the shared row height', () => { - const rows = buildRows({ items: [createImageItem('starred-1', true), createImageItem('regular-1')] }); + const items = [createImageItem('starred-1', true), createImageItem('regular-1')]; - expect(rows.map((row) => getGalleryGridRowHeightPx(row, 100))).toEqual([ + expect(buildRows({ items }).map((row) => getGalleryGridRowHeightPx(row, 100))).toEqual([ GALLERY_STARRED_HEADER_HEIGHT_PX, 100, + GALLERY_STARRED_SEPARATOR_HEIGHT_PX, + 100, + ]); + expect(buildRows({ isStarredOpen: false, items }).map((row) => getGalleryGridRowHeightPx(row, 100))).toEqual([ + GALLERY_STARRED_HEADER_HEIGHT_PX, GALLERY_GRID_GAP_PX, 100, ]); diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryGridLayout.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryGridLayout.ts index cda6bc9f628..c1938850efb 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryGridLayout.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryGridLayout.ts @@ -9,6 +9,8 @@ import { getGalleryPlaceholderInsertionIndex } from './galleryStateView'; export const GALLERY_GRID_GAP_PX = 4; export const GALLERY_STARRED_HEADER_HEIGHT_PX = 24; +/** The gap row grows to hold a hairline rule while the starred section is open. */ +export const GALLERY_STARRED_SEPARATOR_HEIGHT_PX = 13; const GALLERY_MIN_COLUMN_COUNT = 2; const GALLERY_MAX_COLUMN_COUNT = 12; @@ -61,7 +63,7 @@ export type GalleryGridSection = 'regular' | 'starred'; export type GalleryGridRow = | { cells: GalleryGridCell[]; key: string; kind: 'cells'; section: GalleryGridSection } - | { key: string; kind: 'starred-gap' } + | { key: string; kind: 'starred-gap'; withSeparator: boolean } | { itemCount: number; key: string; kind: 'starred-header' }; const getGalleryGridCellKey = (cell: GalleryGridCell): string => @@ -149,7 +151,9 @@ export const buildGalleryGridRows = ({ } if (regularCells.length > 0) { - rows.push({ key: 'starred-gap', kind: 'starred-gap' }); + // A visible rule only while the starred cells are showing; collapsed, the + // header already separates the sections. + rows.push({ key: 'starred-gap', kind: 'starred-gap', withSeparator: isStarredOpen }); } } @@ -165,7 +169,7 @@ export const getGalleryGridRowHeightPx = (row: GalleryGridRow, cellRowHeightPx: } if (row.kind === 'starred-gap') { - return GALLERY_GRID_GAP_PX; + return row.withSeparator ? GALLERY_STARRED_SEPARATOR_HEIGHT_PX : GALLERY_GRID_GAP_PX; } return cellRowHeightPx; diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.test.ts b/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.test.ts index 4fc3cd56d3e..a1ab0c2408e 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.test.ts +++ b/invokeai/frontend/webv2/src/features/gallery/ui/galleryStateView.test.ts @@ -201,7 +201,7 @@ describe('gallery state view', () => { showArchivedBoards: false, showDateBoards: false, showImageDimensions: false, - showOtherProjectBoards: true, + showOtherProjectBoards: false, showPendingItems: true, starredFirst: true, thumbnailFit: 'square', diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateConditioningRebalanceField.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateConditioningRebalanceField.tsx index 70a25aa1bab..bcaa9564def 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateConditioningRebalanceField.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateConditioningRebalanceField.tsx @@ -272,7 +272,7 @@ export const GenerateConditioningRebalanceField = ({ activePreset && setPresetPendingDelete(activePreset)} diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateHeaderActions.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateHeaderActions.tsx index 0c9aa986f38..6f0b38fdc36 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateHeaderActions.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateHeaderActions.tsx @@ -1,27 +1,18 @@ /* oxlint-disable react-perf/jsx-no-new-function-as-prop */ import type { GenerationModelCatalogItem as ModelConfig } from '@features/generation/contracts'; import type { VaeModelConfig } from '@features/generation/core/types'; -import type { MouseEvent } from 'react'; -import { Icon, Menu, Portal, Text } from '@chakra-ui/react'; -import { - getGenerateModelSelectionResult, - isSupportedGenerateModel, -} from '@features/generation/core/baseGenerationPolicies'; +import { Icon } from '@chakra-ui/react'; +import { isSupportedGenerateModel } from '@features/generation/core/baseGenerationPolicies'; import { normalizeGenerateSettings } from '@features/generation/core/settings'; -import { resolveGenerateWidgetValues } from '@features/generation/settings'; -import { IconButton, RenameDialog, Tooltip } from '@platform/ui'; -import { MenuContent } from '@platform/ui/Menu'; -import { BookmarkIcon, RotateCcwIcon, Trash2Icon } from 'lucide-react'; -import { useId, useMemo, useState } from 'react'; +import { IconButton, Tooltip } from '@platform/ui'; +import { RotateCcwIcon } from 'lucide-react'; +import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import type { GeneratePresetRecord } from './GenerationUiContext'; - import { flushGenerateDrafts } from './generateDraftRegistry'; -import { getGenerateFormCommitPatch } from './generateFormViewModel'; +import { GeneratePresetsPopover } from './GeneratePresetsPopover'; import { useGenerationUi } from './GenerationUiContext'; -import { notifyGenerateModelSelectionCleared } from './modelSelectionNotice'; import { getModelDefaultsPatch, getModelDefaultSettings, @@ -29,19 +20,13 @@ import { } from './shared/modelDefaultSettings'; /** - * Widget-header actions: the preset library (save/apply/delete named settings - * snapshots) and reset-every-model-governed-setting-to-model-defaults. They sit - * in the header because they act on the whole panel, not one zone of it. + * Widget-header actions: the preset library (save/apply/rename/delete named + * settings snapshots) and reset-every-model-governed-setting-to-model-defaults. + * They sit in the header because they act on the whole panel, not one zone. */ export const GenerateHeaderActions = () => { - const { i18n, t } = useTranslation(); + const { t } = useTranslation(); const ui = useGenerationUi(); - const [isSavingPreset, setIsSavingPreset] = useState(false); - // The Tooltip and the Menu render onto the same trigger element and each - // machine wants to own its id; sharing one keeps the menu anchored (without - // it the menu opens at the viewport origin). See RoutingControl.tsx. - const presetsTriggerId = useId(); - const presetsTriggerIds = useMemo(() => ({ trigger: presetsTriggerId }), [presetsTriggerId]); const models = ui.models.catalog; const projectId = ui.project.activeProjectId; const settings = normalizeGenerateSettings(ui.project.generateValues); @@ -70,98 +55,11 @@ export const GenerateHeaderActions = () => { ui.settings.patchGenerateSettings(getModelDefaultsPatch(settings, selectedModel, vaeModels), projectId); }; - const savePreset = (label: string) => { - // Flush so the snapshot carries what the user sees, not a debounce behind it. - flushGenerateDrafts(); - const snapshot = normalizeGenerateSettings(ui.project.generateValues); - - if (snapshot) { - ui.presets.save(label, { ...snapshot }); - } - }; - - const applyPreset = (record: GeneratePresetRecord) => { - const normalized = normalizeGenerateSettings(record.values); - - if (!normalized) { - ui.notifications.error(t('widgets.generate.presetUnreadable'), record.label); - return; - } - - const model = supportedModels.find((candidate) => candidate.key === normalized.modelKey); - - if (!model) { - ui.notifications.error(t('widgets.generate.presetModelMissing'), record.label); - return; - } - - // Reconcile against the current catalog (models installed or removed since - // the preset was saved), then commit through the same resolve + patch path - // the widget's own model selection takes. - const result = getGenerateModelSelectionResult({ currentValues: normalized, model, models }); - const resolved = resolveGenerateWidgetValues({ models, storedValues: { ...result.settings, model } }); - - if (!resolved) { - ui.notifications.error(t('widgets.generate.presetUnreadable'), record.label); - return; - } - - ui.settings.patchGenerateSettings(getGenerateFormCommitPatch(resolved.values), projectId); - notifyGenerateModelSelectionCleared({ - clearedLabels: result.clearedLabels, - locale: i18n.resolvedLanguage, - modelName: model.name, - notifications: ui.notifications, - t, - }); - }; - - const removePreset = (event: MouseEvent, presetId: string) => { - // The row's click applies the preset; the trash icon only deletes it. - event.stopPropagation(); - ui.presets.remove(presetId); - }; - - const presetsLabel = t('widgets.generate.presets'); const resetLabel = t('widgets.generate.resetAllToModelDefaults'); return ( <> - - - - - - - - - - - - setIsSavingPreset(true)}> - {t('widgets.generate.savePreset')} - - {ui.presets.presets.length > 0 ? : null} - {ui.presets.presets.map((preset) => ( - applyPreset(preset)}> - - {preset.label} - - removePreset(event, preset.id)} - > - - - - ))} - - - - + { - setIsSavingPreset(false)} - onSubmit={savePreset} - /> ); }; diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateModelCard.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateModelCard.tsx index e289a8a04d7..73934ff6d83 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateModelCard.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateModelCard.tsx @@ -15,7 +15,7 @@ import { isGenerateModelConfig, isVaeModelConfig } from '@features/generation/co import { Button } from '@platform/ui/Button'; import { ConfirmDialog } from '@platform/ui/ConfirmDialog'; import { Field } from '@platform/ui/Field'; -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { GenerationModelSelect as ModelSelect, useGenerationUi } from './GenerationUiContext'; @@ -52,6 +52,7 @@ export const GenerateModelCard = ({ const { i18n, t } = useTranslation(); const ui = useGenerationUi(); const { openManager } = ui.models; + const openManagerForMainModels = useCallback(() => openManager({ modelType: 'main' }), [openManager]); // A switch that clears incompatible settings waits behind a confirm. Only the // model is held; the transition is recomputed against live settings on // confirm, and the labels shown come from a preview run of the same move. @@ -164,7 +165,7 @@ export const GenerateModelCard = ({ {t('widgets.generate.noSupportedModels')} - diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GeneratePresetsPopover.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GeneratePresetsPopover.tsx new file mode 100644 index 00000000000..d531d231de0 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/ui/GeneratePresetsPopover.tsx @@ -0,0 +1,381 @@ +import type { GenerateSettings } from '@features/generation/core/types'; +import type { ChangeEvent } from 'react'; + +import { HStack, Icon, Input, InputGroup, Popover, Portal, Separator, Stack, Text } from '@chakra-ui/react'; +import { + getGenerateModelSelectionResult, + isSupportedGenerateModel, +} from '@features/generation/core/baseGenerationPolicies'; +import { normalizeGenerateSettings } from '@features/generation/core/settings'; +import { resolveGenerateWidgetValues } from '@features/generation/settings'; +import { + Button, + ConfirmDialog, + IconButton, + PopoverContent, + RenameDialog, + Row, + Scrollable, + Tooltip, +} from '@platform/ui'; +import { BookmarkIcon, CheckIcon, PencilIcon, PlusIcon, SearchIcon, Trash2Icon } from 'lucide-react'; +import { useCallback, useId, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { GeneratePresetRecord } from './GenerationUiContext'; + +import { flushGenerateDrafts } from './generateDraftRegistry'; +import { getGenerateFormCommitPatch } from './generateFormViewModel'; +import { useGenerationUi } from './GenerationUiContext'; +import { notifyGenerateModelSelectionCleared } from './modelSelectionNotice'; +import { PANEL_HEADER_CONTROL_HEIGHT, PromptPanelHeader } from './promptFields/PromptPanelHeader'; + +const POPOVER_POSITIONING = { placement: 'bottom-end' } as const; +const SEARCH_START_ELEMENT = ; +// The rows sit on the popover's `bg.muted` surface; hover one surface step up. +const PRESET_ROW_HOVER_PROPS = { bg: 'bg.emphasized/60' }; +const PRESET_ROW_SEPARATOR = ; +/** Below this many presets, a search box is more furniture than help. */ +const SEARCH_VISIBLE_MIN_PRESETS = 6; + +type PresetDialogState = { mode: 'save' } | { mode: 'rename'; preset: GeneratePresetRecord }; + +/** + * "These settings are still this preset" must ignore what applying a preset + * does not write (`getGenerateFormCommitPatch` drops `batchCount`) and what is + * presentation or volatile: prompt-box heights, the template view mode, and + * the seed while it randomizes. + */ +const getPresetComparisonKey = (settings: GenerateSettings): string => { + const comparable: Record = { ...settings }; + + delete comparable.batchCount; + delete comparable.negativePromptHeightPx; + delete comparable.positivePromptHeightPx; + delete comparable.promptTemplateViewMode; + + if (settings.shouldRandomizeSeed) { + delete comparable.seed; + } + + return JSON.stringify(comparable); +}; + +const PresetRow = ({ + isActive, + preset, + onApply, + onDelete, + onRename, +}: { + /** The current settings still equal this preset's snapshot. */ + isActive: boolean; + preset: GeneratePresetRecord; + onApply: (preset: GeneratePresetRecord) => void; + onDelete: (preset: GeneratePresetRecord) => void; + onRename: (preset: GeneratePresetRecord) => void; +}) => { + const { t } = useTranslation(); + const handleApply = useCallback(() => onApply(preset), [onApply, preset]); + const handleRename = useCallback(() => onRename(preset), [onRename, preset]); + const handleDelete = useCallback(() => onDelete(preset), [onDelete, preset]); + + return ( + + + + + + + + + + + + + + + + ); +}; + +/** + * The preset library, treated like the wildcards panel: a searchable managed + * list rather than a bare menu. Applying reconciles the snapshot against the + * current model catalog through the widget's own resolve + patch path. + */ +export const GeneratePresetsPopover = () => { + const { i18n, t } = useTranslation(); + const ui = useGenerationUi(); + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [dialog, setDialog] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); + // The Tooltip and the Popover share the trigger element, and each machine + // wants to own its id; sharing one keeps the popover anchored. + const triggerId = useId(); + const triggerIds = useMemo(() => ({ trigger: triggerId }), [triggerId]); + + const models = ui.models.catalog; + const projectId = ui.project.activeProjectId; + const presets = ui.presets.presets; + const supportedModels = useMemo(() => models.filter(isSupportedGenerateModel), [models]); + const settings = useMemo(() => normalizeGenerateSettings(ui.project.generateValues), [ui.project.generateValues]); + const canSave = supportedModels.some((model) => model.key === settings?.modelKey); + + const filteredPresets = useMemo(() => { + const term = searchTerm.trim().toLowerCase(); + + return term ? presets.filter((preset) => preset.label.toLowerCase().includes(term)) : presets; + }, [presets, searchTerm]); + + // Normalization builds its object in one code path, so serialized equality + // of the comparison keys is a faithful match test. + const presetKeys = useMemo( + () => + presets.map((preset) => { + const normalized = normalizeGenerateSettings(preset.values); + + return normalized ? getPresetComparisonKey(normalized) : null; + }), + [presets] + ); + const activePreset = useMemo(() => { + if (!settings) { + return null; + } + + const currentKey = getPresetComparisonKey(settings); + const index = presetKeys.findIndex((key) => key !== null && key === currentKey); + + return index >= 0 ? (presets[index] ?? null) : null; + }, [presetKeys, presets, settings]); + + const handleOpenChange = useCallback((event: { open: boolean }) => { + setIsOpen(event.open); + + if (!event.open) { + setSearchTerm(''); + } + }, []); + const handleSearchChange = useCallback( + (event: ChangeEvent) => setSearchTerm(event.currentTarget.value), + [] + ); + + const applyPreset = useCallback( + (record: GeneratePresetRecord) => { + const normalized = normalizeGenerateSettings(record.values); + + if (!normalized) { + ui.notifications.error(t('widgets.generate.presetUnreadable'), record.label); + return; + } + + const model = supportedModels.find((candidate) => candidate.key === normalized.modelKey); + + if (!model) { + ui.notifications.error(t('widgets.generate.presetModelMissing'), record.label); + return; + } + + // Reconcile against the current catalog (models installed or removed + // since the preset was saved), then commit through the same resolve + + // patch path the widget's own model selection takes. + const result = getGenerateModelSelectionResult({ currentValues: normalized, model, models }); + const resolved = resolveGenerateWidgetValues({ models, storedValues: { ...result.settings, model } }); + + if (!resolved) { + ui.notifications.error(t('widgets.generate.presetUnreadable'), record.label); + return; + } + + ui.settings.patchGenerateSettings(getGenerateFormCommitPatch(resolved.values), projectId); + notifyGenerateModelSelectionCleared({ + clearedLabels: result.clearedLabels, + locale: i18n.resolvedLanguage, + modelName: model.name, + notifications: ui.notifications, + t, + }); + setIsOpen(false); + }, + [i18n.resolvedLanguage, models, projectId, supportedModels, t, ui.notifications, ui.settings] + ); + + const openSaveDialog = useCallback(() => setDialog({ mode: 'save' }), []); + const openRenameDialog = useCallback((preset: GeneratePresetRecord) => setDialog({ mode: 'rename', preset }), []); + const closeDialog = useCallback(() => setDialog(null), []); + const handleDialogSubmit = useCallback( + (label: string) => { + if (dialog?.mode === 'rename') { + ui.presets.rename(dialog.preset.id, label); + return; + } + + // Flush so the snapshot carries what the user sees, not a debounce behind it. + flushGenerateDrafts(); + const snapshot = normalizeGenerateSettings(ui.project.generateValues); + + if (snapshot) { + ui.presets.save(label, { ...snapshot }); + } + }, + [dialog, ui.presets, ui.project.generateValues] + ); + + const cancelDelete = useCallback(() => setPendingDelete(null), []); + const confirmDelete = useCallback(() => { + if (pendingDelete) { + ui.presets.remove(pendingDelete.id); + } + + setPendingDelete(null); + }, [pendingDelete, ui.presets]); + + const presetsLabel = t('widgets.generate.presets'); + // Tooltip and accessible name carry the visible preset label (label-in-name). + const triggerLabel = activePreset ? `${presetsLabel} · ${activePreset.label}` : presetsLabel; + + return ( + <> + + + + {/* The applied preset's name rides beside the icon while the + current settings still match it, like the dynamic-prompts count. */} + + + {activePreset ? ( + + {activePreset.label} + + ) : null} + + + + + + + + + + + + {presets.length >= SEARCH_VISIBLE_MIN_PRESETS ? ( + + + + ) : null} + {presets.length === 0 ? ( + + {t('widgets.generate.presetsEmpty')} + + ) : filteredPresets.length === 0 ? ( + + {t('widgets.generate.presetsNoMatches')} + + ) : ( + + + {filteredPresets.map((preset) => ( + + ))} + + + )} + + + + + + + {dialog ? ( + + ) : null} + + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateRenderSection.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateRenderSection.tsx index f4470600cec..ab70d4bfe45 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateRenderSection.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateRenderSection.tsx @@ -316,7 +316,6 @@ const SeedField = ({ onCommit, settings }: Pick onCommit({ seed: item.seed, shouldRandomizeSeed: false })} diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerationUiContext.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerationUiContext.tsx index 1658e019327..726f9bb912f 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerationUiContext.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerationUiContext.tsx @@ -66,8 +66,11 @@ export interface GenerationUiAdapter { error: string | null; getBaseColorPalette(base: string): string; getBaseLabel(base: string): string; - /** Navigates to the model manager, which opens on Add Models by default. */ - openManager(): void; + /** + * Navigates to the model manager, which opens on Add Models by default; + * `modelType` pre-filters its starter catalog to the missing kind. + */ + openManager(options?: { modelType?: string }): void; status: 'error' | 'idle' | 'loaded' | 'loading'; }; notifications: { diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsButton.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsButton.browser.test.tsx index ffebdd470fa..78a4ae1985f 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsButton.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsButton.browser.test.tsx @@ -1,13 +1,8 @@ import type { DynamicPromptsConfig } from '@features/generation/core/dynamicPrompts'; -import { Box, ChakraProvider } from '@chakra-ui/react'; +import { ChakraProvider } from '@chakra-ui/react'; import { DynamicPromptsButton } from '@features/generation/ui/promptFields/DynamicPromptsButton'; -import { - captureRowInteractionStyles, - expectRowInteractionStylesToMatch, -} from '@features/generation/ui/promptFields/promptFieldsBrowserTestUtils'; import { PromptTextarea } from '@features/generation/ui/promptFields/PromptTextarea'; -import { Row } from '@platform/ui/Row'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { system } from '@theme/system'; import { act } from 'react'; @@ -49,13 +44,6 @@ const render = async (prompt: string, onUsePrompt = vi.fn()) => { root?.render( - - - - - { }); describe('dynamic prompts in the positive prompt field', () => { - it('shows expanded prompts on the standard popover surface', async () => { + it('shows every expanded prompt and uses the one that was clicked', async () => { const { onUsePrompt } = await render('a {red|green} cat'); await vi.waitFor(() => expect(findButton().textContent).toContain('2')); @@ -107,14 +95,9 @@ describe('dynamic prompts in the positive prompt field', () => { }); const rows = [...document.querySelectorAll('button')].filter((button) => button.textContent?.includes('a red cat')); - const content = document.querySelector('[data-scope="popover"][data-part="content"]')!; - const surface = getComputedStyle( - host!.querySelector('[data-testid="popover-surface-style-probe"]')! - ).backgroundColor; expect(rows.length).toBe(1); expect(document.body.textContent).toContain('a green cat'); - expect(getComputedStyle(content).backgroundColor).toBe(surface); await act(async () => { await userEvent.click(rows[0]!); @@ -123,27 +106,6 @@ describe('dynamic prompts in the positive prompt field', () => { expect(onUsePrompt).toHaveBeenCalledWith('a red cat'); }); - it('uses the shared Row interaction contract for each selectable expanded prompt', async () => { - await render('a {red|green} cat'); - const probe = host!.querySelector('button[aria-label="Row probe"]')!; - const hoverBackgroundColor = getComputedStyle( - host!.querySelector('[data-testid="row-hover-style-probe"]')! - ).backgroundColor; - const expected = await captureRowInteractionStyles(probe, hoverBackgroundColor); - - await vi.waitFor(() => expect(findButton().textContent).toContain('2')); - await act(async () => { - await userEvent.click(findButton()); - }); - - const row = [...document.querySelectorAll('button')].find( - (button) => - button.title === 'widgets.generate.dynamicPrompts.usePrompt' && button.textContent?.includes('a red cat') - )!; - - await expectRowInteractionStylesToMatch(expected, row, hoverBackgroundColor); - }); - it('is a labeled primary at rest, and widens only to carry a count', async () => { // A labeled button rather than a bare icon; the expansion count appears // beside the label once the prompt is dynamic. diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsButton.tsx b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsButton.tsx index 8e789baa3f5..09700e76c19 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsButton.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsButton.tsx @@ -1,11 +1,13 @@ import type { DynamicPromptsFieldConfig } from '@features/generation/ui/promptFields/DynamicPromptsPanel'; -import { Popover, Portal, SegmentGroup, Stack, Text } from '@chakra-ui/react'; +import { Popover, Portal, Stack, Text } from '@chakra-ui/react'; import { DynamicPromptsPanel } from '@features/generation/ui/promptFields/DynamicPromptsPanel'; import { WildcardsPanel } from '@features/generation/ui/promptFields/WildcardsPanel'; import { useDynamicPrompts } from '@features/generation/ui/useDynamicPrompts'; import { useWildcards } from '@features/generation/ui/useWildcards'; import { IconButton } from '@platform/ui/Button'; +import { PopoverContent } from '@platform/ui/Popover'; +import { SegmentTabs, segmentTabsPanelId, segmentTabsTabId } from '@platform/ui/SegmentTabs'; import { Tooltip } from '@platform/ui/Tooltip'; import { BracesIcon } from 'lucide-react'; import { useCallback, useId, useMemo, useState } from 'react'; @@ -33,6 +35,7 @@ export const DynamicPromptsButton = ({ }: DynamicPromptsButtonProps) => { const { t } = useTranslation(); const triggerId = useId(); + const tabsIdBase = useId(); const [isOpen, setIsOpen] = useState(false); const [tab, setTab] = useState<'preview' | 'wildcards'>('preview'); const expansion = useDynamicPrompts(positivePrompt, config); @@ -40,10 +43,7 @@ export const DynamicPromptsButton = ({ const popoverIds = useMemo(() => ({ trigger: triggerId }), [triggerId]); const handleOpenChange = useCallback((event: { open: boolean }) => setIsOpen(event.open), []); - const handleTabChange = useCallback( - (event: { value: string | null }) => setTab(event.value === 'wildcards' ? 'wildcards' : 'preview'), - [] - ); + const handleTabChange = useCallback((value: 'preview' | 'wildcards') => setTab(value), []); const closeWith = useCallback( (apply: (value: string) => void) => (value: string) => { apply(value); @@ -56,8 +56,8 @@ export const DynamicPromptsButton = ({ const tabItems = useMemo( () => [ - { label: t('widgets.generate.dynamicPrompts.preview'), value: 'preview' }, - { label: t('widgets.generate.dynamicPrompts.wildcards'), value: 'wildcards' }, + { id: 'preview' as const, label: t('widgets.generate.dynamicPrompts.preview') }, + { id: 'wildcards' as const, label: t('widgets.generate.dynamicPrompts.wildcards') }, ], [t] ); @@ -105,33 +105,42 @@ export const DynamicPromptsButton = ({ - + - - {/* `alignSelf` keeps the tabs to their content width; stretched across - the popover they read as a header band rather than a control. */} - - - - - {tab === 'preview' ? ( - - ) : ( - - )} + + + + {tab === 'preview' ? ( + + ) : ( + + )} + - + diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsControls.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsControls.browser.test.tsx index fa9fd7e0815..77d765d150a 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsControls.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsControls.browser.test.tsx @@ -89,13 +89,8 @@ const openPopover = async () => { }); }; -/** - * Segment labels intercept pointer events, so the visible text is the click - * target. i18n is not bootstrapped in browser tests, so labels render as keys — - * segments are addressed by their stable value instead. - */ -const segmentLabel = (value: string) => - document.querySelector(`[data-scope="segment-group"][data-part="item-text"][id$=":radio:label:${value}"]`); +/** i18n is not bootstrapped in browser tests, so tabs are addressed by their stable id. */ +const tabButton = (id: string) => document.querySelector(`[role="tab"][id$="-tab-${id}"]`); const captureError = (event: ErrorEvent) => pageErrors.push(event.message); @@ -137,7 +132,7 @@ describe('dynamic prompts popover controls', () => { } expect(pageErrors).toEqual([]); - expect(segmentLabel('preview'), 'popover should still be mounted').toBeTruthy(); + expect(tabButton('preview'), 'popover should still be mounted').toBeTruthy(); }); it('sends a click on the seed label to the switch, not the number input', async () => { @@ -258,22 +253,11 @@ describe('dynamic prompts popover controls', () => { const summaryHeight = headerControl(); await act(async () => { - await userEvent.click(segmentLabel('wildcards')!); + await userEvent.click(tabButton('wildcards')!); }); // The summary badge and the "New wildcard" button sit in the same slot on // either tab, so switching tabs must not change the header's shape. expect(headerControl()).toBe(summaryHeight); }); - - it('keeps the tabs to their content width rather than stretching them', async () => { - await render(vi.fn()); - await openPopover(); - - const tabs = segmentLabel('preview')!.closest('[data-scope="segment-group"][data-part="root"]')!; - - // Asserting the computed alignment rather than a pixel width: browser tests - // render i18n keys, so the labels are not their real size here. - expect(getComputedStyle(tabs).alignSelf).toBe('start'); - }); }); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsPanel.tsx b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsPanel.tsx index 8e9f866e1f3..8fa758abc9d 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsPanel.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/DynamicPromptsPanel.tsx @@ -23,6 +23,13 @@ import { useTranslation } from 'react-i18next'; /** Rendering every row of a 10,000-prompt expansion would cost more than it tells the user. */ const MAX_PREVIEW_ROWS = 200; const TABULAR_NUMS = { fontVariantNumeric: 'tabular-nums' } as const; +// The rows sit on the popover's `bg.muted` surface, where the row recipe's +// `bg.muted/60` hover is invisible — hover needs the next surface step. +const PROMPT_ROW_HOVER_PROPS = { bg: 'bg.emphasized/60' } as const; +// The preview text is the row's whole point; a disabled row loses its +// affordances (row-recipe hover, dimming, not-allowed cursor), not its ink. +const DISABLED_PROMPT_ROW_PROPS = { cursor: 'default', opacity: 1 } as const; +const NO_HOVER_PROPS = { bg: 'transparent' } as const; const MENU_POSITIONING = { placement: 'bottom-start' } as const; const SWITCH_CHECKED = { bg: 'accent.solid' } as const; @@ -211,6 +218,9 @@ export const DynamicPromptsPanel = ({ void; @@ -254,10 +266,12 @@ const DynamicPromptRow = ({ px="2" py="1.5" textStyle="xs" - title={t('widgets.generate.dynamicPrompts.usePrompt')} + title={isDisabled ? undefined : t('widgets.generate.dynamicPrompts.usePrompt')} whiteSpace="nowrap" + _disabled={DISABLED_PROMPT_ROW_PROPS} + _hover={isDisabled ? NO_HOVER_PROPS : PROMPT_ROW_HOVER_PROPS} > - ); @@ -218,16 +222,23 @@ export const AddPromptTriggerButton = ({ onOpenPromptTriggerPicker: (anchorElement: HTMLElement) => void; }) => { const { t } = useTranslation(); + // Not disabled while open like it used to be: the greyed button read as + // broken next to the other action popovers' expanded tint. The guard keeps + // the dismiss-then-click sequence from immediately reopening. const handleClick = useCallback( - (event: MouseEvent) => onOpenPromptTriggerPicker(event.currentTarget), - [onOpenPromptTriggerPicker] + (event: MouseEvent) => { + if (!isOpen) { + onOpenPromptTriggerPicker(event.currentTarget); + } + }, + [isOpen, onOpenPromptTriggerPicker] ); return ( - + {/* With nothing to search or scroll, the popover hugs its empty state instead of holding the full list height open. */} @@ -327,7 +338,7 @@ export const PromptTriggerPopover = ({ )} - + @@ -337,8 +348,13 @@ export const PromptTriggerPopover = ({ const PromptTriggerEmptyState = () => { const { t } = useTranslation(); + // The same anatomy as the image-to-prompt popover's no-model branch: + // uppercase title, one subtle line, the model-manager button. return ( - + + + {t('widgets.generate.addPromptTrigger')} + {t('widgets.generate.noPromptTriggersAvailable')} @@ -463,12 +479,8 @@ const ExpandPromptButton = ({ positioning={POPOVER_POSITIONING_BOTTOM_END} onOpenChange={handleOpenChange} > - + {/* Always the feature name: the popover explains a missing model and offers the way out. */} + - + @@ -493,7 +505,7 @@ const ExpandPromptButton = ({ {t('widgets.generate.installTextLlmToExpandPrompts')} - + ) : ( <> @@ -528,7 +540,7 @@ const ExpandPromptButton = ({ )} - + @@ -639,12 +651,7 @@ const ImageToPromptButton = ({ positioning={POPOVER_POSITIONING_BOTTOM_END} onOpenChange={handleOpenChange} > - + - + @@ -669,7 +676,7 @@ const ImageToPromptButton = ({ {t('widgets.generate.installVisionModelToGeneratePrompts')} - + ) : ( <> @@ -711,7 +718,7 @@ const ImageToPromptButton = ({ )} - + @@ -757,7 +764,7 @@ const PositivePromptHistoryButton = ({ onUsePrompt }: Pick - + @@ -797,7 +804,7 @@ const PositivePromptHistoryButton = ({ onUsePrompt }: Pick - + diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTemplateEditor.tsx b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTemplateEditor.tsx index c74c58ea2fb..5d8a8b7ce09 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTemplateEditor.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTemplateEditor.tsx @@ -306,7 +306,6 @@ export const PromptTemplateEditor = ({ = {}): PromptTe ...overrides, }); -const StatefulPromptTemplatesPanel = ({ catalog }: { catalog: PromptTemplateCatalog }) => { - const [activeTemplate, setActiveTemplate] = useState(null); - - return ( - <> - - - - - - - ); -}; - const render = async (element: React.ReactNode) => { host = document.createElement('div'); host.style.width = '400px'; @@ -244,22 +217,26 @@ describe('the prompt templates panel', () => { }); }); - it('uses the shared Row interaction contract for inactive templates and keeps management controls separate', async () => { - await render(); + it('keeps management controls outside the inactive template row button', async () => { + await render( + + ); - const probe = host!.querySelector('button[aria-label="Row probe"]')!; const row = buttonWithText('Cinematic'); - const hoverBackgroundColor = getComputedStyle( - host!.querySelector('[data-testid="row-hover-style-probe"]')! - ).backgroundColor; const edit = host!.querySelector('button[aria-label="Edit: Cinematic"]')!; const remove = host!.querySelector('button[aria-label="Delete: Cinematic"]')!; expect(row.getAttribute('aria-current')).toBeNull(); expect(row.contains(edit)).toBe(false); expect(row.contains(remove)).toBe(false); - - await expectRowInteractionsToMatch(probe, row, hoverBackgroundColor); }); it('detaches rather than clears when the applied template is deleted', async () => { diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTemplatesButton.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTemplatesButton.browser.test.tsx index a9b3e7d5004..16960a0763e 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTemplatesButton.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTemplatesButton.browser.test.tsx @@ -1,7 +1,7 @@ import type { PromptTemplateSnapshot } from '@features/generation/core/promptTemplates'; import type { PromptTemplateCatalog } from '@features/generation/ui/usePromptTemplates'; -import { Box, ChakraProvider } from '@chakra-ui/react'; +import { ChakraProvider } from '@chakra-ui/react'; import { PromptTemplatesButton } from '@features/generation/ui/promptFields/PromptTemplatesButton'; import { system } from '@theme/system'; import i18next from 'i18next'; @@ -62,7 +62,6 @@ const render = async (activeTemplate: PromptTemplateSnapshot | null) => { root?.render( - @@ -85,7 +84,7 @@ afterEach(async () => { }); describe('the prompt templates button', () => { - it('opens the catalog lazily on the standard popover surface', async () => { + it('opens the catalog lazily', async () => { await render(null); expect(lastIsEnabled()).toBe(false); @@ -95,11 +94,7 @@ describe('the prompt templates button', () => { }); expect(lastIsEnabled()).toBe(true); - const content = document.querySelector('[data-scope="popover"][data-part="content"]')!; - const surface = getComputedStyle( - host!.querySelector('[data-testid="popover-surface-style-probe"]')! - ).backgroundColor; - expect(getComputedStyle(content).backgroundColor).toBe(surface); + expect(document.querySelector('[data-scope="popover"][data-part="content"]')).not.toBeNull(); }); it('dims the name and says so when the applied template is gone', async () => { diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTemplatesButton.tsx b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTemplatesButton.tsx index d5ecf5fb44b..bec90638c99 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTemplatesButton.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTemplatesButton.tsx @@ -10,6 +10,7 @@ import { useOnPendingPromptTemplateDraft } from '@features/generation/ui/promptT import { isPromptTemplateMissing, usePromptTemplates } from '@features/generation/ui/usePromptTemplates'; import { IconButton } from '@platform/ui/Button'; import { MiddleTruncate } from '@platform/ui/MiddleTruncate'; +import { PopoverContent } from '@platform/ui/Popover'; import { Tooltip } from '@platform/ui/Tooltip'; import { LayoutTemplateIcon } from 'lucide-react'; import { useCallback, useId, useMemo, useState } from 'react'; @@ -145,7 +146,7 @@ export const PromptTemplatesButton = ({ - + {editorTarget ? ( )} - + diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTemplatesPanel.tsx b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTemplatesPanel.tsx index b5697a3f06a..38054ff184f 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTemplatesPanel.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTemplatesPanel.tsx @@ -3,7 +3,7 @@ import type { PromptTemplateRecord } from '@features/generation/data/promptTempl import type { PromptTemplateCatalog } from '@features/generation/ui/usePromptTemplates'; import type { ChangeEvent } from 'react'; -import { Box, HStack, Input, Separator, Stack, Text } from '@chakra-ui/react'; +import { Box, HStack, Icon, Input, Separator, Stack, Text } from '@chakra-ui/react'; import { searchCatalog } from '@features/generation/core/catalogSearch'; import { PROMPT_TEMPLATE_PLACEHOLDER } from '@features/generation/core/promptTemplates'; import { toPromptTemplateSnapshot } from '@features/generation/data/promptTemplates'; @@ -22,11 +22,14 @@ import { ConfirmDialog } from '@platform/ui/ConfirmDialog'; import { Row } from '@platform/ui/Row'; import { Scrollable } from '@platform/ui/Scrollable'; import { Tooltip } from '@platform/ui/Tooltip'; -import { DownloadIcon, ImageIcon, PencilIcon, PlusIcon, TrashIcon, UploadIcon } from 'lucide-react'; +import { CheckIcon, DownloadIcon, ImageIcon, PencilIcon, PlusIcon, TrashIcon, UploadIcon } from 'lucide-react'; import { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; const THUMBNAIL_SIZE = '7'; +// The rows sit on the popover's `bg.muted` surface, where the row recipe's +// `bg.muted/60` hover is invisible — hover needs the next surface step. +const TEMPLATE_ROW_HOVER_PROPS = { bg: 'bg.emphasized/60' }; const TEMPLATE_THUMBNAIL_FALLBACK = ( {onEdit ? ( diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTriggerAutocomplete.tsx b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTriggerAutocomplete.tsx index c3b9373dd76..1179475471f 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTriggerAutocomplete.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/PromptTriggerAutocomplete.tsx @@ -143,7 +143,6 @@ const AutocompleteOption = ({ aria-selected={isActive} bg={isActive ? 'bg.emphasized' : undefined} color="fg" - cursor="pointer" fontSize="xs" id={id} px="2" diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/SystemPromptsField.tsx b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/SystemPromptsField.tsx index 24fadda79d5..6135214337e 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/SystemPromptsField.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/SystemPromptsField.tsx @@ -182,6 +182,13 @@ export const SystemPromptsField = ({ catalog, onSelect, selectedId }: SystemProm const handleSelectChange = useCallback(({ value }: { value: string[] }) => onSelect(value[0] ?? null), [onSelect]); const selectValue = useMemo(() => (selectedId ? [selectedId] : []), [selectedId]); + // The Select primitive renders `valueText` verbatim in the closed trigger, so + // it must carry the selected prompt's name — a static string would mask the + // selection ("None" while a prompt is active). + const selectedName = useMemo( + () => (selectedId ? (prompts.find((prompt) => prompt.id === selectedId)?.name ?? null) : null), + [prompts, selectedId] + ); if (isManaging && editorTarget) { return ( @@ -289,7 +296,7 @@ export const SystemPromptsField = ({ catalog, onSelect, selectedId }: SystemProm id={selectId} size="xs" value={selectValue} - valueText={t('widgets.generate.systemPrompts.none')} + valueText={selectedName ?? t('widgets.generate.systemPrompts.none')} onValueChange={handleSelectChange} /> diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/WildcardsPanel.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/WildcardsPanel.browser.test.tsx index 1764f1a47d0..a32df15b056 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/WildcardsPanel.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/WildcardsPanel.browser.test.tsx @@ -1,11 +1,9 @@ import type { WildcardCatalog } from '@features/generation/ui/useWildcards'; -import { Box, ChakraProvider } from '@chakra-ui/react'; -import { expectRowInteractionsToMatch } from '@features/generation/ui/promptFields/promptFieldsBrowserTestUtils'; +import { ChakraProvider } from '@chakra-ui/react'; import { WildcardsPanel } from '@features/generation/ui/promptFields/WildcardsPanel'; import { WILDCARD_COLLECTION_FORMATS } from '@features/generation/ui/wildcardFiles'; import { accountLifecycle } from '@platform/state/accountLifecycle'; -import { Row } from '@platform/ui/Row'; import { system } from '@theme/system'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; @@ -34,8 +32,7 @@ const catalog: WildcardCatalog = { const renderPanel = async ( showSyntaxHighlighting = true, panelCatalog: WildcardCatalog = catalog, - onInsert = vi.fn(), - includeRowProbe = false + onInsert = vi.fn() ) => { host = document.createElement('div'); host.style.width = '380px'; @@ -45,16 +42,6 @@ const renderPanel = async ( await act(() => { root?.render( - {includeRowProbe ? ( - <> - - - - - - ) : null} ); @@ -234,14 +221,10 @@ describe('wildcard list interactions', () => { const selectableWildcardButton = () => host!.querySelector('button[title="widgets.generate.dynamicPrompts.insertWildcard"]')!; - it('uses the shared Row interaction contract without nesting the edit controls', async () => { + it('keeps the edit controls outside the selectable row button', async () => { const onInsert = vi.fn(); - await renderPanel(true, catalog, onInsert, true); + await renderPanel(true, catalog, onInsert); - const probe = host!.querySelector('button[aria-label="Row probe"]')!; - const hoverBackgroundColor = getComputedStyle( - host!.querySelector('[data-testid="row-hover-style-probe"]')! - ).backgroundColor; const row = selectableWildcardButton(); const edit = host!.querySelector('button[aria-label="common.edit"]')!; const remove = host!.querySelector('button[aria-label="common.delete"]')!; @@ -249,8 +232,6 @@ describe('wildcard list interactions', () => { expect(row.contains(edit)).toBe(false); expect(row.contains(remove)).toBe(false); - await expectRowInteractionsToMatch(probe, row, hoverBackgroundColor); - await act(async () => { await userEvent.click(row); }); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/promptFieldsBrowserTestUtils.ts b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/promptFieldsBrowserTestUtils.ts deleted file mode 100644 index 7c4261b5145..00000000000 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/promptFieldsBrowserTestUtils.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { act } from 'react'; -import { expect, vi } from 'vitest'; -import { userEvent } from 'vitest/browser'; - -type FinalComputedStyles = Readonly>>; - -export const waitForComputedStyles = async (element: Element, expected: FinalComputedStyles) => { - await vi.waitFor(() => { - const styles = getComputedStyle(element); - - for (const property of Object.keys(expected) as (keyof FinalComputedStyles)[]) { - expect(styles[property]).toBe(expected[property]); - } - }); -}; - -export const captureRowInteractionStyles = async (probe: HTMLButtonElement, hoverBackgroundColor: string) => { - await act(async () => { - await focusWithKeyboard(probe); - await userEvent.hover(probe); - }); - await waitForComputedStyles(probe, { backgroundColor: hoverBackgroundColor }); - const expected = getInteractionStyles(probe); - - await act(async () => { - await userEvent.unhover(probe); - }); - - return expected; -}; - -export const expectRowInteractionStylesToMatch = async ( - expected: ReturnType, - row: HTMLButtonElement, - hoverBackgroundColor: string -) => { - await act(async () => { - await focusWithKeyboard(row); - await userEvent.hover(row); - }); - await waitForComputedStyles(row, { backgroundColor: hoverBackgroundColor }); - - expect(getInteractionStyles(row)).toEqual(expected); -}; - -export const expectRowInteractionsToMatch = async ( - probe: HTMLButtonElement, - row: HTMLButtonElement, - hoverBackgroundColor: string -) => { - const expected = await captureRowInteractionStyles(probe, hoverBackgroundColor); - await expectRowInteractionStylesToMatch(expected, row, hoverBackgroundColor); -}; - -export const getXsButtonDensityStyles = (element: HTMLElement) => { - const styles = getComputedStyle(element); - - return { - borderBottomColor: styles.borderBottomColor, - borderBottomStyle: styles.borderBottomStyle, - borderBottomWidth: styles.borderBottomWidth, - borderLeftColor: styles.borderLeftColor, - borderLeftStyle: styles.borderLeftStyle, - borderLeftWidth: styles.borderLeftWidth, - borderRightColor: styles.borderRightColor, - borderRightStyle: styles.borderRightStyle, - borderRightWidth: styles.borderRightWidth, - borderTopColor: styles.borderTopColor, - borderTopStyle: styles.borderTopStyle, - borderTopWidth: styles.borderTopWidth, - fontSize: styles.fontSize, - fontWeight: styles.fontWeight, - lineHeight: styles.lineHeight, - whiteSpace: styles.whiteSpace, - }; -}; - -export const matchElementWidth = (element: HTMLElement, reference: HTMLElement) => { - element.style.width = `${reference.offsetWidth}px`; -}; - -export const getElementBoxMetrics = (element: HTMLElement) => ({ - clientHeight: element.clientHeight, - clientWidth: element.clientWidth, - offsetHeight: element.offsetHeight, - offsetWidth: element.offsetWidth, - scrollHeight: element.scrollHeight, - scrollWidth: element.scrollWidth, -}); - -export const getRenderedLineCount = (element: HTMLElement) => { - const range = document.createRange(); - range.selectNodeContents(element); - const lineTops = new Set( - [...range.getClientRects()].filter((rect) => rect.width > 0).map((rect) => Math.round(rect.top * 100) / 100) - ); - - return lineTops.size; -}; - -const focusWithKeyboard = async (element: HTMLButtonElement) => { - for (let index = 0; index < 12; index += 1) { - if (document.activeElement === element) { - return; - } - await userEvent.tab(); - } - - throw new Error(`Could not focus ${element.title || element.textContent} with the keyboard`); -}; - -const getInteractionStyles = (element: HTMLElement) => { - const styles = getComputedStyle(element); - - return { - backgroundColor: styles.backgroundColor, - borderRadius: styles.borderRadius, - outline: styles.outline, - outlineOffset: styles.outlineOffset, - transitionDuration: styles.transitionDuration, - transitionProperty: styles.transitionProperty, - transitionTimingFunction: styles.transitionTimingFunction, - }; -}; diff --git a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/useDismissOnViewportChange.ts b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/useDismissOnViewportChange.ts index e0c821b4582..cf9acdaf13c 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/promptFields/useDismissOnViewportChange.ts +++ b/invokeai/frontend/webv2/src/features/generation/ui/promptFields/useDismissOnViewportChange.ts @@ -2,7 +2,8 @@ import { useMountEffect } from '@platform/react/useMountEffect'; /** * Dismisses a rect-anchored surface once scrolling or resizing makes its stored - * viewport coordinates stale. + * viewport coordinates stale. Scrolls inside a popover don't count: the anchor + * lives in the page, and the surface scrolling its own list moves nothing. */ export const useDismissOnViewportChange = (enabled: boolean, dismiss: () => void): void => { useMountEffect(() => { @@ -10,7 +11,13 @@ export const useDismissOnViewportChange = (enabled: boolean, dismiss: () => void return; } - const handleViewportChange = () => dismiss(); + const handleViewportChange = (event: Event) => { + if (event.target instanceof Element && event.target.closest('[data-scope="popover"][data-part="content"]')) { + return; + } + + dismiss(); + }; window.addEventListener('scroll', handleViewportChange, { capture: true, passive: true }); window.addEventListener('resize', handleViewportChange, { passive: true }); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/GenerateReferenceImagesSection.tsx b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/GenerateReferenceImagesSection.tsx index 993e3040b84..2da782df56f 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/GenerateReferenceImagesSection.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/GenerateReferenceImagesSection.tsx @@ -240,11 +240,12 @@ export const GenerateReferenceImagesContent = ({ {isCollapsed ? ( - + diff --git a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageControls.tsx b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageControls.tsx index affbd6da353..988ae7343c7 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageControls.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/reference-images/ReferenceImageControls.tsx @@ -325,7 +325,6 @@ export const IPAdapterControls = ({ + @@ -306,6 +306,9 @@ export const ReferenceImageCropDialog = ({ {t('widgets.generate.cropReferenceImage')} + + + @@ -396,7 +399,7 @@ export const ReferenceImageCropDialog = ({ > {t('common.reset')} - diff --git a/invokeai/frontend/webv2/src/features/identity/ui/UserFormDialog.tsx b/invokeai/frontend/webv2/src/features/identity/ui/UserFormDialog.tsx index 3034c9a5f35..0e716f46e7d 100644 --- a/invokeai/frontend/webv2/src/features/identity/ui/UserFormDialog.tsx +++ b/invokeai/frontend/webv2/src/features/identity/ui/UserFormDialog.tsx @@ -52,7 +52,6 @@ export const UserFormDialog = ({ {target.mode === 'create' ? t('users.addUser') : t('users.editUser')} - {target.mode === 'edit' ? ( - - {target.user.email} - - ) : null} + {target.mode === 'edit' ? {target.user.email} : null} ) : null} - + @@ -199,7 +194,7 @@ const UserForm = ({ return ( <> - + {form.formError ? : null} {isCreate ? ( @@ -268,7 +263,7 @@ const UserForm = ({ - + diff --git a/invokeai/frontend/webv2/src/features/models/launchpad.ts b/invokeai/frontend/webv2/src/features/models/launchpad.ts index c4310520746..5b3e2a81d13 100644 --- a/invokeai/frontend/webv2/src/features/models/launchpad.ts +++ b/invokeai/frontend/webv2/src/features/models/launchpad.ts @@ -10,4 +10,4 @@ export { ModelsNotice } from './ui/launchpad/ModelsNotice'; * only ever reached by `import()`, so seeding a search from the editor costs * the editor nothing until someone clicks. */ -export { requestAddModelsSearch } from './ui/uiStore'; +export { requestAddModelsSearch, requestAddModelsTypeFilter } from './ui/uiStore'; diff --git a/invokeai/frontend/webv2/src/features/models/ui/add-models/AccessTokenPopover.tsx b/invokeai/frontend/webv2/src/features/models/ui/add-models/AccessTokenPopover.tsx index 0a25a776baf..41f8576fc07 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/add-models/AccessTokenPopover.tsx +++ b/invokeai/frontend/webv2/src/features/models/ui/add-models/AccessTokenPopover.tsx @@ -1,6 +1,6 @@ /* eslint-disable react-perf/jsx-no-jsx-as-prop, react-perf/jsx-no-new-array-as-prop, react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-object-as-prop */ import { Icon, Input, Popover, Portal, Stack, Text } from '@chakra-ui/react'; -import { Button, IconButton } from '@platform/ui'; +import { Button, IconButton, PopoverContent } from '@platform/ui'; import { ArrowRightIcon, KeyRoundIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -30,7 +30,7 @@ export const AccessTokenPopover = ({ - + @@ -53,7 +53,7 @@ export const AccessTokenPopover = ({ - + diff --git a/invokeai/frontend/webv2/src/features/models/ui/add-models/AddModelsView.browser.test.tsx b/invokeai/frontend/webv2/src/features/models/ui/add-models/AddModelsView.browser.test.tsx index 9424b385799..dcb31dcef68 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/add-models/AddModelsView.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/models/ui/add-models/AddModelsView.browser.test.tsx @@ -81,7 +81,7 @@ describe('AddModelsView search seed', () => { document.body.append(host); const store = await import('@features/models/ui/uiStore'); - store.clearAddModelsSeed(); + store.clearAddModelsSeeds(); }); afterEach(() => { diff --git a/invokeai/frontend/webv2/src/features/models/ui/add-models/AddModelsView.tsx b/invokeai/frontend/webv2/src/features/models/ui/add-models/AddModelsView.tsx index 552b3945084..b8353d8df42 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/add-models/AddModelsView.tsx +++ b/invokeai/frontend/webv2/src/features/models/ui/add-models/AddModelsView.tsx @@ -16,8 +16,9 @@ import { } from '@features/models/data/externalProvidersStore'; import { ensureStartersLoaded, useStartersSelector } from '@features/models/data/startersStore'; import { - clearAddModelsSeed, + clearAddModelsSeeds, getAddModelsSeed, + getAddModelsTypeSeed, openExternalProviderKeys, openModelManagerTab, updateModelsUi, @@ -100,11 +101,17 @@ export const AddModelsView = () => { ), [providerConfigs] ); - const [starterFilters, setStarterFilters] = useState(DEFAULT_STARTER_MODEL_FILTERS); + const [starterFilters, setStarterFilters] = useState(() => { + const typeSeed = getAddModelsTypeSeed(); + + return typeSeed === null + ? DEFAULT_STARTER_MODEL_FILTERS + : { ...DEFAULT_STARTER_MODEL_FILTERS, typeFilter: typeSeed }; + }); useMountEffect(() => { - // One-shot: a seed only ever fills the box the view was opened with. - clearAddModelsSeed(); + // One-shot: a seed only ever fills the view it was opened with. + clearAddModelsSeeds(); ensureStartersLoaded(); const owner = captureAccountScope(); diff --git a/invokeai/frontend/webv2/src/features/models/ui/components/ModelSelect.tsx b/invokeai/frontend/webv2/src/features/models/ui/components/ModelSelect.tsx index 89af34ef0b3..bdc1b37b4e5 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/components/ModelSelect.tsx +++ b/invokeai/frontend/webv2/src/features/models/ui/components/ModelSelect.tsx @@ -12,7 +12,7 @@ import { useModelsUi } from '@features/models/ui/ModelsUiContext'; import { setPickerCompactView, useModelsUiSelector } from '@features/models/ui/uiStore'; import { useMountEffect } from '@platform/react/useMountEffect'; import { areArraysEqual } from '@platform/state/selectors'; -import { Button, CloseButton, IconButton, Tooltip } from '@platform/ui'; +import { Button, CloseButton, IconButton, PopoverContent, Tooltip } from '@platform/ui'; import { MiddleTruncate } from '@platform/ui/MiddleTruncate'; import { Picker } from '@platform/ui/Picker'; import { Link } from '@tanstack/react-router'; @@ -296,13 +296,14 @@ export const ModelSelect = ({ - emptyMessage={t('models.scopeNoCompatibleInstalled', { scope: scopeLabel })} @@ -368,7 +369,7 @@ export const ModelSelect = ({ } onSelect={selectModel} /> - + @@ -432,7 +433,6 @@ const BaseChip = ({ { if (!event.open) { @@ -70,13 +69,11 @@ export const UpdatePathDialog = ({ {t('models.updatePath')} - - {t('models.updatePathDescription')} - + {t('models.updatePathDescription')} - + {model.path} @@ -96,7 +93,7 @@ export const UpdatePathDialog = ({ - + @@ -111,7 +108,7 @@ export const UpdatePathDialog = ({ - + diff --git a/invokeai/frontend/webv2/src/features/models/ui/library/OrphanedModelsDialog.tsx b/invokeai/frontend/webv2/src/features/models/ui/library/OrphanedModelsDialog.tsx index 0bd018c2f03..6c723396fde 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/library/OrphanedModelsDialog.tsx +++ b/invokeai/frontend/webv2/src/features/models/ui/library/OrphanedModelsDialog.tsx @@ -112,7 +112,6 @@ export const OrphanedModelsDialog = ({ onClose }: { onClose: () => void }) => { return ( { @@ -128,9 +127,7 @@ export const OrphanedModelsDialog = ({ onClose }: { onClose: () => void }) => { {t('models.orphanedTitle')} - - {t('models.orphanedDescription')} - + {t('models.orphanedDescription')} @@ -147,7 +144,7 @@ export const OrphanedModelsDialog = ({ onClose }: { onClose: () => void }) => { {t('models.noOrphaned')} ) : ( - + void }) => { )} - + @@ -209,7 +206,7 @@ export const OrphanedModelsDialog = ({ onClose }: { onClose: () => void }) => { - + diff --git a/invokeai/frontend/webv2/src/features/models/ui/shared/ModelActionsMenu.tsx b/invokeai/frontend/webv2/src/features/models/ui/shared/ModelActionsMenu.tsx index 8c5d77358c5..1fdb25390cd 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/shared/ModelActionsMenu.tsx +++ b/invokeai/frontend/webv2/src/features/models/ui/shared/ModelActionsMenu.tsx @@ -66,7 +66,7 @@ export const ModelActionMenuItems = ({ ) : null} {extraItems} - onRequestConfirm({ kind: 'delete', model })}> + onRequestConfirm({ kind: 'delete', model })}> {t('models.deleteModel')} diff --git a/invokeai/frontend/webv2/src/features/models/ui/uiStore.test.ts b/invokeai/frontend/webv2/src/features/models/ui/uiStore.test.ts index 1515a416273..5340169a2f2 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/uiStore.test.ts +++ b/invokeai/frontend/webv2/src/features/models/ui/uiStore.test.ts @@ -57,12 +57,30 @@ describe('models ui store', () => { expect(store.getAddModelsSeed()).toBe('Juggernaut XL'); expect(store.getAddModelsSeed()).toBe('Juggernaut XL'); - store.clearAddModelsSeed(); + store.clearAddModelsSeeds(); // The next time Add Models opens on its own, the box is empty again. expect(store.getAddModelsSeed()).toBe(''); }); + it('hands the starter type-filter seed over exactly once, and the two seeds displace each other', async () => { + const store = await import('./uiStore'); + + store.requestAddModelsTypeFilter('text_llm'); + expect(store.getModelsUiSnapshotForTests().activeTab).toBe('add'); + expect(store.getAddModelsTypeSeed()).toBe('text_llm'); + expect(store.getAddModelsSeed()).toBe(''); + + store.requestAddModelsSearch('Juggernaut XL'); + expect(store.getAddModelsTypeSeed()).toBeNull(); + + store.requestAddModelsTypeFilter('llava_onevision'); + expect(store.getAddModelsSeed()).toBe(''); + + store.clearAddModelsSeeds(); + expect(store.getAddModelsTypeSeed()).toBeNull(); + }); + it('keeps the bundle selection until it is explicitly replaced', async () => { const store = await import('./uiStore'); diff --git a/invokeai/frontend/webv2/src/features/models/ui/uiStore.ts b/invokeai/frontend/webv2/src/features/models/ui/uiStore.ts index 78e6e3bdc70..d79919813af 100644 --- a/invokeai/frontend/webv2/src/features/models/ui/uiStore.ts +++ b/invokeai/frontend/webv2/src/features/models/ui/uiStore.ts @@ -1,4 +1,4 @@ -import type { FoundModel } from '@features/models/core/types'; +import type { FoundModel, ModelTaxonomyType } from '@features/models/core/types'; import { DEFAULT_LIBRARY_FILTERS, type ModelLibraryFilters } from '@features/models/core/library'; import { registerAccountOwnedResource } from '@platform/state/accountLifecycle'; @@ -31,11 +31,17 @@ export interface ModelsUiSnapshot { /** * A pending Add Models search, handed over by a surface outside the manager * (see {@link requestAddModelsSearch}). Strictly one-shot: the view takes it - * at mount and {@link clearAddModelsSeed} empties it, so the box keeps its own - * local state and still resets when the view unmounts. `null` = nothing + * at mount and {@link clearAddModelsSeeds} empties it, so the box keeps its + * own local state and still resets when the view unmounts. `null` = nothing * pending, which is not the same as a seeded empty string. */ addModelsSeed: string | null; + /** + * A pending starter-catalog type filter, one-shot exactly like + * {@link ModelsUiSnapshot.addModelsSeed} — for links that know what *kind* of + * model is missing rather than which one. + */ + addModelsTypeSeed: ModelTaxonomyType | null; /** Model focused in the manager library's detail pane. */ activeModelKey: string | null; /** @@ -66,6 +72,7 @@ const createInitialModelsUiSnapshot = (): ModelsUiSnapshot => ({ activeModelKey: null, activeTab: 'add', addModelsSeed: null, + addModelsTypeSeed: null, filters: { ...DEFAULT_LIBRARY_FILTERS }, hfLookup: null, highlightProviderId: null, @@ -162,6 +169,24 @@ export const requestAddModelsSearch = (query: string): void => { updateModelsUi({ activeTab: 'add', addModelsSeed: query, + addModelsTypeSeed: null, + hfLookup: null, + scan: null, + selectedBundleName: null, + }); +}; + +/** + * Open Add Models with the starter catalog filtered to one model type. The same + * navigate-from-elsewhere contract as {@link requestAddModelsSearch}, for links + * that know what kind of model is missing (prompt expansion needs a `text_llm`) + * rather than which one. + */ +export const requestAddModelsTypeFilter = (typeFilter: ModelTaxonomyType): void => { + updateModelsUi({ + activeTab: 'add', + addModelsSeed: null, + addModelsTypeSeed: typeFilter, hfLookup: null, scan: null, selectedBundleName: null, @@ -171,16 +196,19 @@ export const requestAddModelsSearch = (query: string): void => { /** Reads a pending seed without consuming it — safe to call from a `useState` initializer, which StrictMode double-invokes. */ export const getAddModelsSeed = (): string => store.getSnapshot().addModelsSeed ?? ''; +/** Pure read, like {@link getAddModelsSeed}. */ +export const getAddModelsTypeSeed = (): ModelTaxonomyType | null => store.getSnapshot().addModelsTypeSeed; + /** - * Consumes the pending seed. Silent because the only reader took it in the same - * commit and nothing subscribes to it: a notify here would be a store write - * whose only effect is re-rendering the view that just read it. + * Consumes the pending seeds. Silent because the only reader took them in the + * same commit and nothing subscribes to them: a notify here would be a store + * write whose only effect is re-rendering the view that just read it. */ -export const clearAddModelsSeed = (): void => { +export const clearAddModelsSeeds = (): void => { const snapshot = store.getSnapshot(); - if (snapshot.addModelsSeed !== null) { - store.setSnapshotSilently({ ...snapshot, addModelsSeed: null }); + if (snapshot.addModelsSeed !== null || snapshot.addModelsTypeSeed !== null) { + store.setSnapshotSilently({ ...snapshot, addModelsSeed: null, addModelsTypeSeed: null }); } }; diff --git a/invokeai/frontend/webv2/src/features/nodes/ui/library/NodePackContextMenu.tsx b/invokeai/frontend/webv2/src/features/nodes/ui/library/NodePackContextMenu.tsx index 7fca4308678..9bac6293da5 100644 --- a/invokeai/frontend/webv2/src/features/nodes/ui/library/NodePackContextMenu.tsx +++ b/invokeai/frontend/webv2/src/features/nodes/ui/library/NodePackContextMenu.tsx @@ -64,7 +64,7 @@ export const NodePackContextMenu = ({ {t('nodes.copyPath')} - setPendingUninstall(pack)}> + setPendingUninstall(pack)}> {t('nodes.uninstall')} diff --git a/invokeai/frontend/webv2/src/features/queue/ui/QueueHeaderMenu.tsx b/invokeai/frontend/webv2/src/features/queue/ui/QueueHeaderMenu.tsx index e7ba45cf357..4259aeb0839 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/QueueHeaderMenu.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/QueueHeaderMenu.tsx @@ -11,8 +11,6 @@ import { QueueMenuItems, useQueueMenuActions } from './queueMenuActions'; import { useQueueQueryScope } from './queueScope'; import { useQueueUi } from './QueueUiContext'; -const ERROR_ITEM_HOVER_PROPS = { bg: 'bg.error', color: 'fg.error' }; - /** * Queue actions contributed to the frame's shared actions menu via the manifest * `headerMenu` slot. This intentionally uses the same action model as the @@ -75,23 +73,11 @@ export const QueueHeaderMenu = () => { {t('common.clear')} - + {t('widgets.queue.clearFailedItems')} - + {t('widgets.queue.clearAllItems')} diff --git a/invokeai/frontend/webv2/src/features/queue/ui/queueMenuActions.tsx b/invokeai/frontend/webv2/src/features/queue/ui/queueMenuActions.tsx index 503b53880ca..31bb28582e3 100644 --- a/invokeai/frontend/webv2/src/features/queue/ui/queueMenuActions.tsx +++ b/invokeai/frontend/webv2/src/features/queue/ui/queueMenuActions.tsx @@ -11,8 +11,6 @@ import { refreshQueue, useNowNextItems, useQueueCounts } from './queueDataStore' import { useQueueQueryScope } from './queueScope'; import { useQueueUi } from './QueueUiContext'; -const ERROR_ITEM_HOVER_PROPS = { bg: 'bg.error', color: 'fg.error' }; - interface QueueMenuActionInputs { labels: { cancelAll: string; @@ -276,13 +274,7 @@ const DestructiveQueueMenuItem = ({ action }: { action: QueueMenuAction }) => { ); return ( - + {action.label} diff --git a/invokeai/frontend/webv2/src/features/upscale/ui/UpscaleImageField.tsx b/invokeai/frontend/webv2/src/features/upscale/ui/UpscaleImageField.tsx index ae23ab39d62..15d4781b5ce 100644 --- a/invokeai/frontend/webv2/src/features/upscale/ui/UpscaleImageField.tsx +++ b/invokeai/frontend/webv2/src/features/upscale/ui/UpscaleImageField.tsx @@ -169,7 +169,7 @@ export const UpscaleImageField = memo( as="button" aria-busy={isLoading} aria-label={inputImage ? t('widgets.upscale.replaceImage') : t('widgets.upscale.uploadImage')} - cursor="pointer" + isDisabled={isLoading} isOver={isOver} {...(isLoading ? DROP_ZONE_BUSY_PROPS : undefined)} minH="24" diff --git a/invokeai/frontend/webv2/src/features/video/ui/VideoFrameImageField.tsx b/invokeai/frontend/webv2/src/features/video/ui/VideoFrameImageField.tsx index 9752a2935e6..3a7e4fea488 100644 --- a/invokeai/frontend/webv2/src/features/video/ui/VideoFrameImageField.tsx +++ b/invokeai/frontend/webv2/src/features/video/ui/VideoFrameImageField.tsx @@ -180,7 +180,8 @@ export const VideoFrameImageField = memo( aria-busy={isLoading} aria-disabled={disabled} aria-label={image ? t('widgets.video.replaceFrame') : t('widgets.video.uploadFrame')} - cursor={disabled ? 'not-allowed' : 'pointer'} + cursor={disabled ? 'not-allowed' : undefined} + isDisabled={isInert} isOver={isOver} {...(isLoading ? DROP_ZONE_BUSY_PROPS : undefined)} minH="20" diff --git a/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx b/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx index c990a04aa1b..8d86b25a067 100644 --- a/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx +++ b/invokeai/frontend/webv2/src/features/video/ui/VideoReferenceListField.tsx @@ -47,7 +47,6 @@ const DROP_ZONE_FOCUS_PROPS = { }; const DROP_ZONE_DISABLED_PROPS = { cursor: 'not-allowed', opacity: 0.6 }; const DROP_ZONE_BUSY_PROPS = { disabled: true }; -const DROP_ZONE_HOVER_PROPS = { bg: 'bg.muted', color: 'fg' }; const getSingleGalleryDragItem = (data: unknown): { kind: 'image' | 'video'; name: string } | null => { if (!isGalleryItemDragData(data) || data.items.length !== 1) { @@ -544,7 +543,8 @@ export const VideoReferenceListField = memo(function VideoReferenceListField({ ref={setNodeRef} {...(isInert ? DROP_ZONE_DISABLED_PROPS : {})} {...(isLoading ? DROP_ZONE_BUSY_PROPS : {})} - {...(isOver && acceptsActiveDrag ? DROP_ZONE_HOVER_PROPS : {})} + isDisabled={isInert} + isOver={isOver && acceptsActiveDrag} _focusVisible={DROP_ZONE_FOCUS_PROPS} position="relative" > diff --git a/invokeai/frontend/webv2/src/features/video/ui/VideoSourceClipField.tsx b/invokeai/frontend/webv2/src/features/video/ui/VideoSourceClipField.tsx index 831e61b5423..e51a807e58d 100644 --- a/invokeai/frontend/webv2/src/features/video/ui/VideoSourceClipField.tsx +++ b/invokeai/frontend/webv2/src/features/video/ui/VideoSourceClipField.tsx @@ -228,7 +228,8 @@ export const VideoSourceClipField = memo( aria-busy={isLoading} aria-disabled={disabled} aria-label={sourceVideo ? t('widgets.video.replaceClip') : t('widgets.video.uploadClip')} - cursor={disabled ? 'not-allowed' : 'pointer'} + cursor={disabled ? 'not-allowed' : undefined} + isDisabled={isInert} isOver={isOver} {...(isLoading ? DROP_ZONE_BUSY_PROPS : undefined)} minH="24" diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/MenuActionItem.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/MenuActionItem.tsx index fd2e1d5f263..6ee98d5d500 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/MenuActionItem.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/MenuActionItem.tsx @@ -37,7 +37,7 @@ export const MenuActionItem = ({ hint, icon, isDisabled, label, tone, value, onS { {t('widgets.workflow.copyJson')} - + {t('widgets.workflow.newWorkflowWithEllipsis')} diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/editor/AddNodeDialog.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/editor/AddNodeDialog.tsx index 62d336b2bde..588cf45295c 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/editor/AddNodeDialog.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/editor/AddNodeDialog.tsx @@ -118,7 +118,6 @@ const NodeResultRow = ({ w="full" onClick={row.onAdd} onMouseEnter={onActive} - cursor="pointer" > @@ -171,7 +170,6 @@ const CategoryHeaderRow = ({ role="treeitem" tabIndex={-1} _hover={ROW_HOVER_PROPS} - cursor="pointer" ps="1" pe="2" py="1" @@ -231,7 +229,6 @@ export const AddNodeDialog = ({ { }); describe('editor toolbar', () => { - it('keeps every button on the shared 32px toolbar box', async () => { + it('keeps every button on one shared square toolbar box', async () => { // The node-opacity button is hand-rolled rather than a ToolbarButton, so it // never picked up the primitive's size. That is not a local defect: Toolbar // is a column Stack, and its default stretch alignment let one `sm` button - // widen every sibling to 36px while they kept their 32px height, turning - // the whole strip into rectangles four pixels wider than the canvas one. + // widen every sibling while they kept their height, turning the whole + // strip into rectangles wider than the canvas one. await render(1); - expect(new Set(buttonBoxes())).toEqual(new Set(['32x32'])); + expect(new Set(buttonBoxes())).toEqual(new Set(['28x28'])); }); it('states node opacity the way the tool buttons state themselves', async () => { @@ -96,6 +96,6 @@ describe('editor toolbar', () => { expect(opacity.getAttribute('aria-pressed')).toBe('true'); expect(paintedFill(opacity)).toBe(paintedFill(activeTool)); - expect(new Set(buttonBoxes())).toEqual(new Set(['32x32'])); + expect(new Set(buttonBoxes())).toEqual(new Set(['28x28'])); }); }); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.tsx index 6a11559b310..6136284a99c 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/editor/EditorToolbar.tsx @@ -1,6 +1,6 @@ import { Box, Icon, Popover, Portal, Slider, Stack, Text } from '@chakra-ui/react'; import { useWorkflowPreferencesSelector } from '@features/workflow/ui/WorkflowUiContext'; -import { IconButton, Toolbar, ToolbarButton, ToolbarSeparator, Tooltip } from '@platform/ui'; +import { IconButton, PopoverContent, Toolbar, ToolbarButton, ToolbarSeparator, Tooltip } from '@platform/ui'; import { useReactFlow } from '@xyflow/react'; import { BlendIcon, @@ -93,8 +93,8 @@ export const EditorToolbar = ({ `Popover.Trigger asChild` would clone that component's Tooltip wrapper instead of the button. Size is load bearing — `Toolbar` is a column Stack, so it stretches every sibling to the widest - child, and one `sm` button here squared off the whole strip at - 36px while the rest stayed 32px tall. */} + child, and one `sm` button here once widened the whole strip + past the `xs` squares. */} - + @@ -130,7 +130,7 @@ export const EditorToolbar = ({ - + diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/editor/NodeContextMenu.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/editor/NodeContextMenu.tsx index 69d79004614..b3ba615d8d3 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/editor/NodeContextMenu.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/editor/NodeContextMenu.tsx @@ -102,7 +102,7 @@ export const NodeContextMenu = ({ ) : null} - + Delete Del diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/fields/FieldDescriptionPopover.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/fields/FieldDescriptionPopover.tsx index 6470b0e4383..ce56d551d32 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/fields/FieldDescriptionPopover.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/fields/FieldDescriptionPopover.tsx @@ -2,6 +2,7 @@ import type { ChangeEvent } from 'react'; import { Icon, Popover, Portal, Stack, Text, Textarea, IconButton } from '@chakra-ui/react'; import { useProjectGraphCommands } from '@features/workflow/ui/useProjectGraphCommands'; +import { PopoverContent } from '@platform/ui/Popover'; import { FileTextIcon } from 'lucide-react'; import { useCallback } from 'react'; @@ -51,7 +52,7 @@ export const FieldDescriptionPopover = ({ - + @@ -70,7 +71,7 @@ export const FieldDescriptionPopover = ({ /> - + diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx index 59cc01d9ca4..9256de89b68 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/fields/WorkflowFieldInput.tsx @@ -95,6 +95,9 @@ export interface WorkflowFieldInputProps { const invalidProps = (invalid: boolean | undefined) => (invalid ? { 'aria-invalid': true } : {}); +// The media well's hover, matching DropZone's pointer-hover accent preview. +const MEDIA_INPUT_HOVER_PROPS = { borderColor: 'accent.solid' }; + const toFiniteNumber = (raw: string): number | null => { if (raw.trim() === '') { return null; @@ -623,7 +626,9 @@ const MediaInput = ({ id, invalid, kind, onChange, value }: WorkflowFieldInputPr justifyContent="center" overflow="hidden" rounded="sm" + transition="border-color var(--wb-motion-duration-fast) ease" w="full" + _hover={MEDIA_INPUT_HOVER_PROPS} > {failedThumbnail !== mediaName ? ( {`Drop a ${config.noun} here`} diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.browser.test.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.browser.test.tsx index 9e5ea169a5d..51ddbf4cf44 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.browser.test.tsx @@ -430,13 +430,13 @@ describe('GraphPreviewDialog', () => { }); const switchToMode = async (mode: 'graph' | 'list' | 'json') => { - const input = document.querySelector(`input[value="${mode}"]`); - expect(input).not.toBeNull(); - const label = input?.closest('label'); - expect(label).not.toBeNull(); + const tab = [...document.querySelectorAll('[role="tab"]')].find((candidate) => + candidate.id.endsWith(`-tab-${mode}`) + ); + expect(tab).not.toBeUndefined(); await act(() => { - label?.click(); + tab?.click(); }); }; @@ -560,14 +560,7 @@ describe('GraphPreviewDialog', () => { expect(document.querySelector('[data-flow-stub]')).not.toBeNull(); - const jsonInput = document.querySelector('input[value="json"]'); - expect(jsonInput).not.toBeNull(); - const jsonLabel = jsonInput?.closest('label'); - expect(jsonLabel).not.toBeNull(); - - await act(() => { - jsonLabel?.click(); - }); + await switchToMode('json'); expect(document.querySelector('[data-flow-stub]')).toBeNull(); expect(document.body.textContent ?? '').toContain('"denoise_latents"'); @@ -622,7 +615,10 @@ describe('GraphPreviewDialog', () => { await clickButtonWithText('denoise_latents'); // List selection reveals the node in graph mode, not list mode. - expect(document.querySelector('input[value="graph"]')?.checked).toBe(true); + const graphTab = [...document.querySelectorAll('[role="tab"]')].find((candidate) => + candidate.id.endsWith('-tab-graph') + ); + expect(graphTab?.getAttribute('aria-selected')).toBe('true'); expect(document.querySelector('[data-flow-stub]')).not.toBeNull(); const text = document.body.textContent ?? ''; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.tsx index 2a370d5f355..ce5527ada21 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.tsx @@ -2,11 +2,11 @@ import type { GraphPreviewSourceState, WorkflowInvocationSourceId } from '@featu import type { ReactFlowInstance } from '@xyflow/react'; import type { ReactNode } from 'react'; -import { Box, Dialog, Icon, Portal, SegmentGroup, Stack, Text } from '@chakra-ui/react'; +import { Box, Dialog, Icon, Portal, Stack, Text } from '@chakra-ui/react'; import { useWorkflowGraphPreview } from '@features/workflow/ui/WorkflowUiContext'; -import { Button, JsonPreview, toaster } from '@platform/ui'; +import { Button, JsonPreview, SegmentTabs, segmentTabsPanelId, segmentTabsTabId, toaster } from '@platform/ui'; import { CheckIcon, ChevronUpIcon, CopyIcon, TriangleAlertIcon } from 'lucide-react'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useId, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { GraphPreviewFlow } from './GraphPreviewFlow'; @@ -34,14 +34,11 @@ interface GraphPreviewDialogProps { type PreviewMode = 'graph' | 'list' | 'json'; -const MODE_VALUES: readonly PreviewMode[] = ['graph', 'list', 'json']; -const isPreviewMode = (value: string | null): value is PreviewMode => MODE_VALUES.includes(value as PreviewMode); - const modeItems = [ { labelKey: 'graphPreview.graph', value: 'graph' }, { labelKey: 'graphPreview.list', value: 'list' }, { labelKey: 'common.json', value: 'json' }, -] as const; +] as const satisfies readonly { labelKey: string; value: PreviewMode }[]; const COPY_RESET_DELAY_MS = 1500; const SELECT_AND_REVEAL_FIT_VIEW_OPTIONS = { duration: 150, maxZoom: 1 } as const; @@ -84,6 +81,7 @@ export const GraphPreviewDialog = ({ }: GraphPreviewDialogProps) => { const { t } = useTranslation(); const graphPreview = useWorkflowGraphPreview(); + const modeTabsIdBase = useId(); const [mode, setMode] = useState('graph'); const [selectedNodeId, setSelectedNodeId] = useState(null); const [hasCopied, setHasCopied] = useState(false); @@ -110,11 +108,7 @@ export const GraphPreviewDialog = ({ ); const handleOpenChange = useCallback((event: { open: boolean }) => closeAndReset(event.open), [closeAndReset]); - const handleModeChange = useCallback((event: { value: string | null }) => { - if (isPreviewMode(event.value)) { - setMode(event.value); - } - }, []); + const handleModeChange = useCallback((value: PreviewMode) => setMode(value), []); const closeDialog = useCallback(() => closeAndReset(false), [closeAndReset]); const invokeRoute = useCallback(() => { void graphPreview.invoke(sourceId).then((submitted) => { @@ -194,6 +188,7 @@ export const GraphPreviewDialog = ({ .catch(() => toaster.create({ title: t('graphPreview.copyFailed'), type: 'error' })); }, [graph, t]); + const modeTabs = useMemo(() => modeItems.map((item) => ({ id: item.value, label: t(item.labelKey) })), [t]); const jsonLabel = useMemo(() => t('graphPreview.graphJsonLabel', { title: sourceLabel }), [t, sourceLabel]); const subtitle = useMemo(() => { const compiledFrom = t('graphPreview.compiledFrom', { source: sourceLabel }); @@ -201,13 +196,7 @@ export const GraphPreviewDialog = ({ }, [t, sourceLabel, source.isLive]); return ( - + @@ -219,21 +208,27 @@ export const GraphPreviewDialog = ({ {t('graphPreview.title')} - - {subtitle} - + {subtitle} - - - {modeItems.map((item) => ( - - - {t(item.labelKey)} - - ))} - + - + {hasInvalidReasons ? ( @@ -311,7 +306,7 @@ export const GraphPreviewDialog = ({ {t('graphPreview.invokeRoute', { route: dialogRoute.label })} ) : null} - diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewSidePanel.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewSidePanel.tsx index ac62bdfdf9d..7477bf580b2 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewSidePanel.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewSidePanel.tsx @@ -77,7 +77,7 @@ const SummaryNotice = ({ fontSize="2xs" fontWeight="normal" h="auto" - px="0" + px="1" size="2xs" variant="plain" onClick={handleShowNode} @@ -253,7 +253,7 @@ const NodeInspector = ({ fontSize="2xs" fontWeight="normal" h="auto" - px="0" + px="1" size="2xs" variant="plain" onClick={onProvenanceClick} diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryCard.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryCard.tsx index d3210d2c9e9..3a1e43f7c51 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryCard.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryCard.tsx @@ -62,7 +62,6 @@ export const WorkflowLibraryCard = ({ bg={isSelected ? 'bg.emphasized' : 'bg.subtle'} borderColor={isSelected ? 'accent.solid' : 'border.subtle'} borderWidth="1px" - cursor="pointer" data-workflow-card={workflowId} minW="0" overflow="hidden" @@ -100,7 +99,7 @@ export const WorkflowLibraryCard = ({ {enrichment.status === 'pending' ? ( // Enrichment in flight. An unreadable workflow ('error') gets no // placeholder and no error styling — the facts simply stay absent. - + ) : null} {primaryBase ? ( diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDialog.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDialog.tsx index 2c813d6227b..03516e31dfe 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDialog.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryDialog.tsx @@ -214,7 +214,7 @@ export const WorkflowLibraryDialog = ({ return ( <> - + @@ -271,12 +271,10 @@ export const WorkflowLibraryDialog = ({ across both bands instead of reading as part of either. */} diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryGrid.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryGrid.tsx index d0f55a243ef..888cc3c3a3d 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryGrid.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowLibraryGrid.tsx @@ -73,6 +73,12 @@ export const WorkflowLibraryGrid = ({ } const handleScroll = () => { + // A grid that doesn't overflow cannot have been scrolled near its + // bottom by anyone; a scroll event there is measurement noise. + if (viewport.scrollHeight <= viewport.clientHeight) { + return; + } + const remaining = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; if (remaining <= viewport.clientHeight * NEAR_BOTTOM_VIEWPORTS) { diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowRequirementsList.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowRequirementsList.tsx index c668963b0b8..a8682a43f78 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowRequirementsList.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/library/WorkflowRequirementsList.tsx @@ -238,7 +238,7 @@ export const WorkflowRequirementsList = ({ errorMessage, resolved, onFindModel } ) : null} {!errorMessage && resolved === null ? Array.from({ length: SKELETON_ROW_COUNT }, (_unused, index) => ( - + )) : null} {!errorMessage && resolved !== null && resolved.length > 0 ? ( diff --git a/invokeai/frontend/webv2/src/platform/ui/BrandIcon.tsx b/invokeai/frontend/webv2/src/platform/ui/BrandIcon.tsx index a1e40365d39..3f0a44a17f6 100644 --- a/invokeai/frontend/webv2/src/platform/ui/BrandIcon.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/BrandIcon.tsx @@ -75,6 +75,12 @@ export const GoogleGeminiIcon = createBrandIcon({ title: 'Google Gemini', }); +export const GithubIcon = createBrandIcon({ + path: 'M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12', + slug: 'github', + title: 'GitHub', +}); + export const DiscordIcon = createBrandIcon({ origin: 'Material Icons (Apache-2.0)', path: 'M19.27 5.33C17.94 4.71 16.5 4.26 15 4a.1.1 0 0 0-.07.03c-.18.33-.39.76-.53 1.09a16.1 16.1 0 0 0-4.8 0c-.14-.34-.35-.76-.54-1.09c-.01-.02-.04-.03-.07-.03c-1.5.26-2.93.71-4.27 1.33c-.01 0-.02.01-.03.02c-2.72 4.07-3.47 8.03-3.1 11.95c0 .02.01.04.03.05c1.8 1.32 3.53 2.12 5.24 2.65c.03.01.06 0 .07-.02c.4-.55.76-1.13 1.07-1.74c.02-.04 0-.08-.04-.09c-.57-.22-1.11-.48-1.64-.78c-.04-.02-.04-.08-.01-.11c.11-.08.22-.17.33-.25c.02-.02.05-.02.07-.01c3.44 1.57 7.15 1.57 10.55 0c.02-.01.05-.01.07.01c.11.09.22.17.33.26c.04.03.04.09-.01.11c-.52.31-1.07.56-1.64.78c-.04.01-.05.06-.04.09c.32.61.68 1.19 1.07 1.74c.03.01.06.02.09.01c1.72-.53 3.45-1.33 5.25-2.65c.02-.01.03-.03.03-.05c.44-4.53-.73-8.46-3.1-11.95c-.01-.01-.02-.02-.04-.02M8.52 14.91c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.84 2.12-1.89 2.12m6.97 0c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.83 2.12-1.89 2.12', diff --git a/invokeai/frontend/webv2/src/platform/ui/Button.tsx b/invokeai/frontend/webv2/src/platform/ui/Button.tsx index be62b86c249..602c267afec 100644 --- a/invokeai/frontend/webv2/src/platform/ui/Button.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/Button.tsx @@ -43,7 +43,8 @@ export const IconButton = ({ colorPalette, ...props }: IconButtonProps) => ( ); -export const CloseButton = (props: CloseButtonProps) => ; +/** Chakra defaults close buttons to a full `md` control; dismissal chrome here is small and muted. */ +export const CloseButton = (props: CloseButtonProps) => ; export interface ToggleIconButtonProps extends Omit< IconButtonProps, diff --git a/invokeai/frontend/webv2/src/platform/ui/ConfirmDialog.tsx b/invokeai/frontend/webv2/src/platform/ui/ConfirmDialog.tsx index efa0007cf0d..895227ebd2e 100644 --- a/invokeai/frontend/webv2/src/platform/ui/ConfirmDialog.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/ConfirmDialog.tsx @@ -70,7 +70,6 @@ export const ConfirmDialog = ({ closeOnEscape={!isPending} closeOnInteractOutside={!isPending} open={isOpen} - placement="center" role="alertdialog" size="sm" onOpenChange={handleOpenChange} @@ -78,14 +77,14 @@ export const ConfirmDialog = ({ - + {title} {typeof body === 'string' ? {body} : body} - + @@ -101,7 +100,7 @@ export const ConfirmDialog = ({ - + diff --git a/invokeai/frontend/webv2/src/platform/ui/DropZone.tsx b/invokeai/frontend/webv2/src/platform/ui/DropZone.tsx index 6f36e4e85f9..bb8dc45dd28 100644 --- a/invokeai/frontend/webv2/src/platform/ui/DropZone.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/DropZone.tsx @@ -2,11 +2,14 @@ import type { BoxProps } from '@chakra-ui/react'; import type { Ref } from 'react'; import { Box } from '@chakra-ui/react'; +import { useMemo } from 'react'; const DROP_ZONE_TRANSITION = 'background var(--wb-motion-duration-fast) ease, border-color var(--wb-motion-duration-fast) ease, opacity var(--wb-motion-duration-fast) ease, box-shadow var(--wb-motion-duration-fast) ease'; export interface DropZoneProps extends BoxProps { + /** The zone cannot accept input right now; the hover preview stands down. */ + isDisabled?: boolean; /** A compatible drag is hovering the zone. */ isOver?: boolean; /** Forwarded to the underlying element (e.g. dnd-kit's `setNodeRef`). */ @@ -20,24 +23,41 @@ export interface DropZoneProps extends BoxProps { } /** - * The workbench drop-target look: dashed `border.emphasized` at rest, + * The workbench drop-target look: dashed `border.emphasized` at rest, an + * accent border on pointer hover previewing the drop treatment, and an * `accent.solid` border over an `accent.muted` tint while a compatible drag * hovers. Every drop zone and upload area composes this so drag affordances * stay identical across the app; callers add their own icon/hint content, * interaction handlers, and layout props. */ -export const DropZone = ({ children, isOver, variant = 'inline', ...boxProps }: DropZoneProps) => ( - - {children} - -); +export const DropZone = ({ children, isDisabled, isOver, variant = 'inline', _hover, ...boxProps }: DropZoneProps) => { + // Merged here rather than spread: `:hover` stays live during a drag, so a + // consumer hover fill would otherwise paint over the drag-over treatment. + // The accent preview is for persistent inline zones only — overlays exist + // mid-drag, where dnd-kit's own `isOver` is the signal. + const hoverProps = useMemo( + () => ({ + ...(variant === 'inline' && !isDisabled ? { borderColor: 'accent.solid' } : null), + ..._hover, + ...(isOver ? { bg: 'accent.solid/15', borderColor: 'accent.solid' } : null), + }), + [_hover, isDisabled, isOver, variant] + ); + + return ( + + {children} + + ); +}; diff --git a/invokeai/frontend/webv2/src/platform/ui/InputShell.tsx b/invokeai/frontend/webv2/src/platform/ui/InputShell.tsx new file mode 100644 index 00000000000..2048c0fcc7b --- /dev/null +++ b/invokeai/frontend/webv2/src/platform/ui/InputShell.tsx @@ -0,0 +1,66 @@ +import type { BoxProps } from '@chakra-ui/react'; +import type { PointerEvent, ReactNode, Ref } from 'react'; + +import { Box } from '@chakra-ui/react'; +import { inputShellInteraction } from '@theme/recipes'; + +export interface InputShellProps extends BoxProps { + ref?: Ref; + /** Leading adornment (e.g. a search icon), outside the content cell. */ + startElement?: ReactNode; + /** Trailing adornments (clear/help buttons), outside the content cell. */ + endElement?: ReactNode; +} + +/** Chrome clicks focus the field like a native input; adornment controls keep their own clicks. */ +const focusInnerInput = (event: PointerEvent) => { + const target = event.target as HTMLElement; + + if (target.closest('button, input, textarea, select, a')) { + return; + } + + const input = event.currentTarget.querySelector('input, textarea, [contenteditable]'); + + if (input) { + event.preventDefault(); + input.focus(); + } +}; + +/** + * The themed input's chrome for composite fields whose focusable element lives + * inside the frame — a transparent input under a rendered mirror, a query + * chip. Metrics and states match ``, keyed on focus-within so + * the frame responds to the inner control; `aria-invalid` on the shell drives + * the invalid border like the input's own. + */ +export const InputShell = ({ children, endElement, ref, startElement, ...boxProps }: InputShellProps) => ( + + {startElement} + + {children} + + {endElement} + +); diff --git a/invokeai/frontend/webv2/src/platform/ui/JsonPreview.tsx b/invokeai/frontend/webv2/src/platform/ui/JsonPreview.tsx index 96a68f32b2c..0aaf7e5890b 100644 --- a/invokeai/frontend/webv2/src/platform/ui/JsonPreview.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/JsonPreview.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { IconButton } from './Button'; import { toaster } from './toaster'; +import { useScrollAreaPhantomHeal } from './useScrollAreaPhantomHeal'; /** * The workbench's standard JSON preview: a monospace block with a copy button @@ -29,6 +30,9 @@ export const JsonPreview = ({ }) => { const [hasCopied, setHasCopied] = useState(false); const copyResetTimerRef = useRef | null>(null); + const viewportRef = useRef(null); + + useScrollAreaPhantomHeal(viewportRef); const json = useMemo(() => text ?? JSON.stringify(value, null, 2) ?? 'null', [text, value]); useEffect( @@ -84,7 +88,7 @@ export const JsonPreview = ({ - + ( - + {icon ? ( diff --git a/invokeai/frontend/webv2/src/platform/ui/Picker.tsx b/invokeai/frontend/webv2/src/platform/ui/Picker.tsx index 7c3ef2a795f..e9cf205a72c 100644 --- a/invokeai/frontend/webv2/src/platform/ui/Picker.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/Picker.tsx @@ -316,7 +316,7 @@ const PickerOptionRow = ({ aria-disabled={isDisabled || undefined} aria-selected={isSelected} bg={isActive && !isDisabled ? 'bg.emphasized' : undefined} - cursor={isDisabled ? 'not-allowed' : 'pointer'} + cursor={isDisabled ? 'not-allowed' : undefined} data-active={isActive ? '' : undefined} data-picker-option-id={id} id={`picker-option-${id}`} diff --git a/invokeai/frontend/webv2/src/platform/ui/Popover.tsx b/invokeai/frontend/webv2/src/platform/ui/Popover.tsx new file mode 100644 index 00000000000..e7a39b03e45 --- /dev/null +++ b/invokeai/frontend/webv2/src/platform/ui/Popover.tsx @@ -0,0 +1,24 @@ +import type { ComponentProps } from 'react'; + +import { Popover } from '@chakra-ui/react'; + +type PopoverContentProps = ComponentProps & { + /** Anchored panels keep the arrow; dropdown-shaped popovers (pickers) opt out. */ + showArrow?: boolean; +}; + +/** + * Popover.Content with the anchor arrow baked in. Chrome (surface, stroke, + * shadow, arrow fill) comes from the `popover` slot-recipe override in + * `theme/recipes.ts`, so consumers stay consistent by construction. + */ +export const PopoverContent = ({ children, showArrow = true, ...props }: PopoverContentProps) => ( + + {showArrow ? ( + + + + ) : null} + {children} + +); diff --git a/invokeai/frontend/webv2/src/platform/ui/RenameDialog.tsx b/invokeai/frontend/webv2/src/platform/ui/RenameDialog.tsx index bef425faeba..210f0b533cc 100644 --- a/invokeai/frontend/webv2/src/platform/ui/RenameDialog.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/RenameDialog.tsx @@ -74,11 +74,11 @@ export const RenameDialog = ({ ); return ( - + - + {title} @@ -90,7 +90,7 @@ export const RenameDialog = ({ - + @@ -100,7 +100,7 @@ export const RenameDialog = ({ - + diff --git a/invokeai/frontend/webv2/src/platform/ui/ResizableTextarea.tsx b/invokeai/frontend/webv2/src/platform/ui/ResizableTextarea.tsx index a4dd79ba608..ce5a6443568 100644 --- a/invokeai/frontend/webv2/src/platform/ui/ResizableTextarea.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/ResizableTextarea.tsx @@ -138,7 +138,7 @@ export const ResizableTextarea = ({ {underlay} { /** No spaces and no hyphens: nothing the layout can break on, so it contributes its whole width to min-content. */ const UNBREAKABLE = 'AVeryLongUnbreakableWorkflowNameThatHasNowhereToWrap'; +/** Zag's measure timing varies under load; poll instead of guessing a delay. */ +const waitFor = async (predicate: () => boolean, timeoutMs = 3000): Promise => { + const startedAt = Date.now(); + + await act(async () => { + while (!predicate() && Date.now() - startedAt < timeoutMs) { + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 25); + }); + } + }); +}; + const renderScrollable = async (children: ReactNode, orientation: 'horizontal' | 'vertical'): Promise => { host = document.createElement('div'); host.style.cssText = 'width:200px;height:80px;'; @@ -54,7 +70,7 @@ describe('Scrollable', () => { expect(viewport.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth + 1); }); - it('still lets a horizontal strip outgrow its viewport', async () => { + it('still lets a horizontal strip outgrow its viewport, scrollbar shown', async () => { const viewport = await renderScrollable(
{UNBREAKABLE} @@ -64,5 +80,80 @@ describe('Scrollable', () => { ); expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth); + + // The orientation guard must not over-hide: a bar whose own axis + // overflows stays visible. + const scrollbar = document.querySelector( + '[data-scope="scroll-area"][data-part="scrollbar"][data-orientation="horizontal"]' + )!; + + await waitFor(() => getComputedStyle(scrollbar).display !== 'none'); + expect(getComputedStyle(scrollbar).display).not.toBe('none'); + }); + + it('hides the vertical scrollbar when only sideways spill exists', async () => { + // Chakra's stock guard hides a scrollbar only when NEITHER axis + // overflows, so nowrap rows spilling sideways pinned a min-size thumb on + // the vertical bar (dynamic prompts, JSON previews). The theme's + // orientation-scoped guard is what this pins. + const viewport = await renderScrollable( +
+ {UNBREAKABLE} + {UNBREAKABLE} + {UNBREAKABLE} +
, + 'vertical' + ); + + expect(viewport.scrollHeight).toBeLessThanOrEqual(viewport.clientHeight); + + const scrollbar = document.querySelector( + '[data-scope="scroll-area"][data-part="scrollbar"][data-orientation="vertical"]' + )!; + + await waitFor(() => getComputedStyle(scrollbar).display === 'none'); + + expect(scrollbar).not.toBeNull(); + // The spill is real and zag records it; only the orientation guard keeps + // the vertical bar out of it. + expect(scrollbar.hasAttribute('data-overflow-x')).toBe(true); + expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth); + expect(getComputedStyle(scrollbar).display).toBe('none'); + }); + + it('hides the scrollbar for non-overflowing content inside a popover', async () => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + + await act(async () => { + root?.render( + + + + + + + + one short row + + + + + + + + ); + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 50); + }); + }); + + const scrollbar = document.querySelector('[data-scope="scroll-area"][data-part="scrollbar"]')!; + + expect(scrollbar).not.toBeNull(); + await waitFor(() => !scrollbar.hasAttribute('data-overflow-y')); + expect(scrollbar.hasAttribute('data-overflow-y')).toBe(false); + expect(getComputedStyle(scrollbar).display).toBe('none'); }); }); diff --git a/invokeai/frontend/webv2/src/platform/ui/Scrollable.tsx b/invokeai/frontend/webv2/src/platform/ui/Scrollable.tsx index 250ee89a62a..fefd5071252 100644 --- a/invokeai/frontend/webv2/src/platform/ui/Scrollable.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/Scrollable.tsx @@ -4,6 +4,8 @@ import { ScrollArea } from '@chakra-ui/react'; import { usePreservedScrollOffset } from '@platform/react/usePreservedScrollOffset'; import { useRef } from 'react'; +import { useScrollAreaPhantomHeal } from './useScrollAreaPhantomHeal'; + type ScrollAreaRootProps = ComponentProps; type ScrollAreaContentProps = ComponentProps; type ScrollAreaViewportProps = ComponentProps; @@ -55,6 +57,8 @@ export const Scrollable = ({ // container that stops being rendered loses its offset outright. usePreservedScrollOffset(resolvedViewportRef); + useScrollAreaPhantomHeal(resolvedViewportRef); + return ( { id: T; - label: string; + /** Usually a string; gallery tabs carry a dimmed count span. */ + label: ReactNode; } /** Roving focus for a horizontal tablist: arrows cycle, Home/End jump. */ @@ -48,6 +53,7 @@ export const SegmentTabs = ({ activeId, ariaLabel, idBase, + isCompact = false, onSelect, showActivePanel = true, tabs, @@ -56,12 +62,21 @@ export const SegmentTabs = ({ activeId: T; ariaLabel: string; idBase: string; + /** Embedded strips (popovers, dialog headers) drop the panel-strip height and outer padding. */ + isCompact?: boolean; onSelect: (id: T) => void; showActivePanel?: boolean; tabs: readonly SegmentTab[]; trailing?: ReactNode; }) => ( - + ({ isSelected: boolean; /** Selected AND its panel is visible; a collapsed block keeps selection without the shown look. */ isShown: boolean; - label: string; + label: ReactNode; onSelect: (id: T) => void; }) => { const select = useCallback(() => onSelect(id), [id, onSelect]); @@ -126,19 +141,19 @@ const SegmentTabButton = ({ { + ariaLabel?: string; + disabled?: boolean; + /** Default true: the control fills its container, split into equal segments. */ + isFullWidth?: boolean; + onChange: (value: string) => void; + options: readonly SegmentedControlOption[]; + value: string | null; +} + +/** The house segmented control: an `xs` group of equal centered segments with `2xs` labels. */ +export const SegmentedControl = ({ + ariaLabel, + disabled, + isFullWidth = true, + onChange, + options, + value, + ...rest +}: SegmentedControlProps) => { + const handleValueChange = useCallback( + ({ value: next }: SegmentGroup.ValueChangeDetails) => { + if (next !== null) { + onChange(next); + } + }, + [onChange] + ); + + return ( + + + {options.map((option) => ( + + + {option.label} + + ))} + + ); +}; diff --git a/invokeai/frontend/webv2/src/platform/ui/Tabs.browser.test.tsx b/invokeai/frontend/webv2/src/platform/ui/Tabs.browser.test.tsx index e013fd812a8..0efc99939a6 100644 --- a/invokeai/frontend/webv2/src/platform/ui/Tabs.browser.test.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/Tabs.browser.test.tsx @@ -1,12 +1,10 @@ -import { Box, ChakraProvider, Stack, Tabs } from '@chakra-ui/react'; +import { ChakraProvider, Tabs } from '@chakra-ui/react'; import { system } from '@theme/system'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, describe, expect, it } from 'vitest'; import { userEvent } from 'vitest/browser'; -const variants = ['line', 'subtle', 'enclosed', 'outline', 'plain'] as const; - let host: HTMLDivElement | null = null; let root: Root | null = null; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -18,184 +16,47 @@ afterEach(async () => { root = null; }); -/** - * This test drives real pointer input across five variants, and each move costs - * whatever the machine can spare. Measured on a loaded 16-core box, the moves - * alone took 7–13.5s against the default 15s, and every failure was a timeout - * sitting at 12.5–13.6s — never an assertion. The settle waits were flat at - * ~1.1s throughout, so synchronization was never the problem; the budget was. - * Dropping the redundant unhovers below removes a third of the moves, and this - * ceiling covers what is left. - */ -const HOVER_SWEEP_TIMEOUT_MS = 60_000; - -describe('tab hover styles', () => { - it( - 'gives every variant restrained hover feedback without changing selected or disabled tabs', - async () => { - host = document.createElement('div'); - document.body.append(host); - root = createRoot(host); - - await act(async () => { - root?.render( - - - - - - - - {variants.map((variant) => ( - - - - Idle - - - Selected - - - Disabled - - - - ))} - - - ); - await new Promise((resolve) => { - globalThis.setTimeout(resolve, 0); - }); - }); - - const mutedBackground = getProbeStyle(host, 'muted background probe').backgroundColor; - const faintMutedBackground = getProbeStyle(host, 'faint muted background probe').backgroundColor; - const lightMutedBackground = getProbeStyle(host, 'light muted background probe').backgroundColor; - const emphasizedBackground = getProbeStyle(host, 'emphasized background probe').backgroundColor; - const emphasizedBorder = getProbeStyle(host, 'emphasized border probe').borderColor; - const expectedHoverBackgrounds = { - enclosed: emphasizedBackground, - line: faintMutedBackground, - outline: mutedBackground, - plain: lightMutedBackground, - subtle: mutedBackground, - }; - - for (const variant of variants) { - const idle = host.querySelector(`[aria-label="${variant} idle"]`)!; - const selected = host.querySelector(`[aria-label="${variant} selected"]`)!; - const disabled = host.querySelector(`[aria-label="${variant} disabled"]`)!; - const idleBefore = getInteractionStyles(idle); - const selectedBefore = getInteractionStyles(selected); - const disabledBefore = getInteractionStyles(disabled); - - await act(async () => { - await userEvent.hover(idle); - await waitForSettledStyles(idle); - }); - const idleHovered = getInteractionStyles(idle); - expect(idleHovered.transitionDuration).toBe('0.1s'); - expect(idleHovered.transitionProperty).toBe('background, border-color, color'); - expect(idleHovered.backgroundColor).toBe(expectedHoverBackgrounds[variant]); - if (variant === 'line' || variant === 'plain') { - expect(idleHovered.color).not.toBe(idleBefore.color); - } else { - expect(idleHovered.color).toBe(idleBefore.color); - } - if (variant === 'outline') { - expect(idleHovered.borderColor).toBe(emphasizedBorder); - } - - await act(async () => { - await userEvent.hover(selected); - await waitForSettledStyles(selected); - }); - expect(getInteractionStyles(selected)).toEqual(selectedBefore); - - await act(async () => { - await userEvent.hover(disabled); - await waitForSettledStyles(disabled); - }); - expect(getInteractionStyles(disabled)).toEqual(disabledBefore); - } - - const lineIdle = host.querySelector('[aria-label="line idle"]')!; - const lineSelected = host.querySelector('[aria-label="line selected"]')!; - await act(async () => { - await userEvent.tab(); - await userEvent.keyboard('{ArrowLeft}'); +describe('tab focus feedback', () => { + it('keeps the keyboard focus outline through hover without disturbing the selected tab', async () => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + + await act(async () => { + root?.render( + + + + + Idle + + + Selected + + + + + ); + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 0); }); - expect(document.activeElement).toBe(lineIdle); - const focusOutline = getComputedStyle(lineIdle).outline; - expect(focusOutline).not.toBe('none'); - - await act(async () => { - await userEvent.hover(lineIdle); - await waitForSettledStyles(lineIdle); - }); - expect(getComputedStyle(lineIdle).outline).toBe(focusOutline); - expect(lineSelected.dataset.selected).toBe(''); - }, - HOVER_SWEEP_TIMEOUT_MS - ); -}); - -const getInteractionStyles = (element: HTMLElement) => { - const styles = getComputedStyle(element); - - return { - backgroundColor: styles.backgroundColor, - borderColor: styles.borderColor, - color: styles.color, - transitionDuration: styles.transitionDuration, - transitionProperty: styles.transitionProperty, - }; -}; - -/** Consecutive unchanged samples that count as "the transition has finished". */ -const STABLE_SAMPLES = 3; -const SAMPLE_INTERVAL_MS = 16; - -/** - * Waits until an element's interaction styles stop changing, rather than - * sleeping a fixed interval after each hover. - * - * These transitions run for 0.1s, but the test hovers fifteen triggers and - * used to wait 200ms every time — three seconds of sleeping against a 15s - * test timeout, which is what made this fail on a loaded CI runner. Sampling - * until the values hold still takes as long as the machine actually needs, - * and usually far less. - */ -const waitForSettledStyles = (element: HTMLElement, timeoutMs = 5000): Promise => - new Promise((resolve, reject) => { - const deadline = Date.now() + timeoutMs; - let previous = JSON.stringify(getInteractionStyles(element)); - let stableSamples = 0; - - const sample = () => { - const current = JSON.stringify(getInteractionStyles(element)); - - stableSamples = current === previous ? stableSamples + 1 : 0; - previous = current; - - if (stableSamples >= STABLE_SAMPLES) { - resolve(); - - return; - } - - if (Date.now() > deadline) { - reject(new Error(`Timed out after ${timeoutMs}ms waiting for styles to settle; last value ${current}`)); - - return; - } - - globalThis.setTimeout(sample, SAMPLE_INTERVAL_MS); - }; - - globalThis.setTimeout(sample, SAMPLE_INTERVAL_MS); + }); + + const idle = host.querySelector('[aria-label="line idle"]')!; + const selected = host.querySelector('[aria-label="line selected"]')!; + + await act(async () => { + await userEvent.tab(); + await userEvent.keyboard('{ArrowLeft}'); + }); + expect(document.activeElement).toBe(idle); + const focusOutline = getComputedStyle(idle).outline; + expect(focusOutline).not.toBe('none'); + + await act(async () => { + await userEvent.hover(idle); + }); + expect(getComputedStyle(idle).outline).toBe(focusOutline); + expect(selected.dataset.selected).toBe(''); }); - -const getProbeStyle = (container: HTMLElement, label: string) => - getComputedStyle(container.querySelector(`[aria-label="${label}"]`)!); +}); diff --git a/invokeai/frontend/webv2/src/platform/ui/ToggleDot.tsx b/invokeai/frontend/webv2/src/platform/ui/ToggleDot.tsx index f014609bd6e..aeb376a5bd8 100644 --- a/invokeai/frontend/webv2/src/platform/ui/ToggleDot.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/ToggleDot.tsx @@ -38,7 +38,7 @@ export const ToggleDot = ({ bg={checked ? 'accent.solid' : 'transparent'} borderColor={checked ? 'accent.solid' : 'border.emphasized'} borderWidth="1px" - cursor={disabled ? 'not-allowed' : 'pointer'} + cursor={disabled ? 'not-allowed' : undefined} flexShrink="0" h="3" rounded="full" diff --git a/invokeai/frontend/webv2/src/platform/ui/hints/FeatureHint.tsx b/invokeai/frontend/webv2/src/platform/ui/hints/FeatureHint.tsx index 82af2d5319f..16b9ec75373 100644 --- a/invokeai/frontend/webv2/src/platform/ui/hints/FeatureHint.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/hints/FeatureHint.tsx @@ -40,7 +40,7 @@ const HintCard = ({ hint, onDisable }: { hint: FeatureHintId; onDisable: (() => {onDisable && ( - )} diff --git a/invokeai/frontend/webv2/src/platform/ui/index.ts b/invokeai/frontend/webv2/src/platform/ui/index.ts index 760646ea801..1bbbee6183d 100644 --- a/invokeai/frontend/webv2/src/platform/ui/index.ts +++ b/invokeai/frontend/webv2/src/platform/ui/index.ts @@ -7,13 +7,16 @@ export * from './DropTargetOverlay'; export * from './DropZone'; export * from './Field'; export * from './Group'; +export * from './InputShell'; export * from './JsonPreview'; export * from './Menu'; export * from './Panel'; +export * from './Popover'; export * from './RenameDialog'; export * from './ResizableTextarea'; export * from './Row'; export * from './Scrollable'; +export * from './SegmentedControl'; export * from './SegmentTabs'; export * from './Select'; export * from './Slider'; diff --git a/invokeai/frontend/webv2/src/platform/ui/theme/__fixtures__/legacyTokenBaseline.json b/invokeai/frontend/webv2/src/platform/ui/theme/__fixtures__/legacyTokenBaseline.json index 26ae854c66d..2d58f209463 100644 --- a/invokeai/frontend/webv2/src/platform/ui/theme/__fixtures__/legacyTokenBaseline.json +++ b/invokeai/frontend/webv2/src/platform/ui/theme/__fixtures__/legacyTokenBaseline.json @@ -1,48 +1,48 @@ { "classic": { - "bg": "oklch(21.074% 0.0087 264.37)", - "bg.subtle": "oklch(26.279% 0.0123 264.34)", - "bg.muted": "oklch(31.237% 0.0157 264.32)", - "bg.panel": "oklch(31.237% 0.0157 264.32)", - "bg.emphasized": "oklch(36.004% 0.019 264.3)", - "bg.inset": "oklch(21.074% 0.0087 264.37)", - "bg.error": "color-mix(in oklab, oklch(70.61% 0.0841 19.38) 14%, oklch(26.279% 0.0123 264.34))", - "bg.success": "color-mix(in oklab, oklch(79.8% 0.1132 141.63) 14%, oklch(26.279% 0.0123 264.34))", - "bg.warning": "color-mix(in oklab, oklch(76.62% 0.0612 62.9) 14%, oklch(26.279% 0.0123 264.34))", - "fg": "oklch(96.017% 0.0029 264.54)", - "fg.muted": "oklch(73.736% 0.0202 264.44)", - "fg.subtle": "oklch(57.965% 0.0337 264.27)", - "fg.grid": "oklch(40.619% 0.0221 264.29)", + "bg": "oklch(18.8% 0.014 264.4)", + "bg.subtle": "oklch(23.8% 0.016 264.35)", + "bg.muted": "oklch(28.8% 0.018 264.3)", + "bg.panel": "oklch(28.8% 0.018 264.3)", + "bg.emphasized": "oklch(34% 0.02 264.3)", + "bg.inset": "oklch(18.8% 0.014 264.4)", + "bg.error": "color-mix(in oklab, oklch(70.61% 0.0841 19.38) 14%, oklch(23.8% 0.016 264.35))", + "bg.success": "color-mix(in oklab, oklch(79.8% 0.1132 141.63) 14%, oklch(23.8% 0.016 264.35))", + "bg.warning": "color-mix(in oklab, oklch(76.62% 0.0612 62.9) 14%, oklch(23.8% 0.016 264.35))", + "fg": "oklch(96% 0.006 264.5)", + "fg.muted": "oklch(73.5% 0.019 264.4)", + "fg.subtle": "oklch(57.5% 0.026 264.3)", + "fg.grid": "oklch(38.5% 0.022 264.3)", "fg.error": "oklch(70.61% 0.0841 19.38)", "fg.success": "oklch(79.8% 0.1132 141.63)", "fg.warning": "oklch(76.62% 0.0612 62.9)", - "border": "oklch(40.619% 0.0221 264.29)", - "border.subtle": "oklch(40.619% 0.0221 264.29)", - "border.muted": "oklch(40.619% 0.0221 264.29)", - "border.emphasized": "oklch(45.106% 0.0251 264.28)", + "border": "oklch(39% 0.022 264.3)", + "border.subtle": "oklch(39% 0.022 264.3)", + "border.muted": "oklch(39% 0.022 264.3)", + "border.emphasized": "oklch(44% 0.024 264.3)", "border.error": "oklch(70.61% 0.0841 19.38)", - "gray.contrast": "oklch(21.074% 0.0087 264.37)", - "gray.fg": "oklch(96.017% 0.0029 264.54)", - "gray.subtle": "oklch(38.5% 0.0203 264.29)", - "gray.muted": "oklch(36.004% 0.019 264.3)", - "gray.emphasized": "oklch(45.106% 0.0251 264.28)", - "gray.solid": "oklch(96.017% 0.0029 264.54)", + "gray.contrast": "oklch(18.8% 0.014 264.4)", + "gray.fg": "oklch(96% 0.006 264.5)", + "gray.subtle": "oklch(36% 0.021 264.3)", + "gray.muted": "oklch(34% 0.02 264.3)", + "gray.emphasized": "oklch(44% 0.024 264.3)", + "gray.solid": "oklch(96% 0.006 264.5)", "gray.focusRing": "oklch(77.738% 0.1 231.76)", - "gray.border": "oklch(45.106% 0.0251 264.28)", + "gray.border": "oklch(44% 0.024 264.3)", "brand.solid": "oklch(92.041% 0.2103 116.59)", - "brand.contrast": "oklch(21.074% 0.0087 264.37)", + "brand.contrast": "oklch(18.8% 0.014 264.4)", "brand.fg": "oklch(92.041% 0.2103 116.59)", - "brand.subtle": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 16%, oklch(26.279% 0.0123 264.34))", - "brand.muted": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 26%, oklch(26.279% 0.0123 264.34))", - "brand.emphasized": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 36%, oklch(26.279% 0.0123 264.34))", + "brand.subtle": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 16%, oklch(23.8% 0.016 264.35))", + "brand.muted": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 26%, oklch(23.8% 0.016 264.35))", + "brand.emphasized": "color-mix(in oklab, oklch(92.041% 0.2103 116.59) 36%, oklch(23.8% 0.016 264.35))", "brand.focusRing": "oklch(77.738% 0.1 231.76)", "brand.border": "oklch(92.041% 0.2103 116.59)", "accent.solid": "oklch(77.738% 0.1 231.76)", - "accent.contrast": "oklch(21.074% 0.0087 264.37)", + "accent.contrast": "oklch(18.8% 0.014 264.4)", "accent.fg": "oklch(77.738% 0.1 231.76)", - "accent.subtle": "color-mix(in oklab, oklch(77.738% 0.1 231.76) 16%, oklch(26.279% 0.0123 264.34))", - "accent.muted": "color-mix(in oklab, oklch(77.738% 0.1 231.76) 26%, oklch(26.279% 0.0123 264.34))", - "accent.emphasized": "color-mix(in oklab, oklch(77.738% 0.1 231.76) 36%, oklch(26.279% 0.0123 264.34))", + "accent.subtle": "color-mix(in oklab, oklch(77.738% 0.1 231.76) 16%, oklch(23.8% 0.016 264.35))", + "accent.muted": "color-mix(in oklab, oklch(77.738% 0.1 231.76) 26%, oklch(23.8% 0.016 264.35))", + "accent.emphasized": "color-mix(in oklab, oklch(77.738% 0.1 231.76) 36%, oklch(23.8% 0.016 264.35))", "accent.focusRing": "oklch(77.738% 0.1 231.76)", "accent.border": "oklch(77.738% 0.1 231.76)" }, @@ -93,52 +93,52 @@ "accent.focusRing": "oklch(54.615% 0.2152 262.88)", "accent.border": "oklch(54.615% 0.2152 262.88)" }, - "forest": { - "bg": "oklch(17.349% 0.0154 144.88)", - "bg.subtle": "oklch(19.03% 0.0175 144.85)", - "bg.muted": "oklch(20.99% 0.0219 144.74)", - "bg.panel": "oklch(20.99% 0.0219 144.74)", - "bg.emphasized": "oklch(25.62% 0.03 144.64)", - "bg.inset": "oklch(17.349% 0.0154 144.88)", - "bg.error": "color-mix(in oklab, oklch(69.305% 0.1467 35.44) 14%, oklch(19.03% 0.0175 144.85))", - "bg.success": "color-mix(in oklab, oklch(78% 0.16 148) 14%, oklch(19.03% 0.0175 144.85))", - "bg.warning": "color-mix(in oklab, oklch(80% 0.12 76) 14%, oklch(19.03% 0.0175 144.85))", - "fg": "oklch(90.57% 0.0511 134.45)", - "fg.muted": "oklch(71.619% 0.0575 135.25)", - "fg.subtle": "oklch(53.577% 0.0538 136.73)", - "fg.grid": "oklch(34.825% 0.0526 141.63)", - "fg.error": "oklch(69.305% 0.1467 35.44)", - "fg.success": "oklch(78% 0.16 148)", - "fg.warning": "oklch(80% 0.12 76)", - "border": "oklch(27.425% 0.0354 143.04)", - "border.subtle": "oklch(27.425% 0.0354 143.04)", - "border.muted": "oklch(27.425% 0.0354 143.04)", - "border.emphasized": "oklch(36.133% 0.0575 141.06)", - "border.error": "oklch(69.305% 0.1467 35.44)", - "gray.contrast": "oklch(17.349% 0.0154 144.88)", - "gray.fg": "oklch(90.57% 0.0511 134.45)", - "gray.subtle": "oklch(28.489% 0.0483 141.22)", - "gray.muted": "oklch(25.62% 0.03 144.64)", - "gray.emphasized": "oklch(36.133% 0.0575 141.06)", - "gray.solid": "oklch(90.57% 0.0511 134.45)", - "gray.focusRing": "oklch(70.437% 0.1101 178.23)", - "gray.border": "oklch(36.133% 0.0575 141.06)", - "brand.solid": "oklch(79.714% 0.1784 136.37)", - "brand.contrast": "oklch(18.298% 0.0314 147.69)", - "brand.fg": "oklch(79.714% 0.1784 136.37)", - "brand.subtle": "color-mix(in oklab, oklch(79.714% 0.1784 136.37) 16%, oklch(19.03% 0.0175 144.85))", - "brand.muted": "color-mix(in oklab, oklch(79.714% 0.1784 136.37) 26%, oklch(19.03% 0.0175 144.85))", - "brand.emphasized": "color-mix(in oklab, oklch(79.714% 0.1784 136.37) 36%, oklch(19.03% 0.0175 144.85))", - "brand.focusRing": "oklch(70.437% 0.1101 178.23)", - "brand.border": "oklch(79.714% 0.1784 136.37)", - "accent.solid": "oklch(70.437% 0.1101 178.23)", - "accent.contrast": "oklch(17.44% 0.0258 171.89)", - "accent.fg": "oklch(70.437% 0.1101 178.23)", - "accent.subtle": "color-mix(in oklab, oklch(70.437% 0.1101 178.23) 16%, oklch(19.03% 0.0175 144.85))", - "accent.muted": "color-mix(in oklab, oklch(70.437% 0.1101 178.23) 26%, oklch(19.03% 0.0175 144.85))", - "accent.emphasized": "color-mix(in oklab, oklch(70.437% 0.1101 178.23) 36%, oklch(19.03% 0.0175 144.85))", - "accent.focusRing": "oklch(70.437% 0.1101 178.23)", - "accent.border": "oklch(70.437% 0.1101 178.23)" + "osakaJade": { + "bg": "oklch(16.5% 0.015 184)", + "bg.subtle": "oklch(18.5% 0.017 184)", + "bg.muted": "oklch(21% 0.021 184)", + "bg.panel": "oklch(21% 0.021 184)", + "bg.emphasized": "oklch(25.5% 0.026 183)", + "bg.inset": "oklch(16.5% 0.015 184)", + "bg.error": "color-mix(in oklab, oklch(69.5% 0.147 33) 14%, oklch(18.5% 0.017 184))", + "bg.success": "color-mix(in oklab, oklch(78% 0.15 155) 14%, oklch(18.5% 0.017 184))", + "bg.warning": "color-mix(in oklab, oklch(80% 0.12 78) 14%, oklch(18.5% 0.017 184))", + "fg": "oklch(92% 0.028 178)", + "fg.muted": "oklch(70.5% 0.036 180)", + "fg.subtle": "oklch(53% 0.037 181)", + "fg.grid": "oklch(34.5% 0.035 182)", + "fg.error": "oklch(69.5% 0.147 33)", + "fg.success": "oklch(78% 0.15 155)", + "fg.warning": "oklch(80% 0.12 78)", + "border": "oklch(28.5% 0.03 183)", + "border.subtle": "oklch(28.5% 0.03 183)", + "border.muted": "oklch(28.5% 0.03 183)", + "border.emphasized": "oklch(36.5% 0.038 182)", + "border.error": "oklch(69.5% 0.147 33)", + "gray.contrast": "oklch(16.5% 0.015 184)", + "gray.fg": "oklch(92% 0.028 178)", + "gray.subtle": "oklch(28.5% 0.032 182)", + "gray.muted": "oklch(25.5% 0.026 183)", + "gray.emphasized": "oklch(36.5% 0.038 182)", + "gray.solid": "oklch(92% 0.028 178)", + "gray.focusRing": "oklch(72% 0.105 195)", + "gray.border": "oklch(36.5% 0.038 182)", + "brand.solid": "oklch(81% 0.145 165)", + "brand.contrast": "oklch(17% 0.028 175)", + "brand.fg": "oklch(81% 0.145 165)", + "brand.subtle": "color-mix(in oklab, oklch(81% 0.145 165) 16%, oklch(18.5% 0.017 184))", + "brand.muted": "color-mix(in oklab, oklch(81% 0.145 165) 26%, oklch(18.5% 0.017 184))", + "brand.emphasized": "color-mix(in oklab, oklch(81% 0.145 165) 36%, oklch(18.5% 0.017 184))", + "brand.focusRing": "oklch(72% 0.105 195)", + "brand.border": "oklch(81% 0.145 165)", + "accent.solid": "oklch(72% 0.105 195)", + "accent.contrast": "oklch(16.5% 0.024 195)", + "accent.fg": "oklch(72% 0.105 195)", + "accent.subtle": "color-mix(in oklab, oklch(72% 0.105 195) 16%, oklch(18.5% 0.017 184))", + "accent.muted": "color-mix(in oklab, oklch(72% 0.105 195) 26%, oklch(18.5% 0.017 184))", + "accent.emphasized": "color-mix(in oklab, oklch(72% 0.105 195) 36%, oklch(18.5% 0.017 184))", + "accent.focusRing": "oklch(72% 0.105 195)", + "accent.border": "oklch(72% 0.105 195)" }, "mono": { "bg": "oklch(18.22% 0 0)", @@ -189,48 +189,48 @@ }, "ultradark": { "bg": "oklch(0% 0 0)", - "bg.subtle": "oklch(11.492% 0 0)", - "bg.muted": "oklch(14.479% 0 0)", - "bg.panel": "oklch(14.479% 0 0)", - "bg.emphasized": "oklch(19.125% 0 0)", + "bg.subtle": "oklch(11.5% 0.003 250)", + "bg.muted": "oklch(14.5% 0.003 250)", + "bg.panel": "oklch(14.5% 0.003 250)", + "bg.emphasized": "oklch(19% 0.004 250)", "bg.inset": "oklch(0% 0 0)", - "bg.error": "color-mix(in oklab, oklch(71.161% 0.1812 22.84) 14%, oklch(11.492% 0 0))", - "bg.success": "color-mix(in oklab, oklch(76.5% 0.16 150.5) 14%, oklch(11.492% 0 0))", - "bg.warning": "color-mix(in oklab, oklch(79.5% 0.13 80) 14%, oklch(11.492% 0 0))", - "fg": "oklch(88.901% 0.0317 120.83)", - "fg.muted": "oklch(66.382% 0.0192 131.96)", - "fg.subtle": "oklch(47.041% 0.0184 127.12)", - "fg.grid": "oklch(23.929% 0 0)", + "bg.error": "color-mix(in oklab, oklch(71.161% 0.1812 22.84) 14%, oklch(11.5% 0.003 250))", + "bg.success": "color-mix(in oklab, oklch(76.5% 0.16 150.5) 14%, oklch(11.5% 0.003 250))", + "bg.warning": "color-mix(in oklab, oklch(79.5% 0.13 80) 14%, oklch(11.5% 0.003 250))", + "fg": "oklch(89% 0.008 250)", + "fg.muted": "oklch(66.5% 0.007 250)", + "fg.subtle": "oklch(47% 0.006 250)", + "fg.grid": "oklch(24% 0.004 250)", "fg.error": "oklch(71.161% 0.1812 22.84)", "fg.success": "oklch(76.5% 0.16 150.5)", "fg.warning": "oklch(79.5% 0.13 80)", - "border": "oklch(20.904% 0 0)", - "border.subtle": "oklch(20.904% 0 0)", - "border.muted": "oklch(20.904% 0 0)", - "border.emphasized": "oklch(26.862% 0 0)", + "border": "oklch(21% 0.004 250)", + "border.subtle": "oklch(21% 0.004 250)", + "border.muted": "oklch(21% 0.004 250)", + "border.emphasized": "oklch(27% 0.005 250)", "border.error": "oklch(71.161% 0.1812 22.84)", "gray.contrast": "oklch(0% 0 0)", - "gray.fg": "oklch(88.901% 0.0317 120.83)", - "gray.subtle": "oklch(21.779% 0 0)", - "gray.muted": "oklch(19.125% 0 0)", - "gray.emphasized": "oklch(26.862% 0 0)", - "gray.solid": "oklch(88.901% 0.0317 120.83)", + "gray.fg": "oklch(89% 0.008 250)", + "gray.subtle": "oklch(21.8% 0.004 250)", + "gray.muted": "oklch(19% 0.004 250)", + "gray.emphasized": "oklch(27% 0.005 250)", + "gray.solid": "oklch(89% 0.008 250)", "gray.focusRing": "oklch(80.623% 0.1248 228.24)", - "gray.border": "oklch(26.862% 0 0)", + "gray.border": "oklch(27% 0.005 250)", "brand.solid": "oklch(93.444% 0.19 125.56)", - "brand.contrast": "oklch(15.913% 0.0233 128.66)", + "brand.contrast": "oklch(15% 0.004 250)", "brand.fg": "oklch(93.444% 0.19 125.56)", - "brand.subtle": "color-mix(in oklab, oklch(93.444% 0.19 125.56) 16%, oklch(11.492% 0 0))", - "brand.muted": "color-mix(in oklab, oklch(93.444% 0.19 125.56) 26%, oklch(11.492% 0 0))", - "brand.emphasized": "color-mix(in oklab, oklch(93.444% 0.19 125.56) 36%, oklch(11.492% 0 0))", + "brand.subtle": "color-mix(in oklab, oklch(93.444% 0.19 125.56) 16%, oklch(11.5% 0.003 250))", + "brand.muted": "color-mix(in oklab, oklch(93.444% 0.19 125.56) 26%, oklch(11.5% 0.003 250))", + "brand.emphasized": "color-mix(in oklab, oklch(93.444% 0.19 125.56) 36%, oklch(11.5% 0.003 250))", "brand.focusRing": "oklch(80.623% 0.1248 228.24)", "brand.border": "oklch(93.444% 0.19 125.56)", "accent.solid": "oklch(80.623% 0.1248 228.24)", "accent.contrast": "oklch(17.416% 0.0256 235.84)", "accent.fg": "oklch(80.623% 0.1248 228.24)", - "accent.subtle": "color-mix(in oklab, oklch(80.623% 0.1248 228.24) 16%, oklch(11.492% 0 0))", - "accent.muted": "color-mix(in oklab, oklch(80.623% 0.1248 228.24) 26%, oklch(11.492% 0 0))", - "accent.emphasized": "color-mix(in oklab, oklch(80.623% 0.1248 228.24) 36%, oklch(11.492% 0 0))", + "accent.subtle": "color-mix(in oklab, oklch(80.623% 0.1248 228.24) 16%, oklch(11.5% 0.003 250))", + "accent.muted": "color-mix(in oklab, oklch(80.623% 0.1248 228.24) 26%, oklch(11.5% 0.003 250))", + "accent.emphasized": "color-mix(in oklab, oklch(80.623% 0.1248 228.24) 36%, oklch(11.5% 0.003 250))", "accent.focusRing": "oklch(80.623% 0.1248 228.24)", "accent.border": "oklch(80.623% 0.1248 228.24)" } diff --git a/invokeai/frontend/webv2/src/platform/ui/theme/__tokenResolve.ts b/invokeai/frontend/webv2/src/platform/ui/theme/__tokenResolve.ts index 0bdd9a12b38..a991d2e3899 100644 --- a/invokeai/frontend/webv2/src/platform/ui/theme/__tokenResolve.ts +++ b/invokeai/frontend/webv2/src/platform/ui/theme/__tokenResolve.ts @@ -25,7 +25,7 @@ const dataSel = (id: string): string => `&:root[data-theme=${id}]`; export const THEME_SELECTORS: Record = { classic: [DARK_SEL, BASE_SEL], // default theme: no [data-theme=classic] rule light: [dataSel('light'), LIGHT_SEL, BASE_SEL], - forest: [dataSel('forest'), DARK_SEL, BASE_SEL], + osakaJade: [dataSel('osakaJade'), DARK_SEL, BASE_SEL], mono: [dataSel('mono'), DARK_SEL, BASE_SEL], ultradark: [dataSel('ultradark'), DARK_SEL, BASE_SEL], }; diff --git a/invokeai/frontend/webv2/src/platform/ui/theme/contrastRatio.testing.ts b/invokeai/frontend/webv2/src/platform/ui/theme/contrastRatio.testing.ts index b1383e97bcb..7a76261813e 100644 --- a/invokeai/frontend/webv2/src/platform/ui/theme/contrastRatio.testing.ts +++ b/invokeai/frontend/webv2/src/platform/ui/theme/contrastRatio.testing.ts @@ -49,3 +49,21 @@ export const getContrastRatio = (foreground: string, background: string, alpha: return (lighter! + 0.05) / (darker! + 0.05); }; + +/** + * The colour a translucent fill actually produces over an opaque surface. + * Painted rather than parsed: `toRgb` reads raw channels and would report a + * semi-transparent background as if it were opaque. + */ +export const compositeColors = (top: string, bottom: string): string => { + const context = document.createElement('canvas').getContext('2d')!; + + context.fillStyle = bottom; + context.fillRect(0, 0, 1, 1); + context.fillStyle = top; + context.fillRect(0, 0, 1, 1); + + const [red, green, blue] = context.getImageData(0, 0, 1, 1).data; + + return `rgb(${String(red)}, ${String(green)}, ${String(blue)})`; +}; diff --git a/invokeai/frontend/webv2/src/platform/ui/theme/recipes.ts b/invokeai/frontend/webv2/src/platform/ui/theme/recipes.ts index fc119983c7b..8a4586615ad 100644 --- a/invokeai/frontend/webv2/src/platform/ui/theme/recipes.ts +++ b/invokeai/frontend/webv2/src/platform/ui/theme/recipes.ts @@ -19,6 +19,9 @@ export const tooltipSlotRecipe = defineSlotRecipe({ borderWidth: '1px', boxShadow: 'lg', color: 'fg', + // Chakra's `fast` scale-fade drags on an annotation this small. + _open: { ...chakraSlotRecipes.tooltip.base?.content?._open, animationDuration: 'faster' }, + _closed: { ...chakraSlotRecipes.tooltip.base?.content?._closed, animationDuration: 'faster' }, }, arrowTip: { ...chakraSlotRecipes.tooltip.base?.arrowTip, @@ -58,6 +61,32 @@ export const hoverCardSlotRecipe = defineSlotRecipe({ defaultVariants: { size: 'xs' }, }); +/** + * Popover chrome: same raised surface as the tooltip/hover-card family, with + * an arrow pointing at the anchor. Extends Chakra's default recipe so the + * `arrow` slot keeps its `--arrow-size`/`--arrow-background` vars (which + * derive from `--popover-bg`); replacing the base wholesale would render + * arrows at zero size. + */ +export const popoverSlotRecipe = defineSlotRecipe({ + ...chakraSlotRecipes.popover, + base: { + ...chakraSlotRecipes.popover.base, + content: { + ...chakraSlotRecipes.popover.base?.content, + '--popover-bg': 'colors.bg.muted', + borderColor: 'border.emphasized', + borderWidth: '1px', + boxShadow: 'lg', + color: 'fg', + }, + arrowTip: { + ...chakraSlotRecipes.popover.base?.arrowTip, + borderColor: 'border.emphasized', + }, + }, +}); + export const tabsSlotRecipe = defineSlotRecipe({ ...chakraSlotRecipes.tabs, base: { @@ -106,8 +135,12 @@ export const tabsSlotRecipe = defineSlotRecipe({ ...chakraSlotRecipes.tabs.variants?.variant?.subtle, trigger: { ...chakraSlotRecipes.tabs.variants?.variant?.subtle?.trigger, + // The buttons' corner and translucent accent-leaning hover (see + // `SegmentTabs`): solid subtle fills vanish against muted chrome. + // Selected stays the stock accent fill — nav sidebars rely on it. + borderRadius: 'control', _hover: { - '&:not([data-selected])': { bg: 'bg.muted' }, + '&:not([data-selected])': { bg: 'gray.hoverTint/10', color: 'fg' }, }, }, }, @@ -147,12 +180,44 @@ export const tabsSlotRecipe = defineSlotRecipe({ export const buttonRecipe = defineRecipe({ ...chakraRecipes.button, + base: { + ...chakraRecipes.button.base, + borderRadius: 'control', + // Chakra's `moderate` hover fade reads as lag on a busy workbench. + transitionDuration: 'faster', + }, variants: { ...chakraRecipes.button.variants, + // One notch denser than Chakra's scale: `xs` lands on the segment-tab + // pill height, so the controls that share a row share a silhouette. size: { ...chakraRecipes.button.variants?.size, - sm: { ...chakraRecipes.button.variants?.size?.sm, textStyle: 'xs' }, - md: { ...chakraRecipes.button.variants?.size?.md, textStyle: 'xs' }, + xs: { ...chakraRecipes.button.variants?.size?.xs, h: '7', minW: '7' }, + sm: { ...chakraRecipes.button.variants?.size?.sm, h: '8', minW: '8', px: '3', textStyle: 'xs' }, + md: { ...chakraRecipes.button.variants?.size?.md, h: '9', minW: '9', textStyle: 'xs' }, + }, + variant: { + ...chakraRecipes.button.variants?.variant, + // Chakra's ghost/outline hover is the solid `subtle` fill, whose + // lightness collides with muted/control surfaces (invisible hover); a + // translucent `hoverTint` fill reads on every surface, leans toward the + // accent on the default palette, and keeps the tint of the others. + ghost: { + ...chakraRecipes.button.variants?.variant?.ghost, + _hover: { bg: 'colorPalette.hoverTint/10' }, + _expanded: { bg: 'colorPalette.hoverTint/10' }, + }, + outline: { + ...chakraRecipes.button.variants?.variant?.outline, + _hover: { bg: 'colorPalette.hoverTint/10' }, + _expanded: { bg: 'colorPalette.hoverTint/10' }, + }, + // Stock Chakra gives plain buttons no hover state at all; they take the + // same surface-proof fill as ghost. + plain: { + ...chakraRecipes.button.variants?.variant?.plain, + _hover: { bg: 'colorPalette.hoverTint/10' }, + }, }, } as unknown as typeof chakraRecipes.button.variants, }); @@ -170,7 +235,9 @@ export const segmentGroupSlotRecipe = defineSlotRecipe({ '--segment-indicator-shadow': 'none', bg: 'transparent', borderColor: 'border.subtle', - borderRadius: 'md', + // The buttons' shared corner outside, minus the 1px border inside — the + // sm segment radius already sits at that inner value. + borderRadius: 'control', borderWidth: '1px', boxShadow: 'none', }, @@ -197,6 +264,9 @@ export const segmentGroupSlotRecipe = defineSlotRecipe({ }, indicator: { ...chakraSlotRecipes.segmentGroup.base?.indicator, + // Zag slides the indicator via inline `var(--transition-duration, 150ms)`; + // pointing the var at the motion-aware `fast` token collapses it under reduce motion. + '--transition-duration': '{durations.fast}', shadow: 'none', zIndex: 0, }, @@ -223,14 +293,14 @@ export const segmentGroupSlotRecipe = defineSlotRecipe({ xs: { item: { ...chakraSlotRecipes.segmentGroup.variants?.size?.xs?.item, - height: 'calc({sizes.8} - 2px)', + height: 'calc({sizes.7} - 2px)', px: '2.5', }, }, sm: { item: { ...chakraSlotRecipes.segmentGroup.variants?.size?.sm?.item, - height: 'calc({sizes.9} - 2px)', + height: 'calc({sizes.8} - 2px)', px: '3.5', textStyle: 'xs', }, @@ -275,10 +345,28 @@ export const formControlInteraction = { const formControlOpen = { borderColor: 'accent.solid' }; +/** + * `formControlInteraction` keyed on focus-within, for composite fields whose + * focusable element lives inside the frame (see `platform/ui/InputShell`). + */ +export const inputShellInteraction = { + ...formControlInteraction, + _focusWithin: formControlFocused, + _hover: { ...formControlInteraction._hover, _focusWithin: formControlFocused }, +}; + export const inputRecipe = defineRecipe({ ...chakraRecipes.input, variants: { ...chakraRecipes.input.variants, + // The same one-notch drop as the button scale, so same-named sizes share a + // row height (select/combobox/numberInput repeat it for their vars). + size: { + ...chakraRecipes.input.variants?.size, + xs: { ...chakraRecipes.input.variants?.size?.xs, '--input-height': 'sizes.7' }, + sm: { ...chakraRecipes.input.variants?.size?.sm, '--input-height': 'sizes.8' }, + md: { ...chakraRecipes.input.variants?.size?.md, '--input-height': 'sizes.9' }, + }, variant: { ...chakraRecipes.input.variants?.variant, outline: { ...chakraRecipes.input.variants?.variant?.outline, ...formControlNoFocusRing }, @@ -311,6 +399,21 @@ export const numberInputSlotRecipe = defineSlotRecipe({ ...chakraSlotRecipes.numberInput, variants: { ...chakraSlotRecipes.numberInput.variants, + size: { + ...chakraSlotRecipes.numberInput.variants?.size, + xs: { + ...chakraSlotRecipes.numberInput.variants?.size?.xs, + input: { ...chakraSlotRecipes.numberInput.variants?.size?.xs?.input, '--input-height': 'sizes.7' }, + }, + sm: { + ...chakraSlotRecipes.numberInput.variants?.size?.sm, + input: { ...chakraSlotRecipes.numberInput.variants?.size?.sm?.input, '--input-height': 'sizes.8' }, + }, + md: { + ...chakraSlotRecipes.numberInput.variants?.size?.md, + input: { ...chakraSlotRecipes.numberInput.variants?.size?.md?.input, '--input-height': 'sizes.9' }, + }, + }, variant: { ...chakraSlotRecipes.numberInput.variants?.variant, outline: { @@ -348,6 +451,14 @@ export const dropdownContent = { }; export const dropdownItem = { + borderRadius: 'l2', + // One `data-danger` attribute is the whole destructive treatment; every + // delete/uninstall/clear item opts in instead of restyling locally. + '&[data-danger]': { + color: 'fg.error', + _highlighted: { bg: 'bg.error' }, + _hover: { bg: 'bg.error' }, + }, _highlighted: { bg: 'bg.emphasized' }, _hover: { bg: 'bg.emphasized' }, _focusVisible: { @@ -402,6 +513,21 @@ export const selectSlotRecipe = defineSlotRecipe({ // anchored to Chakra's own map, which the spread-with-override loses. variants: { ...chakraSlotRecipes.select.variants, + size: { + ...chakraSlotRecipes.select.variants?.size, + xs: { + ...chakraSlotRecipes.select.variants?.size?.xs, + root: { ...chakraSlotRecipes.select.variants?.size?.xs?.root, '--select-trigger-height': 'sizes.7' }, + }, + sm: { + ...chakraSlotRecipes.select.variants?.size?.sm, + root: { ...chakraSlotRecipes.select.variants?.size?.sm?.root, '--select-trigger-height': 'sizes.8' }, + }, + md: { + ...chakraSlotRecipes.select.variants?.size?.md, + root: { ...chakraSlotRecipes.select.variants?.size?.md?.root, '--select-trigger-height': 'sizes.9' }, + }, + }, variant: { ...chakraSlotRecipes.select.variants?.variant, outline: { @@ -420,7 +546,7 @@ export const selectSlotRecipe = defineSlotRecipe({ }, }, }, - } as typeof chakraSlotRecipes.select.variants, + } as unknown as typeof chakraSlotRecipes.select.variants, base: { ...chakraSlotRecipes.select.base, trigger: { @@ -447,6 +573,21 @@ export const comboboxSlotRecipe = defineSlotRecipe({ ...chakraSlotRecipes.combobox, variants: { ...chakraSlotRecipes.combobox.variants, + size: { + ...chakraSlotRecipes.combobox.variants?.size, + xs: { + ...chakraSlotRecipes.combobox.variants?.size?.xs, + root: { ...chakraSlotRecipes.combobox.variants?.size?.xs?.root, '--combobox-input-height': 'sizes.7' }, + }, + sm: { + ...chakraSlotRecipes.combobox.variants?.size?.sm, + root: { ...chakraSlotRecipes.combobox.variants?.size?.sm?.root, '--combobox-input-height': 'sizes.8' }, + }, + md: { + ...chakraSlotRecipes.combobox.variants?.size?.md, + root: { ...chakraSlotRecipes.combobox.variants?.size?.md?.root, '--combobox-input-height': 'sizes.9' }, + }, + }, variant: { ...chakraSlotRecipes.combobox.variants?.variant, outline: { @@ -487,21 +628,78 @@ export const comboboxSlotRecipe = defineSlotRecipe({ }, }); +/** + * The one dialog look: compact tool windows on a single surface. Density and + * chrome live here — a dialog file should carry structure, not styling. + * Chakra's stock 24px gutters, `lg` title, and top placement all read as a + * marketing modal rather than a desktop app's dialog. + */ export const dialogSlotRecipe = defineSlotRecipe({ ...chakraSlotRecipes.dialog, base: { ...chakraSlotRecipes.dialog.base, content: { ...chakraSlotRecipes.dialog.base?.content, + bg: 'bg.subtle', borderColor: 'border.subtle', borderWidth: '1px', + color: 'fg', + }, + header: { + ...chakraSlotRecipes.dialog.base?.header, + px: '4', + pt: '3', + pb: '2', + }, + body: { + ...chakraSlotRecipes.dialog.base?.body, + px: '4', + pt: '1.5', + pb: '4', + }, + footer: { + ...chakraSlotRecipes.dialog.base?.footer, + gap: '2', + px: '4', + pt: '1', + pb: '3', }, - // Chakra's stock `lg` title reads as a page heading; dialogs here are - // compact tool windows, so titles match the app's `sm`/700 convention. title: { ...chakraSlotRecipes.dialog.base?.title, fontWeight: '700', - textStyle: 'sm', + textStyle: 'xs', + }, + description: { + ...chakraSlotRecipes.dialog.base?.description, + color: 'fg.subtle', + textStyle: 'xs', + }, + closeTrigger: { + ...chakraSlotRecipes.dialog.base?.closeTrigger, + top: '1.5', + insetEnd: '1.5', + }, + }, + defaultVariants: { + ...chakraSlotRecipes.dialog.defaultVariants, + placement: 'center', + }, +}); + +/** + * Chakra hides a scrollbar only when NEITHER axis overflows + * (`&:not([data-overflow-x], [data-overflow-y])`), so a vertical-only bar + * sticks around — thumb clamped to its minimum size — whenever content merely + * spills sideways (nowrap rows, wide JSON). Each bar answers for its own axis. + */ +export const scrollAreaSlotRecipe = defineSlotRecipe({ + ...chakraSlotRecipes.scrollArea, + base: { + ...chakraSlotRecipes.scrollArea.base, + scrollbar: { + ...chakraSlotRecipes.scrollArea.base?.scrollbar, + '&[data-orientation="vertical"]:not([data-overflow-y])': { display: 'none' }, + '&[data-orientation="horizontal"]:not([data-overflow-x])': { display: 'none' }, }, }, }); @@ -651,6 +849,32 @@ export const colorPickerSlotRecipe = defineSlotRecipe({ }, }); +/** + * Skeletons sweep a subtle highlight instead of pulsing. The gradient rests on + * the same `bg.emphasized` surface the stock pulse used; the band is a small + * fg lift so it stays quiet on every theme. Reduce-motion is handled by the + * global `.chakra-skeleton` animation kill in `system.ts`. + */ +export const skeletonRecipe = defineRecipe({ + ...chakraRecipes.skeleton, + variants: { + ...chakraRecipes.skeleton.variants, + variant: { + ...chakraRecipes.skeleton.variants?.variant, + shine: { + ...chakraRecipes.skeleton.variants?.variant?.shine, + '--duration': '2s', + '--end-color': 'colors.bg.emphasized', + '--start-color': 'color-mix(in oklab, {colors.fg} 8%, {colors.bg.emphasized})', + }, + }, + } as unknown as typeof chakraRecipes.skeleton.variants, + defaultVariants: { + ...chakraRecipes.skeleton.defaultVariants, + variant: 'shine', + }, +}); + export const panelRecipe = defineRecipe({ base: { bg: 'bg.subtle', @@ -682,7 +906,6 @@ export const rowRecipe = defineRecipe({ base: { alignItems: 'center', borderRadius: 'sm', - cursor: 'pointer', display: 'flex', gap: '2', textAlign: 'start', @@ -763,7 +986,6 @@ export const themeCardRecipe = defineSlotRecipe({ borderColor: 'border.subtle', borderRadius: 'lg', borderWidth: '1px', - cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: '2.5', diff --git a/invokeai/frontend/webv2/src/platform/ui/theme/system.test.ts b/invokeai/frontend/webv2/src/platform/ui/theme/system.test.ts index ae48691accb..cb226e15882 100644 --- a/invokeai/frontend/webv2/src/platform/ui/theme/system.test.ts +++ b/invokeai/frontend/webv2/src/platform/ui/theme/system.test.ts @@ -57,7 +57,7 @@ describe('ramp + mapping structure', () => { it('uses pure black/white low-opacity image outlines by color mode', () => { expect(resolveToken(sys, 'light', 'border.image')).toBe('oklch(0 0 0 / 0.1)'); - for (const theme of ['classic', 'forest', 'mono', 'ultradark']) { + for (const theme of ['classic', 'osakaJade', 'mono', 'ultradark']) { expect(resolveToken(sys, theme, 'border.image')).toBe('oklch(1 0 0 / 0.1)'); } }); diff --git a/invokeai/frontend/webv2/src/platform/ui/theme/system.ts b/invokeai/frontend/webv2/src/platform/ui/theme/system.ts index 60c569cb7fd..49bcf1dcf8b 100644 --- a/invokeai/frontend/webv2/src/platform/ui/theme/system.ts +++ b/invokeai/frontend/webv2/src/platform/ui/theme/system.ts @@ -10,9 +10,12 @@ import { inputRecipe, menuSlotRecipe, numberInputSlotRecipe, + popoverSlotRecipe, progressCircleSlotRecipe, + scrollAreaSlotRecipe, segmentGroupSlotRecipe, selectSlotRecipe, + skeletonRecipe, sliderSlotRecipe, tabsSlotRecipe, textareaRecipe, @@ -187,7 +190,23 @@ const semanticColors = { border: grayToken((theme) => theme.colorScheme === 'light' ? theme.colors.neutral[400] : theme.colors.neutral[500] ), + /** + * Interaction-fill base for the default palette: fg pulled toward the + * accent, so translucent hovers read in the menus' cool-tinted family + * instead of a flat gray. Used at low alpha (`gray.hoverTint/10`). + */ + hoverTint: grayToken( + (theme) => + `color-mix(in oklab, ${theme.colors.accent.solid} 40%, ${ + theme.colorScheme === 'light' ? theme.colors.neutral[950] : theme.colors.neutral[50] + })` + ), }, + // Palette-tinted interaction fills for the non-default palettes buttons use. + red: { hoverTint: { value: '{colors.red.fg}' } }, + orange: { hoverTint: { value: '{colors.orange.fg}' } }, + green: { hoverTint: { value: '{colors.green.fg}' } }, + blue: { hoverTint: { value: '{colors.blue.fg}' } }, /** * Invoke identity palette (lime). Authored from two seeds (`solid` + `contrast`), * like `accent`; the rest derive. `brand.fg` is the seed on the dark themes and a @@ -204,6 +223,7 @@ const semanticColors = { emphasized: mix(brandSolid, 36, surface), focusRing: colorToken(accentSolid), border: colorToken(brandSolid), + hoverTint: colorToken(brandFg), }, /** Selection / focus palette (blue). Use via `accent.solid` or `colorPalette="accent"`. */ accent: { @@ -215,6 +235,7 @@ const semanticColors = { emphasized: mix(accentSolid, 36, surface), focusRing: colorToken(accentSolid), border: colorToken(accentSolid), + hoverTint: colorToken(accentSolid), }, }; @@ -240,6 +261,16 @@ const config = defineConfig({ margin: 0, overflow: 'hidden', }, + // Interactive elements keep the default arrow even over their text — + // without this, non-button rows (picker options, menu items) compute + // `auto` and show the I-beam. Recipes still override (e.g. not-allowed). + // Attribute values stay unquoted: serialized markup assertions (SamOptions) + // grep for the quoted forms. No `[role=combobox]` — zag puts that role on + // type-able inputs, which must keep the I-beam. + 'button, [role=button], [role=menuitem], [role=menuitemcheckbox], [role=menuitemradio], [role=option], [role=tab], [role=radio], [role=checkbox], [role=switch]': + { + cursor: 'default', + }, // While a gallery-item drag is in flight (body flag set by // GalleryDragCursor) the closed-hand cursor applies everywhere: without // the descendant rule, every element that sets its own cursor (buttons, @@ -262,8 +293,11 @@ const config = defineConfig({ '--wb-motion-animation-iteration-count': '1', scrollBehavior: 'auto !important', }, + // `backgroundImage` too: the shine gradient frozen mid-sweep reads as a + // smudge, so reduce-motion falls back to the flat fill. ':root[data-reduce-motion="true"] .chakra-skeleton': { animation: 'none !important', + backgroundImage: 'none !important', }, // A loading spinner is essential status, not decoration — frozen, its arc // reads as a broken icon. It slows to a crawl instead of stopping; WCAG @@ -276,6 +310,17 @@ const config = defineConfig({ }, theme: { tokens: { + // Pro-app convention: controls keep the default arrow cursor; pointer is + // reserved for links. Overrides Chakra's `button`/`switch` pointer tokens. + cursor: { + button: { value: 'default' }, + switch: { value: 'default' }, + }, + radii: { + // The shared corner for interactive controls — buttons and the + // segment-tab pills meet between Chakra's l2 (4px) and md (6px). + control: { value: '0.3125rem' }, + }, fonts: { body: { value: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", @@ -308,6 +353,7 @@ const config = defineConfig({ recipes: { button: buttonRecipe, input: inputRecipe, + skeleton: skeletonRecipe, textarea: textareaRecipe, }, slotRecipes: { @@ -318,7 +364,9 @@ const config = defineConfig({ hoverCard: hoverCardSlotRecipe, menu: menuSlotRecipe, numberInput: numberInputSlotRecipe, + popover: popoverSlotRecipe, progressCircle: progressCircleSlotRecipe, + scrollArea: scrollAreaSlotRecipe, segmentGroup: segmentGroupSlotRecipe, select: selectSlotRecipe, slider: sliderSlotRecipe, diff --git a/invokeai/frontend/webv2/src/platform/ui/theme/themes.ts b/invokeai/frontend/webv2/src/platform/ui/theme/themes.ts index 2defd0a9feb..e034c96f6fc 100644 --- a/invokeai/frontend/webv2/src/platform/ui/theme/themes.ts +++ b/invokeai/frontend/webv2/src/platform/ui/theme/themes.ts @@ -1,4 +1,4 @@ -export type WorkbenchThemeId = 'classic' | 'light' | 'forest' | 'mono' | 'ultradark'; +export type WorkbenchThemeId = 'classic' | 'light' | 'osakaJade' | 'mono' | 'ultradark'; /** * Steps of the neutral ramp. `50` is the lightest, `950` the darkest — the same @@ -57,32 +57,35 @@ export interface ThemeDefinition { colors: ThemeColors; } +// The slate tint is deliberately even: chroma tracks the ramp smoothly +// (0.014 → 0.026 → 0.014) instead of the old spike-at-400 curve, and the dark +// end sits ~2.5% L lower so the chrome reads as slate, not washed graphite. const classic: ThemeColors = { neutral: { - 50: 'oklch(96.017% 0.0029 264.54)', - 100: 'oklch(91.561% 0.0064 264.52)', - 200: 'oklch(82.648% 0.0133 264.48)', - 300: 'oklch(73.736% 0.0202 264.44)', - 400: 'oklch(57.965% 0.0337 264.27)', - 500: 'oklch(45.106% 0.0251 264.28)', - 600: 'oklch(40.619% 0.0221 264.29)', - 700: 'oklch(36.004% 0.019 264.3)', - 800: 'oklch(31.237% 0.0157 264.32)', - 900: 'oklch(26.279% 0.0123 264.34)', - 950: 'oklch(21.074% 0.0087 264.37)', + 50: 'oklch(96% 0.006 264.5)', + 100: 'oklch(91.5% 0.009 264.5)', + 200: 'oklch(82.5% 0.014 264.5)', + 300: 'oklch(73.5% 0.019 264.4)', + 400: 'oklch(57.5% 0.026 264.3)', + 500: 'oklch(44% 0.024 264.3)', + 600: 'oklch(39% 0.022 264.3)', + 700: 'oklch(34% 0.02 264.3)', + 800: 'oklch(28.8% 0.018 264.3)', + 900: 'oklch(23.8% 0.016 264.35)', + 950: 'oklch(18.8% 0.014 264.4)', }, - brand: { solid: 'oklch(92.041% 0.2103 116.59)', contrast: 'oklch(21.074% 0.0087 264.37)' }, - accent: { solid: 'oklch(77.738% 0.1 231.76)', contrast: 'oklch(21.074% 0.0087 264.37)' }, + brand: { solid: 'oklch(92.041% 0.2103 116.59)', contrast: 'oklch(18.8% 0.014 264.4)' }, + accent: { solid: 'oklch(77.738% 0.1 231.76)', contrast: 'oklch(18.8% 0.014 264.4)' }, danger: 'oklch(70.61% 0.0841 19.38)', success: 'oklch(79.8% 0.1132 141.63)', warning: 'oklch(76.62% 0.0612 62.9)', - inset: 'oklch(21.074% 0.0087 264.37)', + inset: 'oklch(18.8% 0.014 264.4)', // Sits ~7% L above the neutral.800 surface like every other dark theme's - // fill; it previously matched neutral.800 exactly, making ghost hovers - // invisible on muted surfaces. - fill: 'oklch(38.5% 0.0203 264.29)', - grid: 'oklch(40.619% 0.0221 264.29)', - control: 'oklch(36.004% 0.019 264.3)', + // fill; matching neutral.800 exactly made ghost hovers invisible on muted + // surfaces. + fill: 'oklch(36% 0.021 264.3)', + grid: 'oklch(38.5% 0.022 264.3)', + control: 'oklch(34% 0.02 264.3)', }; // Cool blue-gray neutrals (hue 264, harmonizing with the blue accent): near-white @@ -112,29 +115,31 @@ const light: ThemeColors = { control: 'oklch(93.5% 0.005 264)', }; -const forest: ThemeColors = { +// Jade rather than leaf: the ramp lives on the blue-green side (hue ~180) +// like stone, with the leafier greens reserved for brand and success. +const osakaJade: ThemeColors = { neutral: { - 50: 'oklch(90.57% 0.0511 134.45)', - 100: 'oklch(86.78% 0.0524 134.61)', - 200: 'oklch(79.199% 0.0549 134.93)', - 300: 'oklch(71.619% 0.0575 135.25)', - 400: 'oklch(53.577% 0.0538 136.73)', - 500: 'oklch(36.133% 0.0575 141.06)', - 600: 'oklch(27.425% 0.0354 143.04)', - 700: 'oklch(25.62% 0.03 144.64)', - 800: 'oklch(20.99% 0.0219 144.74)', - 900: 'oklch(19.03% 0.0175 144.85)', - 950: 'oklch(17.349% 0.0154 144.88)', + 50: 'oklch(92% 0.028 178)', + 100: 'oklch(87.5% 0.03 178)', + 200: 'oklch(79% 0.033 179)', + 300: 'oklch(70.5% 0.036 180)', + 400: 'oklch(53% 0.037 181)', + 500: 'oklch(36.5% 0.038 182)', + 600: 'oklch(28.5% 0.03 183)', + 700: 'oklch(25.5% 0.026 183)', + 800: 'oklch(21% 0.021 184)', + 900: 'oklch(18.5% 0.017 184)', + 950: 'oklch(16.5% 0.015 184)', }, - brand: { solid: 'oklch(79.714% 0.1784 136.37)', contrast: 'oklch(18.298% 0.0314 147.69)' }, - accent: { solid: 'oklch(70.437% 0.1101 178.23)', contrast: 'oklch(17.44% 0.0258 171.89)' }, - danger: 'oklch(69.305% 0.1467 35.44)', - success: 'oklch(78% 0.16 148)', - warning: 'oklch(80% 0.12 76)', - inset: 'oklch(17.349% 0.0154 144.88)', - fill: 'oklch(28.489% 0.0483 141.22)', - grid: 'oklch(34.825% 0.0526 141.63)', - control: 'oklch(25.62% 0.03 144.64)', + brand: { solid: 'oklch(81% 0.145 165)', contrast: 'oklch(17% 0.028 175)' }, + accent: { solid: 'oklch(72% 0.105 195)', contrast: 'oklch(16.5% 0.024 195)' }, + danger: 'oklch(69.5% 0.147 33)', + success: 'oklch(78% 0.15 155)', + warning: 'oklch(80% 0.12 78)', + inset: 'oklch(16.5% 0.015 184)', + fill: 'oklch(28.5% 0.032 182)', + grid: 'oklch(34.5% 0.035 182)', + control: 'oklch(25.5% 0.026 183)', }; const mono: ThemeColors = { @@ -162,29 +167,32 @@ const mono: ThemeColors = { control: 'oklch(27.274% 0 0)', }; +// True-black floor with faintly cool text: the old light steps carried a +// green-lime cast that clashed with everything but the brand mark; the ramp +// now cools toward slate and lets brand lime and sky accent be the color. const ultradark: ThemeColors = { neutral: { - 50: 'oklch(88.901% 0.0317 120.83)', - 100: 'oklch(84.397% 0.0292 123.06)', - 200: 'oklch(75.39% 0.0242 127.51)', - 300: 'oklch(66.382% 0.0192 131.96)', - 400: 'oklch(47.041% 0.0184 127.12)', - 500: 'oklch(26.862% 0 0)', - 600: 'oklch(20.904% 0 0)', - 700: 'oklch(19.125% 0 0)', - 800: 'oklch(14.479% 0 0)', - 900: 'oklch(11.492% 0 0)', + 50: 'oklch(89% 0.008 250)', + 100: 'oklch(84.5% 0.008 250)', + 200: 'oklch(75.5% 0.007 250)', + 300: 'oklch(66.5% 0.007 250)', + 400: 'oklch(47% 0.006 250)', + 500: 'oklch(27% 0.005 250)', + 600: 'oklch(21% 0.004 250)', + 700: 'oklch(19% 0.004 250)', + 800: 'oklch(14.5% 0.003 250)', + 900: 'oklch(11.5% 0.003 250)', 950: 'oklch(0% 0 0)', }, - brand: { solid: 'oklch(93.444% 0.19 125.56)', contrast: 'oklch(15.913% 0.0233 128.66)' }, + brand: { solid: 'oklch(93.444% 0.19 125.56)', contrast: 'oklch(15% 0.004 250)' }, accent: { solid: 'oklch(80.623% 0.1248 228.24)', contrast: 'oklch(17.416% 0.0256 235.84)' }, danger: 'oklch(71.161% 0.1812 22.84)', success: 'oklch(76.5% 0.16 150.5)', warning: 'oklch(79.5% 0.13 80)', inset: 'oklch(0% 0 0)', - fill: 'oklch(21.779% 0 0)', - grid: 'oklch(23.929% 0 0)', - control: 'oklch(19.125% 0 0)', + fill: 'oklch(21.8% 0.004 250)', + grid: 'oklch(24% 0.004 250)', + control: 'oklch(19% 0.004 250)', }; /** @@ -208,11 +216,11 @@ export const THEMES: ThemeDefinition[] = [ colors: light, }, { - id: 'forest', - label: 'Forest', - description: 'Deep greens with a leafy accent.', + id: 'osakaJade', + label: 'Osaka Jade', + description: 'Deep jade stone with a cool green accent.', colorScheme: 'dark', - colors: forest, + colors: osakaJade, }, { id: 'mono', @@ -252,6 +260,20 @@ export const THEMES_BY_ID: Record = THEMES.re export const isWorkbenchThemeId = (value: unknown): value is WorkbenchThemeId => typeof value === 'string' && Object.prototype.hasOwnProperty.call(THEMES_BY_ID, value); +/** Renamed themes keep working for preferences persisted under the old id. */ +const LEGACY_THEME_IDS: Record = { + forest: 'osakaJade', +}; + +/** A stored theme preference → a current theme id, or `null` for junk. */ +export const resolveWorkbenchThemeId = (value: unknown): WorkbenchThemeId | null => { + if (isWorkbenchThemeId(value)) { + return value; + } + + return typeof value === 'string' ? (LEGACY_THEME_IDS[value] ?? null) : null; +}; + /** The default panel surface of a theme (lightest end in light mode, near-darkest in dark). */ const surfaceOf = (theme: ThemeDefinition): string => theme.colorScheme === 'light' ? theme.colors.neutral[50] : theme.colors.neutral[900]; diff --git a/invokeai/frontend/webv2/src/platform/ui/useScrollAreaPhantomHeal.ts b/invokeai/frontend/webv2/src/platform/ui/useScrollAreaPhantomHeal.ts new file mode 100644 index 00000000000..e40ee45bb82 --- /dev/null +++ b/invokeai/frontend/webv2/src/platform/ui/useScrollAreaPhantomHeal.ts @@ -0,0 +1,78 @@ +import type { RefObject } from 'react'; + +import { useMountEffect } from '@platform/react/useMountEffect'; + +/** + * Zag's initial scroll-area measure can race a popover's or dialog's + * zero-size mount (its resize observers can even end up watching pre-remount + * nodes), and its "has overflow" default then strands a phantom thumb on + * content that never overflowed. Whenever a check finds that contradiction on + * the live nodes, one synthetic scroll routes a re-measure through zag's own + * event path — and clears the contradiction, so the checks cannot loop. + * Scroll listeners on these viewports must tolerate a no-op scroll event. + * + * Mount this next to any `ScrollArea.Viewport` (see `Scrollable`); the ref + * must point at the viewport element. + */ +export const useScrollAreaPhantomHeal = (viewportRef: RefObject): void => { + useMountEffect(() => { + // Always through the ref: a presence pass can remount the subtree, and a + // node captured at effect time goes dead (which is also how zag's own + // observers end up blind here). + const hasPhantomScrollbar = () => { + const viewport = viewportRef.current; + // `:scope >`: a nested scroll area's scrollbar must not answer for + // ours. Every scrollbar carries both overflow attributes, so the first + // sibling bar is authoritative. + const scrollbar = viewport?.parentElement?.querySelector(':scope > [data-part="scrollbar"]'); + + if (!viewport || !scrollbar) { + return false; + } + + const phantomY = scrollbar.hasAttribute('data-overflow-y') && viewport.scrollHeight <= viewport.clientHeight; + const phantomX = scrollbar.hasAttribute('data-overflow-x') && viewport.scrollWidth <= viewport.clientWidth; + + return Boolean(phantomY || phantomX); + }; + + let observed: HTMLElement | null = null; + const timers: ReturnType[] = []; + const observer = new ResizeObserver(() => check()); + + const check = () => { + const viewport = viewportRef.current; + + if (!viewport) { + return; + } + + if (viewport !== observed) { + if (observed) { + observer.unobserve(observed); + } + + observer.observe(viewport); + observed = viewport; + } + + if (hasPhantomScrollbar()) { + viewport.dispatchEvent(new Event('scroll')); + } + }; + + // The timed checks catch a presence remount the observer can no longer + // see; each re-syncs observation to the ref's current node, and the + // re-synced observer covers real resizes from then on. + for (const delay of [64, 250, 600]) { + timers.push(setTimeout(check, delay)); + } + + check(); + + return () => { + timers.forEach(clearTimeout); + observer.disconnect(); + }; + }); +}; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx index 111ae3f731a..8871aa17f4d 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx @@ -483,7 +483,7 @@ const SingleItemMenuItems = ({ - + ); }; @@ -944,7 +938,7 @@ const BulkMenuItems = ({ void; }) => ( - + - + {label} diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/HelpMenu.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/HelpMenu.tsx index 512d0001585..c7eb153639b 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/HelpMenu.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/HelpMenu.tsx @@ -2,10 +2,10 @@ import type { ElementType } from 'react'; import { chakra, HStack, Icon, Menu, Portal, Text } from '@chakra-ui/react'; import { APP_VERSION } from '@platform/runtime/appMetadata'; -import { DiscordIcon } from '@platform/ui/BrandIcon'; +import { DiscordIcon, GithubIcon } from '@platform/ui/BrandIcon'; import { Button } from '@platform/ui/Button'; import { MenuContent } from '@platform/ui/Menu'; -import { BookOpenTextIcon, ChevronRightIcon, ClapperboardIcon, CircleQuestionMarkIcon, CodeIcon } from 'lucide-react'; +import { BookOpenTextIcon, ChevronRightIcon, ClapperboardIcon, CircleQuestionMarkIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; /** @@ -51,7 +51,7 @@ const COMMUNITY: HelpLink[] = [ }, { href: 'https://github.com/invoke-ai/InvokeAI', - icon: CodeIcon, + icon: GithubIcon, labelKey: 'launchpad.help.github', value: 'github', }, diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/Launchpad.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/Launchpad.tsx index ef9a10afc26..2cbc9be42d4 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/Launchpad.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/Launchpad.tsx @@ -13,6 +13,7 @@ import { LaunchpadNav, type LaunchpadNavGroupId } from './LaunchpadNav'; import { LaunchpadTopBar } from './LaunchpadTopBar'; import { HomePage } from './pages/HomePage'; import { ProjectsPage } from './pages/ProjectsPage'; +import { ProjectActionsMenuProvider } from './projects/ProjectActionsMenuHost'; /** * The landing surface at `/`: a full-height shell with a slim section rail and @@ -131,11 +132,13 @@ export const Launchpad = () => { ); return ( - - - - - + + + + + + + ); }; diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/home/IntentTiles.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/home/IntentTiles.tsx index c95c2623e27..1afde3f4055 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/home/IntentTiles.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/home/IntentTiles.tsx @@ -17,6 +17,9 @@ import { useTranslation } from 'react-i18next'; */ const TILE_COLUMNS = { base: 1, lg: 5, sm: 2 } as const; +// Pro-app convention (see the theme's cursor tokens): tiles act like +// controls, so they keep the arrow instead of the anchor's pointer. +const TILE_LINK_STYLE = { cursor: 'default' } as const; const TILE_HOVER = { bg: 'bg.muted', borderColor: 'border.emphasized' } as const; const TILE_TRANSITION = 'border-color var(--wb-motion-duration-medium) ease, background var(--wb-motion-duration-medium) ease'; @@ -52,7 +55,7 @@ const IntentTile = ({ id }: { id: LaunchpadIntentId }) => { const search = useMemo(() => ({ intent: id, new: true }) as const, [id]); return ( - + {t('launchpad.home.recentProjects')} - )} - + + + + ); diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/pages/HomePage.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/pages/HomePage.tsx index 9d6998e36cc..737962ab25b 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/pages/HomePage.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/pages/HomePage.tsx @@ -102,7 +102,15 @@ export const HomePage = () => { {canManageModels ? : null} - {isFirstLoad ? : mostRecent ? : null} + {isFirstLoad ? ( + + ) : mostRecent ? ( + + ) : null} diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectActionsMenu.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectActionsMenu.tsx index 808c92ca070..1c16619ecc6 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectActionsMenu.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectActionsMenu.tsx @@ -1,120 +1,15 @@ -import type { ReactNode } from 'react'; - -import { Icon, Menu, Portal } from '@chakra-ui/react'; -import { ConfirmDialog } from '@platform/ui/ConfirmDialog'; +import { Icon, Menu } from '@chakra-ui/react'; import { MenuContent } from '@platform/ui/Menu'; -import { RenameDialog } from '@platform/ui/RenameDialog'; import { Link } from '@tanstack/react-router'; import { ArrowRightIcon, CopyIcon, FileDownIcon, PencilIcon, PinIcon, PinOffIcon, Trash2Icon } from 'lucide-react'; -import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import type { ProjectCardActions } from './useProjectCardActions'; - /** - * The per-project action menu, plus the rename and delete dialogs it opens. - * The grid card and the list row both mount this, so right-click and the - * overflow button offer the same things in the same order everywhere. + * The per-project action menu's content. Right-click and the overflow button + * offer the same things in the same order everywhere; the single mounted + * instance and its dialogs live in `ProjectActionsMenuHost`. */ - -const MENU_ITEM_DELETE_HOVER = { bg: 'bg.error', color: 'fg.error' } as const; - -export interface ProjectActionsMenuProps { - actions: ProjectCardActions; - isCompatible: boolean; - isPinned: boolean; - /** Anchor point for a right-click; `null` anchors to the trigger instead. */ - contextMenuTarget: { x: number; y: number } | null; - isOpen: boolean; - projectId: string; - projectName: string; - children: ReactNode; - onOpenChange: (details: { open: boolean }) => void; - onTogglePin: () => void; -} - -const MENU_POSITION_BOTTOM_END = { placement: 'bottom-end' } as const; - -export const ProjectActionsMenu = ({ - actions, - children, - contextMenuTarget, - isOpen, - isCompatible, - isPinned, - onOpenChange, - onTogglePin, - projectId, - projectName, -}: ProjectActionsMenuProps) => { - const { t } = useTranslation(); - const [isRenameOpen, setIsRenameOpen] = useState(false); - const [isDeleteOpen, setIsDeleteOpen] = useState(false); - const projectSearch = useMemo(() => ({ project: projectId }), [projectId]); - const positioning = useMemo( - () => - contextMenuTarget - ? { - getAnchorRect: () => ({ height: 1, width: 1, x: contextMenuTarget.x, y: contextMenuTarget.y }), - placement: 'bottom-start' as const, - } - : MENU_POSITION_BOTTOM_END, - [contextMenuTarget] - ); - - const openRenameDialog = useCallback(() => setIsRenameOpen(true), []); - const closeRenameDialog = useCallback(() => setIsRenameOpen(false), []); - const openDeleteDialog = useCallback(() => setIsDeleteOpen(true), []); - const closeDeleteDialog = useCallback(() => setIsDeleteOpen(false), []); - const handleDuplicate = useCallback(() => void actions.duplicate(), [actions]); - const handleExport = useCallback(() => void actions.export(), [actions]); - - return ( - <> - - {children} - - - - - - - - - - - - ); -}; - -const MenuBody = ({ +export const ProjectActionsMenuBody = ({ isCompatible, isPinned, onDelete, @@ -168,7 +63,7 @@ const MenuBody = ({ {t('common.export')} - + {t('common.delete')}… diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectActionsMenuHost.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectActionsMenuHost.browser.test.tsx new file mode 100644 index 00000000000..0bc86716ba1 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectActionsMenuHost.browser.test.tsx @@ -0,0 +1,164 @@ +import type { ProjectSummary } from '@workbench/projects/library'; +import type { MouseEvent } from 'react'; + +import { ChakraProvider } from '@chakra-ui/react'; +import { IconButton } from '@platform/ui/Button'; +import { system } from '@theme/system'; +import { act, useCallback, useMemo } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./useProjectCardActions', () => ({ + useProjectCardActions: () => ({ + delete: () => Promise.resolve(), + duplicate: () => {}, + export: () => {}, + rename: () => Promise.resolve(), + }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('@tanstack/react-router', () => ({ + Link: ({ children, ...props }: { children?: unknown } & Record) => ( + + {children as never} + + ), +})); + +const { ProjectActionsMenuProvider, useProjectActionsMenu, useProjectActionsMenuTrigger } = + await import('./ProjectActionsMenuHost'); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const makeSummary = (id: string): ProjectSummary => + ({ coverUrl: undefined, id, name: `Project ${id}`, schemaVersion: 1, updatedAt: 0 }) as unknown as ProjectSummary; + +const NOOP_TOGGLE_PIN = () => {}; + +/** The card shape the launchpad project cards share: right-click anchor plus a dots trigger. */ +const Card = ({ id }: { id: string }) => { + const menu = useProjectActionsMenu(); + const summary = useMemo(() => makeSummary(id), [id]); + const menuTarget = useMemo(() => ({ isPinned: false, onTogglePin: NOOP_TOGGLE_PIN, summary }), [summary]); + const handleContextMenu = useCallback( + (event: MouseEvent) => menu.openAtPointer(event, menuTarget), + [menu, menuTarget] + ); + const menuTrigger = useProjectActionsMenuTrigger(menuTarget); + + return ( +
+ + … + +
+ ); +}; + +let host: HTMLDivElement | null = null; +let root: Root | null = null; + +afterEach(async () => { + await act(() => root?.unmount()); + host?.remove(); + host = null; + root = null; +}); + +const rightClick = async (element: Element) => { + await act(async () => { + const rect = element.getBoundingClientRect(); + const init = { + bubbles: true, + button: 2, + cancelable: true, + clientX: rect.left + 20, + clientY: rect.top + 20, + }; + element.dispatchEvent(new PointerEvent('pointerdown', init)); + element.dispatchEvent(new MouseEvent('contextmenu', init)); + element.dispatchEvent(new PointerEvent('pointerup', init)); + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 250); + }); + }); +}; + +const leftClick = async (element: Element) => { + await act(async () => { + const init = { bubbles: true, button: 0, cancelable: true }; + element.dispatchEvent(new PointerEvent('pointerdown', init)); + element.dispatchEvent(new PointerEvent('pointerup', init)); + element.dispatchEvent(new MouseEvent('click', init)); + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 250); + }); + }); +}; + +const openMenus = () => document.querySelectorAll('[role="menu"][data-state="open"]'); +const dotsButton = (id: string) => document.querySelector(`button[aria-label="actions ${id}"]`)!; + +describe('ProjectActionsMenuHost', () => { + it('moves the one menu across cards instead of racing sibling layers', async () => { + // Per-card menus died here: zag's dismissable stack treats a layer mounted + // above another as nested, so the second card's menu was dismissed along + // with the first card's still-closing one. + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + + await act(async () => { + root?.render( + + + + + + + ); + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 50); + }); + }); + + const cardOne = host.querySelector('[data-card="one"]')!; + const cardTwo = host.querySelector('[data-card="two"]')!; + + await rightClick(cardOne); + expect(openMenus()).toHaveLength(1); + expect(dotsButton('one').getAttribute('aria-expanded')).toBe('true'); + + await rightClick(cardTwo); + expect(openMenus()).toHaveLength(1); + expect(dotsButton('one').getAttribute('aria-expanded')).toBe('false'); + expect(dotsButton('two').getAttribute('aria-expanded')).toBe('true'); + + // Re-right-clicking the open card's own body keeps its menu open too. + await rightClick(cardTwo); + expect(openMenus()).toHaveLength(1); + expect(dotsButton('two').getAttribute('aria-expanded')).toBe('true'); + + // A dots click opens; a second dots click toggles closed (the pointerdown + // already dismissed the menu — the click must not reopen it). + await leftClick(dotsButton('one')); + expect(openMenus()).toHaveLength(1); + expect(dotsButton('one').getAttribute('aria-expanded')).toBe('true'); + + await leftClick(dotsButton('one')); + expect(openMenus()).toHaveLength(0); + expect(dotsButton('one').getAttribute('aria-expanded')).toBe('false'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectActionsMenuHost.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectActionsMenuHost.tsx new file mode 100644 index 00000000000..96befdeeb82 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectActionsMenuHost.tsx @@ -0,0 +1,253 @@ +import type { ProjectSummary } from '@workbench/projects/library'; +import type { MouseEvent, ReactNode } from 'react'; + +import { Menu, Portal } from '@chakra-ui/react'; +import { ConfirmDialog } from '@platform/ui/ConfirmDialog'; +import { RenameDialog } from '@platform/ui/RenameDialog'; +import { isProjectSummaryCompatible } from '@workbench/projects/library'; +import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { ProjectCardActions } from './useProjectCardActions'; + +import { ProjectActionsMenuBody } from './ProjectActionsMenu'; +import { useProjectCardActions } from './useProjectCardActions'; + +/** + * One menu instance for every project card on the page. + * + * Per-card menus raced: zag's dismissable stack treats any layer mounted + * above another as nested, so right-clicking card B while card A's menu was + * still tearing down dismissed B's menu along with A's. A single host never + * has two layers — switching targets remounts the one menu, and React runs + * the old instance's cleanup before the new one's effects in the same commit. + * + * The rename/delete dialogs live beside the menu, not inside it, because + * choosing a menu item closes the menu (and with it anything it rendered). + */ + +interface ProjectMenuTarget { + isPinned: boolean; + summary: ProjectSummary; + onTogglePin: (projectId: string) => void; +} + +type MenuAnchor = + | { kind: 'pointer'; x: number; y: number } + | { kind: 'trigger'; rect: { height: number; width: number; x: number; y: number } }; + +interface MenuRequest extends ProjectMenuTarget { + anchor: MenuAnchor; + /** Where focus goes when the menu closes: without a registered zag trigger, + * the machine's own focus restore has nothing to return to. */ + returnFocus: HTMLElement | null; + /** Distinguishes successive opens so the hosted menu remounts even when the + * anchor repeats — zag may have internally closed the previous machine. */ + ticket: number; +} + +interface DialogRequest { + actions: ProjectCardActions; + kind: 'delete' | 'rename'; + name: string; +} + +interface ProjectActionsMenuControl { + /** The project whose menu is showing; drives the dots triggers' `aria-expanded`. */ + activeProjectId: string | null; + openAtPointer: (event: MouseEvent, target: ProjectMenuTarget) => void; + openFromTrigger: (element: HTMLElement, target: ProjectMenuTarget) => void; +} + +const ProjectActionsMenuContext = createContext(null); + +export const useProjectActionsMenu = (): ProjectActionsMenuControl => { + const control = useContext(ProjectActionsMenuContext); + + if (!control) { + throw new Error('useProjectActionsMenu must be used within a ProjectActionsMenuProvider'); + } + + return control; +}; + +export const ProjectActionsMenuProvider = ({ children }: { children: ReactNode }) => { + const { t } = useTranslation(); + const [menuRequest, setMenuRequest] = useState(null); + const [dialogRequest, setDialogRequest] = useState(null); + const ticketRef = useRef(0); + + const closeMenu = useCallback(() => setMenuRequest(null), []); + const closeDialog = useCallback(() => setDialogRequest(null), []); + const openAtPointer = useCallback((event: MouseEvent, target: ProjectMenuTarget) => { + event.preventDefault(); + ticketRef.current += 1; + setMenuRequest({ + ...target, + anchor: { kind: 'pointer', x: event.clientX, y: event.clientY }, + returnFocus: (event.currentTarget as HTMLElement).querySelector('a, button, [tabindex]'), + ticket: ticketRef.current, + }); + }, []); + const openFromTrigger = useCallback((element: HTMLElement, target: ProjectMenuTarget) => { + const { height, width, x, y } = element.getBoundingClientRect(); + + ticketRef.current += 1; + setMenuRequest({ + ...target, + anchor: { kind: 'trigger', rect: { height, width, x, y } }, + returnFocus: element, + ticket: ticketRef.current, + }); + }, []); + + const control = useMemo( + () => ({ activeProjectId: menuRequest?.summary.id ?? null, openAtPointer, openFromTrigger }), + [menuRequest?.summary.id, openAtPointer, openFromTrigger] + ); + + const menuKey = menuRequest ? `${menuRequest.summary.id}:${menuRequest.ticket}` : ''; + + return ( + + {children} + {menuRequest ? ( + + ) : null} + + + + + + ); +}; + +const NOOP_SUBMIT = () => Promise.resolve(); + +/** + * Handlers for a card's dots button. The button is not a registered zag + * trigger, so a pointerdown on it while its own menu is open dismisses the + * menu as an outside interaction — and the click that follows would reopen + * it. The pointerdown handler runs while the pre-dismiss state is still + * rendered, so it can mark the click as a toggle-close instead. + */ +export const useProjectActionsMenuTrigger = (target: ProjectMenuTarget) => { + const menu = useProjectActionsMenu(); + const suppressNextOpenRef = useRef(false); + const isExpanded = menu.activeProjectId === target.summary.id; + + const onPointerDown = useCallback(() => { + if (menu.activeProjectId === target.summary.id) { + suppressNextOpenRef.current = true; + } + }, [menu.activeProjectId, target.summary.id]); + const onClick = useCallback( + (event: MouseEvent) => { + if (suppressNextOpenRef.current) { + suppressNextOpenRef.current = false; + + return; + } + + menu.openFromTrigger(event.currentTarget, target); + }, + [menu, target] + ); + + return { isExpanded, onClick, onPointerDown }; +}; + +const HostedProjectActionsMenu = ({ + request, + onClose, + onRequestDialog, +}: { + request: MenuRequest; + onClose: () => void; + onRequestDialog: (dialog: DialogRequest) => void; +}) => { + const actions = useProjectCardActions(request.summary); + const isCompatible = isProjectSummaryCompatible(request.summary); + const projectSearch = useMemo(() => ({ project: request.summary.id }), [request.summary.id]); + const positioning = useMemo(() => { + const anchor = request.anchor; + + return anchor.kind === 'pointer' + ? { + getAnchorRect: () => ({ height: 1, width: 1, x: anchor.x, y: anchor.y }), + placement: 'bottom-start' as const, + } + : { + getAnchorRect: () => anchor.rect, + placement: 'bottom-end' as const, + }; + }, [request.anchor]); + + const handleOpenChange = useCallback( + (event: { open: boolean }) => { + if (!event.open) { + // Focus would otherwise drop to the body (Escape, item select). When + // an outside click moved it already, leave the browser's target alone. + if (document.activeElement?.closest('[data-scope="menu"]')) { + request.returnFocus?.focus(); + } + + onClose(); + } + }, + [onClose, request.returnFocus] + ); + const handleRename = useCallback( + () => onRequestDialog({ actions, kind: 'rename', name: request.summary.name }), + [actions, onRequestDialog, request.summary.name] + ); + const handleDelete = useCallback( + () => onRequestDialog({ actions, kind: 'delete', name: request.summary.name }), + [actions, onRequestDialog, request.summary.name] + ); + const handleDuplicate = useCallback(() => void actions.duplicate(), [actions]); + const handleExport = useCallback(() => void actions.export(), [actions]); + const handleTogglePin = useCallback(() => request.onTogglePin(request.summary.id), [request]); + + return ( + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectCard.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectCard.tsx index 6c0c40753e8..a74e464604c 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectCard.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectCard.tsx @@ -1,30 +1,30 @@ import type { ProjectSummary } from '@workbench/projects/library'; import type { MouseEvent } from 'react'; -import { Box, Flex, Icon, Menu, Stack, Text } from '@chakra-ui/react'; +import { Box, Flex, Icon, Stack, Text } from '@chakra-ui/react'; import { IconButton } from '@platform/ui/Button'; import { MiddleTruncate } from '@platform/ui/MiddleTruncate'; import { Link } from '@tanstack/react-router'; import { formatRelativeTime } from '@workbench/launchpad/formatRelativeTime'; import { isProjectSummaryCompatible } from '@workbench/projects/library'; import { EllipsisVerticalIcon, PinIcon } from 'lucide-react'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { ProjectActionsMenu } from './ProjectActionsMenu'; +import { useProjectActionsMenu, useProjectActionsMenuTrigger } from './ProjectActionsMenuHost'; import { ProjectCompatibilityBadge } from './ProjectCompatibilityBadge'; import { ProjectCover } from './ProjectCover'; -import { useProjectCardActions } from './useProjectCardActions'; /** * One saved project in the library grid. The whole card is a deep link into * the editor (`/app?project=…` — hovering preloads the editor chunk); the - * corner menu and right-click carry the library actions. + * corner menu and right-click carry the library actions, served by the page's + * shared `ProjectActionsMenuHost`. */ const CARD_HOVER = { bg: 'bg.muted', borderColor: 'border.emphasized' } as const; const REVEAL_ON_HOVER = { opacity: 1 } as const; -const LINK_STYLE = { inset: 0, position: 'absolute' } as const; +const LINK_STYLE = { cursor: 'default', inset: 0, position: 'absolute' } as const; const CARD_TRANSITION = 'border-color var(--wb-motion-duration-medium) ease, background var(--wb-motion-duration-medium) ease'; @@ -38,25 +38,16 @@ export const ProjectCard = ({ onTogglePin: (projectId: string) => void; }) => { const { t } = useTranslation(); - const actions = useProjectCardActions(summary); - const [isActionsOpen, setIsActionsOpen] = useState(false); - const [contextMenuTarget, setContextMenuTarget] = useState<{ x: number; y: number } | null>(null); + const menu = useProjectActionsMenu(); const isCompatible = isProjectSummaryCompatible(summary); const projectSearch = useMemo(() => ({ project: summary.id }), [summary.id]); - const handleContextMenu = useCallback((event: MouseEvent) => { - event.preventDefault(); - setContextMenuTarget({ x: event.clientX, y: event.clientY }); - setIsActionsOpen(true); - }, []); - const handleOpenChange = useCallback((event: { open: boolean }) => { - setIsActionsOpen(event.open); - - if (!event.open) { - setContextMenuTarget(null); - } - }, []); - const clearContextMenuTarget = useCallback(() => setContextMenuTarget(null), []); + const menuTarget = useMemo(() => ({ isPinned, onTogglePin, summary }), [isPinned, onTogglePin, summary]); + const handleContextMenu = useCallback( + (event: MouseEvent) => menu.openAtPointer(event, menuTarget), + [menu, menuTarget] + ); + const menuTrigger = useProjectActionsMenuTrigger(menuTarget); const handleTogglePin = useCallback(() => onTogglePin(summary.id), [onTogglePin, summary.id]); return ( @@ -120,29 +111,18 @@ export const ProjectCard = ({ - - - - - - - + + ); diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectRow.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectRow.tsx index 5361b066e16..8d69ccccb05 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectRow.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/projects/ProjectRow.tsx @@ -1,7 +1,7 @@ import type { ProjectSummary } from '@workbench/projects/library'; import type { MouseEvent } from 'react'; -import { Box, Flex, Icon, Menu, Text } from '@chakra-ui/react'; +import { Box, Flex, Icon, Text } from '@chakra-ui/react'; import { IconButton } from '@platform/ui/Button'; import { MiddleTruncate } from '@platform/ui/MiddleTruncate'; import { Row } from '@platform/ui/Row'; @@ -9,21 +9,21 @@ import { Link } from '@tanstack/react-router'; import { formatRelativeTime } from '@workbench/launchpad/formatRelativeTime'; import { isProjectSummaryCompatible } from '@workbench/projects/library'; import { EllipsisVerticalIcon, PinIcon } from 'lucide-react'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { ProjectActionsMenu } from './ProjectActionsMenu'; +import { useProjectActionsMenu, useProjectActionsMenuTrigger } from './ProjectActionsMenuHost'; import { ProjectCompatibilityBadge } from './ProjectCompatibilityBadge'; import { ProjectCover } from './ProjectCover'; -import { useProjectCardActions } from './useProjectCardActions'; /** * One saved project in the list view — the density a large library wants. - * Carries the same link target and the same action menu as the grid card; - * only the shape differs. + * Carries the same link target and the same action menu as the grid card + * (served by the page's shared `ProjectActionsMenuHost`); only the shape + * differs. */ -const LINK_STYLE = { inset: 0, position: 'absolute' } as const; +const LINK_STYLE = { cursor: 'default', inset: 0, position: 'absolute' } as const; const REVEAL_ON_HOVER = { opacity: 1 } as const; const THUMBNAIL_WIDTH = '14'; @@ -37,25 +37,16 @@ export const ProjectRow = ({ onTogglePin: (projectId: string) => void; }) => { const { t } = useTranslation(); - const actions = useProjectCardActions(summary); - const [isActionsOpen, setIsActionsOpen] = useState(false); - const [contextMenuTarget, setContextMenuTarget] = useState<{ x: number; y: number } | null>(null); + const menu = useProjectActionsMenu(); const isCompatible = isProjectSummaryCompatible(summary); const projectSearch = useMemo(() => ({ project: summary.id }), [summary.id]); - const handleContextMenu = useCallback((event: MouseEvent) => { - event.preventDefault(); - setContextMenuTarget({ x: event.clientX, y: event.clientY }); - setIsActionsOpen(true); - }, []); - const handleOpenChange = useCallback((event: { open: boolean }) => { - setIsActionsOpen(event.open); - - if (!event.open) { - setContextMenuTarget(null); - } - }, []); - const clearContextMenuTarget = useCallback(() => setContextMenuTarget(null), []); + const menuTarget = useMemo(() => ({ isPinned, onTogglePin, summary }), [isPinned, onTogglePin, summary]); + const handleContextMenu = useCallback( + (event: MouseEvent) => menu.openAtPointer(event, menuTarget), + [menu, menuTarget] + ); + const menuTrigger = useProjectActionsMenuTrigger(menuTarget); const handleTogglePin = useCallback(() => onTogglePin(summary.id), [onTogglePin, summary.id]); return ( @@ -102,29 +93,18 @@ export const ProjectRow = ({ > - - - - - - - + + ); diff --git a/invokeai/frontend/webv2/src/workbench/layoutContracts.ts b/invokeai/frontend/webv2/src/workbench/layoutContracts.ts index c3f06a411d1..b8d237ae5c2 100644 --- a/invokeai/frontend/webv2/src/workbench/layoutContracts.ts +++ b/invokeai/frontend/webv2/src/workbench/layoutContracts.ts @@ -50,6 +50,9 @@ export interface FloatingWidgetState { export interface WidgetRegionState { activeInstanceId: WidgetInstanceId; instanceIds: WidgetInstanceId[]; + /** Instances rendered in the strip's trailing cluster (bottom region only). + * Ids not currently placed are inert — a widget re-enabled later keeps its side. */ + alignEndInstanceIds?: WidgetInstanceId[]; isCollapsed: boolean; sizePx: number; } diff --git a/invokeai/frontend/webv2/src/workbench/layoutPresetSnapshots.ts b/invokeai/frontend/webv2/src/workbench/layoutPresetSnapshots.ts index 763ed24f118..0f8f4bdd1e1 100644 --- a/invokeai/frontend/webv2/src/workbench/layoutPresetSnapshots.ts +++ b/invokeai/frontend/webv2/src/workbench/layoutPresetSnapshots.ts @@ -64,13 +64,19 @@ export const cloneFloatingWidgets = ( ): Record => Object.fromEntries(Object.entries(floatingWidgets).map(([instanceId, state]) => [instanceId, { ...state }])); +const cloneWidgetRegionState = (region: WidgetRegionState): WidgetRegionState => ({ + ...region, + instanceIds: [...region.instanceIds], + ...(region.alignEndInstanceIds ? { alignEndInstanceIds: [...region.alignEndInstanceIds] } : {}), +}); + export const cloneLayoutPresetWidgetRegions = ( widgetRegionState: Record ): Record => ({ - bottom: { ...widgetRegionState.bottom, instanceIds: [...widgetRegionState.bottom.instanceIds] }, - center: { ...widgetRegionState.center, instanceIds: [...widgetRegionState.center.instanceIds] }, - left: { ...widgetRegionState.left, instanceIds: [...widgetRegionState.left.instanceIds] }, - right: { ...widgetRegionState.right, instanceIds: [...widgetRegionState.right.instanceIds] }, + bottom: cloneWidgetRegionState(widgetRegionState.bottom), + center: cloneWidgetRegionState(widgetRegionState.center), + left: cloneWidgetRegionState(widgetRegionState.left), + right: cloneWidgetRegionState(widgetRegionState.right), }); export const createLayoutPresetSnapshot = (project: Project): LayoutPresetSnapshot => { @@ -115,7 +121,8 @@ const areWidgetRegionsEqual = (left: WidgetRegionState, right: WidgetRegionState left.activeInstanceId === right.activeInstanceId && left.isCollapsed === right.isCollapsed && left.sizePx === right.sizePx && - areArraysEqual(left.instanceIds, right.instanceIds); + areArraysEqual(left.instanceIds, right.instanceIds) && + areArraysEqual(left.alignEndInstanceIds ?? [], right.alignEndInstanceIds ?? []); const areWidgetInstanceSnapshotsEqual = ( left: LayoutPresetSnapshot['widgetInstances'], diff --git a/invokeai/frontend/webv2/src/workbench/layoutPresets.test.ts b/invokeai/frontend/webv2/src/workbench/layoutPresets.test.ts index 80fd7352d4c..d0af3f0ca89 100644 --- a/invokeai/frontend/webv2/src/workbench/layoutPresets.test.ts +++ b/invokeai/frontend/webv2/src/workbench/layoutPresets.test.ts @@ -107,7 +107,7 @@ describe('built-in layout preset descriptors', () => { // "Compose with the center view swapped", so the swap has to remain // available. center: ['preview', 'gallery:center'], - left: ['generate', 'upscale', 'video'], + left: ['generate', 'upscale'], panels: { isBottomOpen: false, isLeftOpen: true, isRightOpen: true }, right: ['gallery', 'image-map', 'queue'], }, @@ -120,7 +120,7 @@ describe('built-in layout preset descriptors', () => { }, bottom: ['server-status', 'queue-status', 'gallery:bottom', 'notifications', 'autosave-status'], center: ['canvas', 'preview'], - left: ['generate', 'upscale', 'video'], + left: ['generate', 'upscale'], panels: { isBottomOpen: false, isLeftOpen: true, isRightOpen: true }, // The Edit rail is the Layers panel alone; its editors are panes inside it. right: ['layers'], diff --git a/invokeai/frontend/webv2/src/workbench/layoutPresets.ts b/invokeai/frontend/webv2/src/workbench/layoutPresets.ts index 5765c41fd62..b5826cb7d71 100644 --- a/invokeai/frontend/webv2/src/workbench/layoutPresets.ts +++ b/invokeai/frontend/webv2/src/workbench/layoutPresets.ts @@ -13,6 +13,8 @@ import type { WidgetInstanceId, WidgetTypeId } from '@workbench/widgetContracts' import { BUILT_IN_LAYOUT_PRESET_LABELS } from '@workbench/launchpad/intents'; +// Notifications read as ambient status, not a tool: they live on the right. +const defaultBottomAlignEndInstanceIds: WidgetInstanceId[] = ['notifications']; const defaultBottomInstanceIds: WidgetInstanceId[] = [ 'server-status', 'queue-status', @@ -40,7 +42,6 @@ const defaultInstanceTypes: Record = { queue: 'queue', 'queue-status': 'queue-status', 'server-status': 'server-status', - 'version-status': 'version-status', workflow: 'workflow', 'workflow:bottom': 'workflow', 'workflow:center': 'workflow', @@ -48,15 +49,23 @@ const defaultInstanceTypes: Record = { const createRegion = ({ activeInstanceId, + alignEndInstanceIds, instanceIds, isCollapsed = false, sizePx, }: { activeInstanceId: WidgetInstanceId; + alignEndInstanceIds?: WidgetInstanceId[]; instanceIds: WidgetInstanceId[]; isCollapsed?: boolean; sizePx: number; -}): WidgetRegionState => ({ activeInstanceId, instanceIds, isCollapsed, sizePx }); +}): WidgetRegionState => ({ + activeInstanceId, + ...(alignEndInstanceIds ? { alignEndInstanceIds } : {}), + instanceIds, + isCollapsed, + sizePx, +}); const createWidgetInstances = ( widgetRegions: Record @@ -157,6 +166,7 @@ export const builtInLayoutPresetDescriptors: BuiltInLayoutPresetDescriptor[] = [ widgetRegions: { bottom: createRegion({ activeInstanceId: 'gallery:bottom', + alignEndInstanceIds: defaultBottomAlignEndInstanceIds, instanceIds: defaultBottomInstanceIds, isCollapsed: true, sizePx: 180, @@ -174,7 +184,7 @@ export const builtInLayoutPresetDescriptors: BuiltInLayoutPresetDescriptor[] = [ }), left: createRegion({ activeInstanceId: 'generate', - instanceIds: ['generate', 'upscale', 'video'], + instanceIds: ['generate', 'upscale'], sizePx: 450, }), right: createRegion({ @@ -196,6 +206,7 @@ export const builtInLayoutPresetDescriptors: BuiltInLayoutPresetDescriptor[] = [ widgetRegions: { bottom: createRegion({ activeInstanceId: 'gallery:bottom', + alignEndInstanceIds: defaultBottomAlignEndInstanceIds, instanceIds: defaultBottomInstanceIds, isCollapsed: true, sizePx: 180, @@ -207,7 +218,7 @@ export const builtInLayoutPresetDescriptors: BuiltInLayoutPresetDescriptor[] = [ }), left: createRegion({ activeInstanceId: 'generate', - instanceIds: ['generate', 'upscale', 'video'], + instanceIds: ['generate', 'upscale'], sizePx: 450, }), right: createRegion({ @@ -229,6 +240,7 @@ export const builtInLayoutPresetDescriptors: BuiltInLayoutPresetDescriptor[] = [ widgetRegions: { bottom: createRegion({ activeInstanceId: 'gallery:bottom', + alignEndInstanceIds: defaultBottomAlignEndInstanceIds, instanceIds: defaultBottomInstanceIds, isCollapsed: true, sizePx: 180, @@ -264,6 +276,7 @@ export const builtInLayoutPresetDescriptors: BuiltInLayoutPresetDescriptor[] = [ widgetRegions: { bottom: createRegion({ activeInstanceId: 'gallery:bottom', + alignEndInstanceIds: defaultBottomAlignEndInstanceIds, instanceIds: defaultBottomInstanceIds, isCollapsed: true, sizePx: 180, diff --git a/invokeai/frontend/webv2/src/workbench/palette/CommandPaletteDialog.tsx b/invokeai/frontend/webv2/src/workbench/palette/CommandPaletteDialog.tsx index f4c081997c5..ba6912c9b98 100644 --- a/invokeai/frontend/webv2/src/workbench/palette/CommandPaletteDialog.tsx +++ b/invokeai/frontend/webv2/src/workbench/palette/CommandPaletteDialog.tsx @@ -69,6 +69,9 @@ export const CommandPaletteDialog = ({ finalFocusEl={getCommandPaletteReturnFocusElement} lazyMount open={isOpen} + // Not the recipe's centered tool-window placement: a palette hangs from + // the top band (its positioner pins the exact offset). + placement="top" restoreFocus scrollBehavior="inside" unmountOnExit @@ -167,7 +170,6 @@ const CommandPaletteContent = ({ bg="bg.emphasized" borderRadius="sm" color="fg" - cursor="pointer" display="inline-flex" flexShrink={0} fontSize="xs" diff --git a/invokeai/frontend/webv2/src/workbench/palette/CommandPaletteRows.tsx b/invokeai/frontend/webv2/src/workbench/palette/CommandPaletteRows.tsx index 031baad6e66..c45141f25a3 100644 --- a/invokeai/frontend/webv2/src/workbench/palette/CommandPaletteRows.tsx +++ b/invokeai/frontend/webv2/src/workbench/palette/CommandPaletteRows.tsx @@ -50,7 +50,6 @@ const renderTitle = (title: string, matchIndexes?: readonly number[]): ReactNode // Rows stay out of the tab order for the combobox aria-activedescendant pattern. const rowButtonProps = { - cursor: 'pointer', gap: '2.5', h: `${ENTRY_ROW_HEIGHT_PX}px`, px: '3', diff --git a/invokeai/frontend/webv2/src/workbench/palette/entries.ts b/invokeai/frontend/webv2/src/workbench/palette/entries.ts index d9dfa296fdd..c85ae39c9a4 100644 --- a/invokeai/frontend/webv2/src/workbench/palette/entries.ts +++ b/invokeai/frontend/webv2/src/workbench/palette/entries.ts @@ -305,8 +305,10 @@ const SETTINGS_SECTIONS: ReadonlyArray<{ id: SettingsSectionId; title: string }> { id: 'project', title: 'Project' }, { id: 'queue', title: 'Queue' }, { id: 'workflow', title: 'Workflow' }, + { id: 'imageMap', title: 'Image Map' }, { id: 'developer', title: 'Developer' }, { id: 'workspace', title: 'Workspace' }, + { id: 'about', title: 'About' }, ]; export interface SettingsEntryDeps { diff --git a/invokeai/frontend/webv2/src/workbench/projects/components/OpenProjectDialog.tsx b/invokeai/frontend/webv2/src/workbench/projects/components/OpenProjectDialog.tsx index 304f5080fe7..064e3a2a125 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/components/OpenProjectDialog.tsx +++ b/invokeai/frontend/webv2/src/workbench/projects/components/OpenProjectDialog.tsx @@ -151,7 +151,7 @@ export const OpenProjectDialog = ({ isOpen, onClose }: { isOpen: boolean; onClos const startImport = useCallback(() => void handleImport(), [handleImport]); return ( - + {isOpen ? : null} @@ -180,7 +180,7 @@ export const OpenProjectDialog = ({ isOpen, onClose }: { isOpen: boolean; onClos - + - + diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx b/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx index 64bcc9b2389..dee76e74f15 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/QueueItemActions.tsx @@ -125,11 +125,11 @@ export const QueueItemActions = ({ item }: { item: QueueItemReadModel }) => { - + - + {t('widgets.queue.itemTitle', { id: item.id })} @@ -137,7 +137,7 @@ export const QueueItemActions = ({ item }: { item: QueueItemReadModel }) => { - + diff --git a/invokeai/frontend/webv2/src/workbench/queue-integration/localQueueItemSource.test.ts b/invokeai/frontend/webv2/src/workbench/queue-integration/localQueueItemSource.test.ts index 076f3b2614f..8478d4faecc 100644 --- a/invokeai/frontend/webv2/src/workbench/queue-integration/localQueueItemSource.test.ts +++ b/invokeai/frontend/webv2/src/workbench/queue-integration/localQueueItemSource.test.ts @@ -33,6 +33,8 @@ const wanModel: ModelConfig = { const submitVideo = () => { let state = createInitialWorkbenchState(); + // Video is no longer placed by the non-video defaults; add it first. + state = workbenchReducer(state, { region: 'left', type: 'toggleRegionWidget', widgetId: 'video' }); state = workbenchReducer(state, { type: 'patchWidgetValues', values: { model: wanModel, positivePrompt: 'a fox running' }, diff --git a/invokeai/frontend/webv2/src/workbench/settings/AboutSettings.tsx b/invokeai/frontend/webv2/src/workbench/settings/AboutSettings.tsx new file mode 100644 index 00000000000..1798df29474 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/settings/AboutSettings.tsx @@ -0,0 +1,70 @@ +import { HStack, Icon, Link, Spinner, Stack, Text } from '@chakra-ui/react'; +import { useCapabilities } from '@features/identity'; +import { useMountEffect } from '@platform/react/useMountEffect'; +import { DiscordIcon, GithubIcon } from '@platform/ui/BrandIcon'; +import { JsonPreview } from '@platform/ui/JsonPreview'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { refreshAboutInfo, useAboutInfo } from './aboutInfoStore'; + +const GITHUB_URL = 'https://github.com/invoke-ai/InvokeAI'; +const DISCORD_URL = 'https://discord.gg/ZmtBAhwWhy'; + +/** + * The legacy About modal's content as a settings section: the server's + * version, the community links, and the full system-information blob + * (installed dependency versions plus, for admins, the redacted runtime + * config) as copyable JSON. + */ +export const AboutSettings = () => { + const { t } = useTranslation(); + const { canManageAppConfig } = useCapabilities(); + const info = useAboutInfo(); + + useMountEffect(() => { + void refreshAboutInfo(canManageAppConfig); + }); + + const systemInfo = useMemo( + () => ({ + version: info.version, + dependencies: info.dependencies, + ...(info.runtimeConfig ? { config: info.runtimeConfig } : {}), + }), + [info.dependencies, info.runtimeConfig, info.version] + ); + + return ( + + + + {info.version ? `Invoke v${info.version}` : 'Invoke'} + + + + + {t('settings.about.github')} + + + + {t('settings.about.discord')} + + + + + {info.loadState === 'loading' || info.loadState === 'idle' ? ( + + + {t('settings.about.loading')} + + ) : info.loadState === 'error' ? ( + + {info.error} + + ) : ( + + )} + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/settings/SettingsDialog.tsx b/invokeai/frontend/webv2/src/workbench/settings/SettingsDialog.tsx index 855b5b5762c..c0c504b6f80 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/SettingsDialog.tsx +++ b/invokeai/frontend/webv2/src/workbench/settings/SettingsDialog.tsx @@ -39,6 +39,7 @@ import { Code2Icon, DatabaseIcon, FolderIcon, + InfoIcon, KeyboardIcon, ListOrderedIcon, MapIcon, @@ -53,6 +54,7 @@ import { import { useCallback, useEffect, useId, useMemo, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; +import { AboutSettings } from './AboutSettings'; import { GenerationDevicesSettings } from './GenerationDevicesSettings'; import { HotkeysSettingsSection } from './HotkeysSettingsSection'; import { ImageMapVocabularySettings } from './ImageMapVocabularySettings'; @@ -111,7 +113,6 @@ export const SettingsDialog = ({ isOpen, onClose }: { isOpen: boolean; onClose: closeOnInteractOutside={false} lazyMount open={isOpen} - placement="center" scrollBehavior="inside" size="xl" unmountOnExit @@ -161,7 +162,7 @@ const SettingsDialogContent = ({ onClose }: { onClose: () => void }) => { - + @@ -229,6 +230,12 @@ const SettingsTabs = () => { label: t('settings.tabs.workspace'), value: 'workspace', }, + { + children: , + icon: InfoIcon, + label: t('settings.tabs.about'), + value: 'about', + }, ], [hasWorkbench, t] ); @@ -456,8 +463,8 @@ const BehaviorSection = () => { /> @@ -483,12 +490,6 @@ const ProjectSection = () => { }, [updateProjectSettings] ); - const updateShowProgressDetails = useCallback( - (checked: boolean) => { - updateProjectSettings({ showProgressDetails: checked }); - }, - [updateProjectSettings] - ); const updateAntialiasProgressImages = useCallback( (checked: boolean) => { updateProjectSettings({ antialiasProgressImages: checked }); @@ -513,13 +514,6 @@ const ProjectSection = () => { label="Use CPU noise" onChange={updateUseCpuNoise} /> - { ); }; +const AboutSection = () => { + const { t } = useTranslation(); + + return ( + + + + + + ); +}; + const ImageMapSection = () => { const { t } = useTranslation(); @@ -773,13 +779,11 @@ const WorkspaceSection = () => { const SettingToggle = ({ checked, - comingSoon, description, label, onChange, }: { checked: boolean; - comingSoon?: boolean; description?: string; label: string; onChange: (checked: boolean) => void; @@ -794,7 +798,6 @@ const SettingToggle = ({ void; @@ -850,7 +851,6 @@ const SettingSelect = ({ return (