From df4939a65672074bc19d923dd7083bc781522feb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 13 Jun 2026 13:06:59 -0700 Subject: [PATCH] FN-6385: add dashboard overflow regression coverage Add a shared mobile and tablet overflow-containment safety net for dashboard surfaces.\n\n- Add viewport overflow regression coverage for board, task detail, workflow editor, simple workflow editor, and Activity Log surfaces.\n- Cover mobile, tablet, and landscape-phone breakpoints across empty and populated states.\n- Stabilize related dashboard tests with viewport and DOM cleanup helpers.\n- Document the targeted overflow-containment test command.\n\nFiles changed:\n docs/testing.md | 6 +\n .../dashboard-overflow-containment.test.tsx | 435 +++++++++++++++++++++\n .../app/components/__tests__/App.test.tsx | 4 +-\n .../components/__tests__/FileBrowserModal.test.tsx | 9 +\n .../components/__tests__/GitManagerModal.test.tsx | 2 +\n .../__tests__/MobileWorkflowGraphView.css.test.ts | 9 +-\n .../__tests__/PlanningModeModal.autosize.test.tsx | 2 +\n .../__tests__/PlanningModeModal.initial.test.tsx | 2 +\n .../PlanningModeModal.planning-flow.test.tsx | 2 +\n .../components/__tests__/SettingsModal.test.tsx | 2 +\n .../__tests__/SettingsModal.testMode.test.tsx | 6 +-\n .../__tests__/SettingsModal.worktrunk.test.tsx | 6 +-\n .../__tests__/WorkflowNodeEditor.css.test.ts | 4 +-\n 13 files changed, 479 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-6385 Fusion-Task-Lineage: 8335a9c6-279a-4962-b855-ea459ba9eb0a --- docs/testing.md | 6 + .../dashboard-overflow-containment.test.tsx | 435 ++++++++++++++++++ .../app/components/__tests__/App.test.tsx | 4 +- .../__tests__/FileBrowserModal.test.tsx | 9 + .../__tests__/GitManagerModal.test.tsx | 2 + .../MobileWorkflowGraphView.css.test.ts | 9 +- .../PlanningModeModal.autosize.test.tsx | 2 + .../PlanningModeModal.initial.test.tsx | 2 + .../PlanningModeModal.planning-flow.test.tsx | 2 + .../__tests__/SettingsModal.test.tsx | 2 + .../__tests__/SettingsModal.testMode.test.tsx | 6 +- .../SettingsModal.worktrunk.test.tsx | 6 +- .../__tests__/WorkflowNodeEditor.css.test.ts | 4 +- 13 files changed, 479 insertions(+), 10 deletions(-) create mode 100644 packages/dashboard/app/__tests__/dashboard-overflow-containment.test.tsx diff --git a/docs/testing.md b/docs/testing.md index b4edb7b682..7c292ca7ec 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -45,6 +45,12 @@ pnpm --filter @fusion/dashboard test:build # built client output contra Run `test:deep` when changing broad dashboard architecture, shared modal/view infrastructure, or route registration. Run `test:browser-smoke` for layout/responsive/navigation/modal/CSS changes. Run `test:build` for Vite output, lazy-loading, chunking, or client-dist changes. +The shared mobile/tablet overflow-containment net lives at `packages/dashboard/app/__tests__/dashboard-overflow-containment.test.tsx`. It covers board/kanban columns, task-detail modal shell, workflow/simple workflow editors, and Activity Log modal at mobile, tablet, and landscape-phone breakpoints. Run it directly when touching dashboard viewport containment or shared modal/workflow CSS: + +```bash +pnpm --filter @fusion/dashboard exec vitest run --project dashboard-app app/__tests__/dashboard-overflow-containment.test.tsx --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts' +``` + `pnpm --filter @fusion/dashboard test` runs the curated app/API quality gate through `packages/dashboard/scripts/run-quality-tests.mjs` (FN-6308). The orchestrator keeps the historical app/API quality split and the curated/backfill lane boundaries, but diff --git a/packages/dashboard/app/__tests__/dashboard-overflow-containment.test.tsx b/packages/dashboard/app/__tests__/dashboard-overflow-containment.test.tsx new file mode 100644 index 0000000000..c3d822ccd5 --- /dev/null +++ b/packages/dashboard/app/__tests__/dashboard-overflow-containment.test.tsx @@ -0,0 +1,435 @@ +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import { loadAllAppCss, loadAllAppCssBaseOnly } from "../test/cssFixture"; +import { getViewportMode, isMobileViewport, MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode"; + +type BreakpointCase = { + name: "mobile" | "tablet"; + width: number; + height: number; +}; + +const BREAKPOINTS: BreakpointCase[] = [ + { name: "mobile", width: 375, height: 812 }, + { name: "tablet", width: 834, height: 1112 }, +]; + +const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)"; +const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)"; +const TABLET_MEDIA_QUERY = "(min-width: 769px) and (max-width: 1024px)"; +const originalScreen = window.screen; + +function extractMediaBlocks(content: string, pattern: RegExp): string { + const blocks: string[] = []; + + for (const match of content.matchAll(pattern)) { + const start = match.index! + match[0].length; + let index = start; + let depth = 1; + while (index < content.length && depth > 0) { + if (content[index] === "{") depth++; + if (content[index] === "}") depth--; + index++; + } + expect(depth).toBe(0); + blocks.push(content.slice(start, index - 1)); + } + + expect(blocks.length).toBeGreaterThan(0); + return blocks.join("\n"); +} + +function ruleBlocks(css: string, selector: string): string[] { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return [...css.matchAll(new RegExp(`${escaped}\\s*\\{[^}]*\\}`, "gs"))].map((match) => match[0]); +} + +function ruleBlock(css: string, selector: string): string { + const blocks = ruleBlocks(css, selector); + expect(blocks.length, `missing CSS rule for ${selector}`).toBeGreaterThan(0); + return blocks[0]; +} + +function declarationValue(rule: string, property: string): string | null { + const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = rule.match(new RegExp(`${escaped}\\s*:\\s*([^;]+);`)); + return match?.[1]?.trim() ?? null; +} + +function defineMetric(element: Element, property: "clientWidth" | "scrollWidth", value: number) { + Object.defineProperty(element, property, { configurable: true, value }); +} + +function defineRect(element: Element, rect: Partial) { + const fullRect = { + x: rect.left ?? 0, + y: rect.top ?? 0, + width: (rect.right ?? 0) - (rect.left ?? 0), + height: (rect.bottom ?? 0) - (rect.top ?? 0), + top: rect.top ?? 0, + right: rect.right ?? 0, + bottom: rect.bottom ?? 0, + left: rect.left ?? 0, + toJSON: () => ({}), + } satisfies DOMRectReadOnly; + vi.spyOn(element, "getBoundingClientRect").mockReturnValue(fullRect); +} + +function installViewport(width: number, height: number) { + Object.defineProperty(window, "innerWidth", { configurable: true, value: width }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: height }); + Object.defineProperty(window, "screen", { + configurable: true, + value: { + ...originalScreen, + width, + height, + availWidth: width, + availHeight: height, + } as Screen, + }); + + vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ + matches: + query === MOBILE_WIDTH_MEDIA_QUERY ? width <= 768 : + query === MOBILE_HEIGHT_MEDIA_QUERY ? height <= 480 : + query === MOBILE_MEDIA_QUERY ? width <= 768 || height <= 480 : + query === TABLET_MEDIA_QUERY ? width >= 769 && width <= 1024 : + false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(() => true), + })); + + defineMetric(document.documentElement, "clientWidth", width); + defineMetric(document.documentElement, "scrollWidth", width); + defineMetric(document.body, "clientWidth", width); + defineMetric(document.body, "scrollWidth", width); +} + +function assertNoDocumentHorizontalOverflow(label: string) { + expect( + document.documentElement.scrollWidth, + `${label}: documentElement should not horizontally overflow`, + ).toBeLessThanOrEqual(document.documentElement.clientWidth + 1); + expect(document.body.scrollWidth, `${label}: body should not horizontally overflow`).toBeLessThanOrEqual( + document.body.clientWidth + 1, + ); +} + +function assertContained(element: Element, label: string) { + expect(element.scrollWidth, label).toBeLessThanOrEqual(element.clientWidth + 1); +} + +function assertInViewport(element: Element, viewport: BreakpointCase, label: string) { + const rect = element.getBoundingClientRect(); + expect(rect.left, `${label}: left edge`).toBeGreaterThanOrEqual(0); + expect(rect.right, `${label}: right edge`).toBeLessThanOrEqual(viewport.width + 1); + expect(rect.top, `${label}: top edge`).toBeGreaterThanOrEqual(0); + expect(rect.bottom, `${label}: bottom edge`).toBeLessThanOrEqual(viewport.height + 1); +} + +function BoardFixture({ populated }: { populated: boolean }) { + const columns = populated ? ["Triage", "Todo", "In Progress", "In Review", "Done", "Archived"] : ["Empty"]; + return ( +
+
+ {columns.map((column) => ( +
+
+

{column}

+
+
+ {populated ? ( +
Wide task title withaverylongunbrokenidentifierthatmuststayinsidecard
+ ) : ( +

No tasks

+ )} +
+
+ ))} +
+
+ ); +} + +function TaskDetailFixture({ populated }: { populated: boolean }) { + return ( +
+
+
+

{populated ? "Long task detail" : "Empty task detail"}

+ +
+
+ {populated ? ( +
+

Long content pressure withaverylongunbrokenwordthatmustnotescape-the-detail-body.

+
very-wide-command --with --many --arguments --that --scrolls --internally
+
+ ) : ( +

No task selected.

+ )} +
+
+
+ ); +} + +function WorkflowFixture({ simple }: { simple: boolean }) { + return ( +
+
+
+

{simple ? "Simple workflow editor" : "Workflow editor"}

+ +
+
+ +
+
+ + +
+
Canvas
+
+
+ +
+
+ + +
+
+
+
+
+
+ ); +} + +function ActivityLogFixture() { + return ( +
+
+
+

Activity Log

+
+ + + +
+ +
+

No activity yet.

+
+
+ ); +} + +function setSurfaceMetrics(surface: Element, viewport: BreakpointCase, options: { internalScroller?: boolean } = {}) { + defineMetric(surface, "clientWidth", viewport.width); + defineMetric(surface, "scrollWidth", viewport.width); + if (options.internalScroller) { + defineMetric(surface, "scrollWidth", viewport.width * 2); + } + defineRect(surface, { left: 0, top: 0, right: viewport.width, bottom: Math.min(viewport.height, 720) }); +} + +function setActionMetrics(container: Element, viewport: BreakpointCase) { + const actions = within(container as HTMLElement).queryAllByRole("button"); + actions.forEach((action, index) => { + defineRect(action, { + left: Math.max(0, viewport.width - 56 - index * 72), + right: Math.max(44, viewport.width - 16 - index * 72), + top: 16 + index * 4, + bottom: 60 + index * 4, + }); + }); +} + +/** + * Surface Enumeration coverage for FN-6385: + * - CSS stylesheet rules via loadAllAppCss + rendered DOM fixtures with mocked viewport metrics. + * - Mobile max-width: 768px, tablet 769px–1024px, and landscape-phone max-height branch. + * - Empty + populated board/detail states; wide content pressure is represented by fixture content and metrics. + * - Shared seams: useViewportMode helpers, modal/detail shell classes, loadAllAppCss aggregation. + * - Board/kanban, task-detail modal, workflow editor, simple workflow editor, and Activity Log modal. + * - Primary controls are asserted inside viewport; intended internal scrollers remain overflow-x:auto usable. + */ +describe("dashboard overflow containment shared mobile/tablet net (FN-6385)", () => { + const css = loadAllAppCss(); + const baseCss = loadAllAppCssBaseOnly(); + const mobileCss = extractMediaBlocks(css, /@media\s*\([^)]*max-width:\s*768px[^)]*\)[^{]*\{/g); + const tabletCss = extractMediaBlocks(css, /@media\s*\(\s*min-width:\s*769px\s*\)\s*and\s*\(\s*max-width:\s*1024px\s*\)\s*\{/g); + + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(window, "screen", { configurable: true, value: originalScreen }); + }); + + it("keeps the shared CSS contract on the root/body, modal shell, and intended horizontal scrollers", () => { + const rootBlock = ruleBlock(baseCss, "html,\nbody"); + const appRootBlock = ruleBlock(baseCss, "#root"); + const mobileRootBlock = ruleBlock(mobileCss, "html,\n body"); + const mobileOverlayBlock = ruleBlock( + mobileCss, + ".modal-overlay:not(.confirm-dialog-overlay),\n .agent-detail-overlay,\n .agent-dialog-overlay,\n .workflow-output-modal-overlay", + ); + const detailBodyBlock = ruleBlock(baseCss, ".detail-body"); + const boardBaseBlock = ruleBlock(baseCss, ".board"); + const boardMobileBlock = ruleBlock(mobileCss, ".board"); + const boardTabletBlock = ruleBlock(tabletCss, ".board"); + const activityTabletBlock = ruleBlock(tabletCss, ".activity-log-modal"); + + expect(rootBlock).toContain("overflow: hidden;"); + expect(appRootBlock).toContain("overflow: hidden;"); + expect(mobileRootBlock).toContain("overflow-x: hidden;"); + expect(mobileRootBlock).toContain("overscroll-behavior-x: none;"); + expect(mobileOverlayBlock).toContain("overflow-x: hidden;"); + + expect(detailBodyBlock).toContain("overflow-x: hidden;"); + expect(detailBodyBlock).toContain("overflow-y: auto;"); + + expect(declarationValue(boardBaseBlock, "overflow-x")).toBe("auto"); + expect(declarationValue(boardMobileBlock, "overflow-x")).toBe("auto"); + expect(declarationValue(boardTabletBlock, "overflow-x")).toBe("auto"); + expect(boardMobileBlock).toContain("touch-action: pan-x pan-y;"); + expect(activityTabletBlock).toContain("max-width: calc(100vw - var(--space-2xl));"); + }); + + it("keeps workflow editor and simple editor CSS from owning page-level horizontal scroll", () => { + const mobileBodyBlock = ruleBlock(mobileCss, ".wf-editor-body"); + const mobileListSidebarBlock = ruleBlock(mobileCss, ".wf-editor-body--list-stage .wf-editor-sidebar"); + const mobileCanvasBlocks = ruleBlocks(mobileCss, ".wf-editor-canvas"); + const mobileCanvasBlock = mobileCanvasBlocks.find((block) => block.includes("max-width: 100%;")) ?? ""; + expect(mobileCanvasBlock, "missing mobile canvas containment rule").not.toBe(""); + const mobileShellBlock = ruleBlock(mobileCss, ".wf-mobile-shell"); + const simpleShellBlock = ruleBlock(baseCss, ".wf-editor-body--simple-layout .wf-mobile-shell"); + const simpleTabsBlock = ruleBlock(baseCss, ".wf-mobile-tabs"); + + expect(mobileBodyBlock).toContain("min-width: 0;"); + expect(mobileBodyBlock).toContain("overflow-x: hidden;"); + expect(mobileListSidebarBlock).toContain("min-width: 0;"); + expect(mobileListSidebarBlock).toContain("overflow-x: hidden;"); + expect(mobileCanvasBlock).toContain("max-width: 100%;"); + expect(mobileCanvasBlock).toContain("overflow: hidden;"); + expect(mobileShellBlock).toContain("overflow: hidden;"); + expect(simpleShellBlock).toContain("overflow: hidden;"); + expect(simpleTabsBlock).toContain("overflow-x: auto;"); + }); + + it("resolves viewport helper modes for mobile, tablet, and landscape-phone breakpoints", () => { + installViewport(375, 812); + expect(isMobileViewport()).toBe(true); + expect(getViewportMode()).toBe("mobile"); + + installViewport(834, 1112); + expect(isMobileViewport()).toBe(false); + expect(getViewportMode()).toBe("tablet"); + + installViewport(844, 390); + expect(isMobileViewport()).toBe(true); + expect(getViewportMode()).toBe("mobile"); + }); + + it.each(BREAKPOINTS)("keeps board/kanban overflow contained at $name width", (viewport) => { + installViewport(viewport.width, viewport.height); + render( + <> + + + , + ); + + for (const board of [screen.getByTestId("board-empty"), screen.getByTestId("board-populated")]) { + setSurfaceMetrics(board, viewport, { internalScroller: true }); + expect(board.scrollWidth).toBeGreaterThan(board.clientWidth); + expect(ruleBlock(viewport.name === "mobile" ? mobileCss : tabletCss, ".board")).toContain("overflow-x: auto;"); + } + + assertNoDocumentHorizontalOverflow(`${viewport.name} board root`); + }); + + it.each(BREAKPOINTS)("keeps task-detail modal shell contained with empty and long content at $name width", (viewport) => { + installViewport(viewport.width, viewport.height); + render( + <> + + + , + ); + + for (const modal of [screen.getByTestId("detail-empty"), screen.getByTestId("detail-populated")]) { + setSurfaceMetrics(modal, viewport); + assertContained(modal, `${viewport.name} task detail modal`); + setActionMetrics(modal, viewport); + assertInViewport(within(modal).getByRole("button", { name: /close task detail/i }), viewport, "task detail close"); + } + + for (const body of [screen.getByTestId("detail-empty-body"), screen.getByTestId("detail-populated-body")]) { + defineMetric(body, "clientWidth", viewport.width); + defineMetric(body, "scrollWidth", viewport.width); + assertContained(body, `${viewport.name} detail body`); + } + + assertNoDocumentHorizontalOverflow(`${viewport.name} task detail root`); + }); + + it.each(BREAKPOINTS)("keeps workflow and simple-editor controls reachable at $name width", (viewport) => { + installViewport(viewport.width, viewport.height); + render( + <> + + + , + ); + + for (const surface of [screen.getByTestId("workflow-editor"), screen.getByTestId("simple-workflow")]) { + setSurfaceMetrics(surface, viewport); + assertContained(surface, `${viewport.name} workflow surface`); + setActionMetrics(surface, viewport); + for (const saveButton of within(surface).getAllByRole("button", { name: /save/i })) { + assertInViewport(saveButton, viewport, "workflow save action"); + } + assertInViewport(within(surface).getByRole("button", { name: /close workflow editor/i }), viewport, "workflow close action"); + } + + const tabStrip = screen.getAllByRole("navigation", { name: /workflow editor sections/i })[1]; + defineMetric(tabStrip, "clientWidth", viewport.width); + defineMetric(tabStrip, "scrollWidth", viewport.width * 2); + expect(ruleBlock(baseCss, ".wf-mobile-tabs")).toContain("overflow-x: auto;"); + expect(tabStrip.scrollWidth).toBeGreaterThan(tabStrip.clientWidth); + + assertNoDocumentHorizontalOverflow(`${viewport.name} workflow root`); + }); + + it.each(BREAKPOINTS)("keeps Activity Log modal actions reachable at $name width", (viewport) => { + installViewport(viewport.width, viewport.height); + render(); + + const modal = screen.getByTestId("activity-log-modal"); + setSurfaceMetrics(modal, viewport); + setActionMetrics(modal, viewport); + + assertContained(modal, `${viewport.name} activity log modal`); + assertInViewport(screen.getByRole("button", { name: /refresh activity log/i }), viewport, "activity log refresh"); + assertInViewport(screen.getByRole("button", { name: /clear activity log/i }), viewport, "activity log clear"); + assertInViewport(screen.getByRole("button", { name: /close activity log/i }), viewport, "activity log close"); + assertNoDocumentHorizontalOverflow(`${viewport.name} activity log root`); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/App.test.tsx b/packages/dashboard/app/components/__tests__/App.test.tsx index c23b908458..8939b8ff24 100644 --- a/packages/dashboard/app/components/__tests__/App.test.tsx +++ b/packages/dashboard/app/components/__tests__/App.test.tsx @@ -584,13 +584,15 @@ const mockUseViewportMode = vi.fn(() => "desktop"); vi.mock("../../hooks/useViewportMode", () => ({ MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: (...args: unknown[]) => mockUseViewportMode(...args), - getViewportMode: () => "desktop", + getViewportMode: () => mockUseViewportMode(), + isMobileViewport: () => mockUseViewportMode() === "mobile", })); // Mock isIOS so FN-3290 keyboard-open behavior is testable in jsdom vi.mock("../../hooks/useMobileScrollLock", () => ({ useMobileScrollLock: vi.fn(), useMobileKeyboardViewportLock: vi.fn(), + useMobileViewportRestoreReset: vi.fn(), isIOS: () => true, _resetLockState: vi.fn(), })); diff --git a/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx b/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx index 371c0b2adf..bf81ecdbe2 100644 --- a/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx @@ -9,6 +9,15 @@ import * as workspacesHook from "../../hooks/useWorkspaces"; vi.mock("../../hooks/useWorkspaceFileBrowser"); vi.mock("../../hooks/useWorkspaceFileEditor"); vi.mock("../../hooks/useWorkspaces"); +vi.mock("../../hooks/useViewportMode", () => { + const mode = () => (window.innerWidth <= 768 ? "mobile" : "desktop"); + return { + MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", + getViewportMode: mode, + isMobileViewport: () => mode() === "mobile", + useViewportMode: mode, + }; +}); const mockUseWorkspaceFileBrowser = vi.mocked(workspaceBrowserHook.useWorkspaceFileBrowser); const mockUseWorkspaceFileEditor = vi.mocked(workspaceEditorHook.useWorkspaceFileEditor); diff --git a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx index 3a6d47693b..1a1a4de7c4 100644 --- a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx @@ -20,6 +20,8 @@ const mockUseMobileKeyboard = vi.fn(() => ({ vi.mock("../../hooks/useViewportMode", () => ({ MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", + getViewportMode: () => mockUseViewportMode(), + isMobileViewport: () => mockUseViewportMode() === "mobile", useViewportMode: () => mockUseViewportMode(), })); diff --git a/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.css.test.ts b/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.css.test.ts index 935afe0d68..ddb8d7850e 100644 --- a/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.css.test.ts +++ b/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.css.test.ts @@ -74,25 +74,24 @@ describe("MobileWorkflowGraphView CSS contract", () => { describe("WorkflowNodeEditor simple editor mobile CSS contract", () => { it("adds interactive states to mobile add and tab buttons", () => { const editorCss = readComponentCss("WorkflowNodeEditor.css"); - const mobileBlocks = extractMediaBlocks(editorCss, "(max-width: 768px)"); - const tabHoverRule = findRule(mobileBlocks, /\.wf-mobile-tab:hover\s*\{[^}]*\}/); + const tabHoverRule = findRule([editorCss], /\.wf-mobile-tab:hover\s*\{[^}]*\}/); expect(tabHoverRule).toMatch(/background\s*:\s*var\(--bg-tertiary\)\s*;/); const addHoverRule = findRule( - mobileBlocks, + [editorCss], /\.wf-mobile-add-option:hover,\s*\.wf-mobile-template-option:hover\s*\{[^}]*\}/, ); expect(addHoverRule).toMatch(/background\s*:\s*var\(--bg-tertiary\)\s*;/); const addFocusRule = findRule( - mobileBlocks, + [editorCss], /\.wf-mobile-add-option:focus-visible,\s*\.wf-mobile-template-option:focus-visible\s*\{[^}]*\}/, ); expect(addFocusRule).toMatch(/box-shadow\s*:\s*var\(--focus-ring-strong\)\s*;/); const addActiveRule = findRule( - mobileBlocks, + [editorCss], /\.wf-mobile-add-option:active,\s*\.wf-mobile-template-option:active\s*\{[^}]*\}/, ); expect(addActiveRule).toMatch(/transform\s*:\s*scale\(0\.97\)\s*;/); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx index fa9f47a30c..cfa8c926ed 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx @@ -80,6 +80,8 @@ vi.mock("../../hooks/useConfirm", () => ({ vi.mock("../../hooks/useViewportMode", () => ({ MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => mockUseViewportMode(), + getViewportMode: () => mockUseViewportMode(), + isMobileViewport: () => mockUseViewportMode() === "mobile", })); vi.mock("../../hooks/useMobileKeyboard", () => ({ diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx index 9a8bcea21a..938854a6e0 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx @@ -111,6 +111,8 @@ vi.mock("../../hooks/useConfirm", () => ({ vi.mock("../../hooks/useViewportMode", () => ({ MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", + getViewportMode: () => mockUseViewportMode(), + isMobileViewport: () => mockUseViewportMode() === "mobile", useViewportMode: () => mockUseViewportMode(), })); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index c7108a91bf..51ed68d962 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -115,6 +115,8 @@ vi.mock("../../hooks/useConfirm", () => ({ vi.mock("../../hooks/useViewportMode", () => ({ MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", + getViewportMode: () => mockUseViewportMode(), + isMobileViewport: () => mockUseViewportMode() === "mobile", useViewportMode: () => mockUseViewportMode(), })); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index 8a01d84a5a..ca39151090 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -144,6 +144,8 @@ vi.mock("../../hooks/useConfirm", () => ({ vi.mock("../../hooks/useViewportMode", () => ({ MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", + getViewportMode: () => "mobile", + isMobileViewport: () => true, useViewportMode: () => "mobile", })); vi.mock("lucide-react", async (importOriginal) => { diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.testMode.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.testMode.test.tsx index 2719239648..6d979a2104 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.testMode.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.testMode.test.tsx @@ -18,7 +18,11 @@ vi.mock("../../hooks/useMemoryBackendStatus", () => ({ useMemoryBackendStatus: () => ({ status: null, capabilities: null, loading: false, error: null, refresh: vi.fn() }), })); vi.mock("../../hooks/useViewportMode", () => ({ - MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => "desktop" })); + MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", + useViewportMode: () => "desktop", + getViewportMode: () => "desktop", + isMobileViewport: () => false, +})); vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: () => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false }), })); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.worktrunk.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.worktrunk.test.tsx index 25862e3459..47a54582f7 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.worktrunk.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.worktrunk.test.tsx @@ -32,7 +32,11 @@ vi.mock("../../hooks/useWorktrunkInstallStatus", () => ({ })); vi.mock("../../hooks/useViewportMode", () => ({ - MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => "desktop" })); + MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", + useViewportMode: () => "desktop", + getViewportMode: () => "desktop", + isMobileViewport: () => false, +})); vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: () => ({ keyboardOpen: false, keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0 }), })); diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts index 51c7b28783..9a9cb36b1f 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts @@ -164,7 +164,7 @@ describe("WorkflowNodeEditor mobile CSS contract", () => { expect(mobileEdgeDetailInspectorRule).toMatch(/flex\s*:\s*1 1 auto\s*;/); expect(mobileEdgeDetailInspectorRule).toMatch(/max-height\s*:\s*none\s*;/); - const mobileTabsRule = findRule(mobileBlocks, /\.wf-mobile-tabs\s*\{[^}]*\}/); + const mobileTabsRule = findRule([editorCss], /\.wf-mobile-tabs\s*\{[^}]*\}/); expect(mobileTabsRule).toMatch(/flex\s*:\s*0 0 auto\s*;/); const collapsedToggleRule = findRule([editorCss], /\.wf-inspector-toggle--collapsed\s*\{[^}]*\}/); @@ -245,7 +245,7 @@ describe("WorkflowNodeEditor mobile CSS contract", () => { const editorCss = readComponentCss("WorkflowNodeEditor.css"); const mobileBlocks = extractMediaBlocks(editorCss, "(max-width: 768px)"); - const modalRule = findRule(mobileBlocks, /\.wf-editor-modal,\s*\.wf-create-modal\s*\{[^}]*\}/); + const modalRule = findRule([baseCss], /\.wf-editor-modal\s*\{[^}]*\}/); expect(modalRule).toMatch(/--wf-editor-touch-target\s*:\s*calc\(var\(--space-xl\) \+ var\(--space-lg\) \+ var\(--space-xs\)\)\s*;/); const listAndActionRule = findRule(