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
This commit is contained in:
gsxdsm
2026-06-13 13:06:59 -07:00
parent ee6d7acec8
commit df4939a656
13 changed files with 479 additions and 10 deletions

View File

@@ -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

View File

@@ -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<DOMRectReadOnly>) {
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 (
<main data-testid="board-surface" className="board-shell">
<section className="board" data-testid={populated ? "board-populated" : "board-empty"}>
{columns.map((column) => (
<article className="column" key={column}>
<header className="column-header">
<h2>{column}</h2>
</header>
<div className="column-body">
{populated ? (
<div className="task-card">Wide task title withaverylongunbrokenidentifierthatmuststayinsidecard</div>
) : (
<p className="empty">No tasks</p>
)}
</div>
</article>
))}
</section>
</main>
);
}
function TaskDetailFixture({ populated }: { populated: boolean }) {
return (
<div className="modal-overlay" data-testid={populated ? "detail-populated-overlay" : "detail-empty-overlay"}>
<section className="modal task-detail-modal" data-testid={populated ? "detail-populated" : "detail-empty"}>
<header className="modal-header">
<h2>{populated ? "Long task detail" : "Empty task detail"}</h2>
<button className="modal-close" aria-label="Close task detail">×</button>
</header>
<div className="detail-body" data-testid={populated ? "detail-populated-body" : "detail-empty-body"}>
{populated ? (
<div className="markdown-body">
<p>Long content pressure withaverylongunbrokenwordthatmustnotescape-the-detail-body.</p>
<pre><code>very-wide-command --with --many --arguments --that --scrolls --internally</code></pre>
</div>
) : (
<p>No task selected.</p>
)}
</div>
</section>
</div>
);
}
function WorkflowFixture({ simple }: { simple: boolean }) {
return (
<div className="modal-overlay" data-testid={simple ? "simple-workflow-overlay" : "workflow-overlay"}>
<section className="modal wf-editor-modal" data-testid={simple ? "simple-workflow" : "workflow-editor"}>
<header className="wf-editor-header">
<h2>{simple ? "Simple workflow editor" : "Workflow editor"}</h2>
<button className="wf-editor-close" aria-label="Close workflow editor">×</button>
</header>
<div className={`wf-editor-body ${simple ? "wf-editor-body--simple-layout" : "wf-editor-body--list-stage"}`}>
<aside className="wf-editor-sidebar">
<button className="wf-editor-new">New workflow</button>
<button className="wf-editor-import">Import workflow</button>
</aside>
<div className="wf-editor-canvas-wrap">
<div className="wf-editor-toolbar">
<button className="wf-editor-action">Validate</button>
<button className="wf-editor-save">Save</button>
</div>
<div className="wf-editor-canvas">Canvas</div>
</div>
<div className="wf-mobile-shell">
<nav className="wf-mobile-tabs" aria-label="Workflow editor sections">
<button className="wf-mobile-tab wf-mobile-tab--active">Add</button>
<button className="wf-mobile-tab">Actions</button>
</nav>
<div className="wf-mobile-panel">
<div className="wf-mobile-actions">
<button className="wf-editor-action">Validate</button>
<button className="wf-editor-save">Save workflow</button>
</div>
</div>
</div>
</div>
</section>
</div>
);
}
function ActivityLogFixture() {
return (
<div className="modal-overlay" data-testid="activity-log-overlay">
<section className="modal modal-lg activity-log-modal" data-testid="activity-log-modal">
<header className="modal-header activity-log-header">
<h2 className="activity-log-title">Activity Log</h2>
<div className="activity-log-actions">
<label className="activity-log-filter">
Type
<select className="activity-log-filter-select" aria-label="Type filter"><option>All</option></select>
</label>
<button className="activity-log-refresh" aria-label="Refresh activity log">↻</button>
<button className="activity-log-clear" aria-label="Clear activity log">Clear</button>
</div>
<button className="modal-close" aria-label="Close activity log">×</button>
</header>
<div className="activity-log-content"><p className="activity-log-empty">No activity yet.</p></div>
</section>
</div>
);
}
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(
<>
<BoardFixture populated={false} />
<BoardFixture populated />
</>,
);
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(
<>
<TaskDetailFixture populated={false} />
<TaskDetailFixture populated />
</>,
);
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(
<>
<WorkflowFixture simple={false} />
<WorkflowFixture simple />
</>,
);
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(<ActivityLogFixture />);
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`);
});
});

View File

@@ -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(),
}));

View File

@@ -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);

View File

@@ -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(),
}));

View File

@@ -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*;/);

View File

@@ -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", () => ({

View File

@@ -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(),
}));

View File

@@ -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(),
}));

View File

@@ -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) => {

View File

@@ -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 }),
}));

View File

@@ -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 }),
}));

View File

@@ -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(