diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index d4138c1fde..676a79cc3f 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -62,6 +62,7 @@ Features: - GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown alongside existing footer metadata like timers - Agent-created provenance badge in task card headers for agent-originated tasks (`sourceType: agent_heartbeat` or `sourceType: automation`, or legacy tasks with `sourceAgentId`), with labels preferring `sourceMetadata.agentName` over raw agent IDs - Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` is ordered by most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback) +- On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll. ![Board view](./screenshots/dashboard-overview.png) diff --git a/docs/solutions/ui-bugs/mobile-workflow-board-fill-chain.md b/docs/solutions/ui-bugs/mobile-workflow-board-fill-chain.md new file mode 100644 index 0000000000..179fa82cba --- /dev/null +++ b/docs/solutions/ui-bugs/mobile-workflow-board-fill-chain.md @@ -0,0 +1,61 @@ +--- +title: "Mobile workflow board fill chain" +date: 2026-06-13 +category: ui-bugs +module: packages/dashboard/app/styles.css +problem_type: ui_bug +component: frontend_css +symptoms: + - "On mobile viewports, workflow-mode kanban renders as a small content-sized box in the upper-left corner" + - "The mobile footer/nav still spans the viewport while the workflow toolbar and columns do not" +root_cause: mobile_css_fill_chain_gap +resolution_type: code_fix +severity: medium +related_components: + - packages/dashboard/app/components/Board.tsx + - packages/dashboard/app/components/Lane.css + - packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx + - packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts +tags: + - mobile-board + - workflow-mode + - css-fill-chain + - scroll-containment + - css-regression-test +applies_when: + - "A board variant is wrapped by `.project-content` and must fill the mobile viewport" + - "Later mobile `.board` rules can override base/tablet workflow fill rules" +--- + +# Mobile workflow board fill chain + +## Problem + +Workflow-mode board rendering uses `.board-workflow-view` around `main.board.board-workflow-columns`. On phones (`max-width: 768px`), the generic mobile board sizing rules can win after the workflow fill rules and leave the workflow board content-sized. The visible symptom is a small toolbar/column cluster in the upper-left while the rest of the dashboard chrome still fills the viewport. + +## Root cause + +The desktop/tablet workflow rules established a fill chain, but the mobile tier did not restate it after the generic `.board` and `.board > .column` overrides. That made the mobile path depend on inherited/earlier flex sizing through: + +```text +.project-content → .board-workflow-view → .board.board-workflow-columns → .column +``` + +When the later mobile rules changed board/column sizing without reasserting definite `flex`, `width`, `height`, `min-height: 0`, and stretch behavior for the workflow path, the workflow board could collapse to its intrinsic content size. + +## Solution + +In the mobile media query, explicitly restate the full workflow fill contract after the generic board rules: + +- `.project-content` remains a stretching flex container with `min-width: 0`, `min-height: 0`, and hidden outer overflow. +- `.board-workflow-view` fills its parent as a column flex container. +- `.board.board-workflow-columns` fills available width/height, remains the horizontal scroller, and keeps `overscroll-behavior-x: contain`, `touch-action: pan-x pan-y`, and `scroll-snap-type: x proximity`. +- Workflow columns keep a fixed mobile column basis/min-width while stretching vertically. + +Do not solve this by relaxing page-level mobile pan locks, changing board snap to `x mandatory`, or clipping the workflow board's horizontal overflow; those changes regress established mobile board navigation and overscroll behavior. + +## Regression coverage + +`packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx` should assert the mobile CSS fill chain for `.project-content`, `.board-workflow-view`, `.board.board-workflow-columns`, and workflow columns, including toolbar-present/toolbar-absent and empty/populated workflow states. + +Keep `packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts` green alongside it so future fill fixes cannot weaken horizontal overscroll containment or change snap strictness. diff --git a/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx index 48f621b55f..e79f87b9c4 100644 --- a/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx @@ -265,6 +265,10 @@ describe("Board mobile initial render stabilization (FN-4574)", () => { const tabletBoardRule = extractRule(tabletCss, ".board"); const mobileBoardRule = extractRule(mobileCss, ".board"); const mobileColumnRule = extractRule(mobileCss, ".board > .column"); + const mobileProjectContentRule = extractRule(mobileCss, ".project-content"); + const mobileWorkflowViewRule = extractRule(mobileCss, ".board-workflow-view"); + const mobileWorkflowColumnsRule = extractRule(mobileCss, ".board.board-workflow-columns"); + const mobileWorkflowColumnRule = extractRule(mobileCss, ".board.board-workflow-columns > .column"); const projectContentRule = extractRule(cssContent, ".project-content"); expect(projectContentRule).toContain("display: flex"); @@ -316,6 +320,37 @@ describe("Board mobile initial render stabilization (FN-4574)", () => { expect(mobileColumnRule).toContain("width: 300px"); expect(mobileColumnRule).toContain("min-width: 300px"); expect(mobileColumnRule).toContain("flex-shrink: 0"); + + expect(mobileProjectContentRule).toContain("display: flex"); + expect(mobileProjectContentRule).toContain("align-items: stretch"); + expect(mobileProjectContentRule).toContain("width: 100%"); + expect(mobileProjectContentRule).toContain("min-height: 0"); + expect(mobileProjectContentRule).toContain("overflow: hidden"); + + expect(mobileWorkflowViewRule).toContain("display: flex"); + expect(mobileWorkflowViewRule).toContain("flex-direction: column"); + expect(mobileWorkflowViewRule).toContain("flex: 1 1 auto"); + expect(mobileWorkflowViewRule).toContain("width: 100%"); + expect(mobileWorkflowViewRule).toContain("height: 100%"); + expect(mobileWorkflowViewRule).toContain("min-height: 0"); + expect(mobileWorkflowViewRule).toContain("overflow: hidden"); + + expect(mobileWorkflowColumnsRule).toContain("display: flex"); + expect(mobileWorkflowColumnsRule).toContain("flex: 1 1 auto"); + expect(mobileWorkflowColumnsRule).toContain("align-items: stretch"); + expect(mobileWorkflowColumnsRule).toContain("width: 100%"); + expect(mobileWorkflowColumnsRule).toContain("height: 100%"); + expect(mobileWorkflowColumnsRule).toContain("min-height: 0"); + expect(mobileWorkflowColumnsRule).toContain("overflow-x: auto"); + expect(mobileWorkflowColumnsRule).toContain("overscroll-behavior-x: contain"); + expect(mobileWorkflowColumnsRule).toContain("touch-action: pan-x pan-y"); + expect(mobileWorkflowColumnsRule).toContain("scroll-snap-type: x proximity"); + expect(mobileWorkflowColumnsRule).not.toContain("scroll-snap-type: x mandatory"); + + expect(mobileWorkflowColumnRule).toContain("flex: 1 0 300px"); + expect(mobileWorkflowColumnRule).toContain("min-width: 300px"); + expect(mobileWorkflowColumnRule).toContain("height: 100%"); + expect(mobileWorkflowColumnRule).toContain("min-height: 0"); }); it("renders the board main element and all column children for empty and populated states", () => { @@ -352,6 +387,72 @@ describe("Board mobile initial render stabilization (FN-4574)", () => { viewportSpy.mockRestore(); }); + it("renders workflow-mode columns for empty and populated states at mobile width with and without the toolbar", async () => { + vi.useRealTimers(); + const viewportSpy = mockViewport(390); + apiMocks.fetchBoardWorkflows.mockResolvedValue(workflowPayload); + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(document.querySelector(".board-workflow-view")).not.toBeNull(); + }); + + expect(document.querySelector(".board-workflow-toolbar")).not.toBeNull(); + let board = document.querySelector("main.board.board-workflow-columns"); + expect(board).not.toBeNull(); + + let columns = document.querySelectorAll(".board-workflow-columns [data-testid^='column-']"); + expect(columns).toHaveLength(6); + for (const column of columns) { + expect(column).toHaveClass("column"); + expect(column).toHaveAttribute("data-task-count", "0"); + } + + rerender( + , + ); + + await waitFor(() => { + expect(document.querySelector("main.board.board-workflow-columns")).not.toBeNull(); + }); + + board = document.querySelector("main.board.board-workflow-columns"); + expect(board).not.toBeNull(); + + columns = document.querySelectorAll(".board-workflow-columns [data-testid^='column-']"); + expect(columns).toHaveLength(6); + expect(document.querySelector(".board-workflow-columns [data-testid='column-triage']")).toHaveAttribute("data-task-count", "1"); + expect(document.querySelector(".board-workflow-columns [data-testid='column-todo']")).toHaveAttribute("data-task-count", "1"); + + cleanup(); + + render(); + + await waitFor(() => { + expect(document.querySelector("main.board.board-workflow-columns")).not.toBeNull(); + }); + + expect(document.querySelector(".board-workflow-toolbar")).toBeNull(); + expect(document.querySelectorAll(".board-workflow-columns [data-testid^='column-']")).toHaveLength(6); + + viewportSpy.mockRestore(); + }); + it("renders workflow-mode columns for empty and populated states at tablet width", async () => { vi.useRealTimers(); const viewportSpy = mockViewport(900); diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index 28d27f1958..a8dac93175 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -3431,6 +3431,58 @@ input[type="range"]:focus-visible { scroll-snap-align: center; } + /* Workflow-mode board: reassert the definite flex fill chain at the phone + tier after the generic mobile .board/.column overrides. The board keeps + the document-pan lock on ancestors while scrolling internally. */ + .project-content { + display: flex; + flex: 1; + align-items: stretch; + width: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + } + + .project-content > .board-workflow-view, + .board-workflow-view { + display: flex; + flex-direction: column; + flex: 1 1 auto; + align-self: stretch; + width: 100%; + height: 100%; + max-height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + } + + .board.board-workflow-columns { + display: flex; + flex-direction: row; + flex: 1 1 auto; + align-self: stretch; + align-items: stretch; + width: 100%; + height: 100%; + max-height: 100%; + min-width: 0; + min-height: 0; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + touch-action: pan-x pan-y; + scroll-snap-type: x proximity; + } + + .board.board-workflow-columns > .column { + flex: 1 0 300px; + min-width: 300px; + height: 100%; + min-height: 0; + } + /* Column header: natural height from padding and font is sufficient */ /* Column count badge: slightly wider on mobile for tapping */ diff --git a/scripts/__tests__/check-test-isolation.test.mjs b/scripts/__tests__/check-test-isolation.test.mjs index 50919b8563..aea48407a5 100644 --- a/scripts/__tests__/check-test-isolation.test.mjs +++ b/scripts/__tests__/check-test-isolation.test.mjs @@ -49,6 +49,29 @@ test("fails when a tracked temp leak appears after baseline", () => { }); }); +test("ignores tracked temp dirs that disappear during the settle window", () => { + withFixture(({ cwd, home }) => { + const before = runScript(["--before"], { cwd, home }); + assert.equal(before.status, 0); + + const transientName = `fusion-test-transient-worker-${process.pid}`; + const transientPath = path.join(tmpdir(), transientName); + mkdirSync(transientPath, { recursive: true }); + const cleanup = spawn(process.execPath, ["-e", `setTimeout(() => require("node:fs").rmSync(process.argv[1], { recursive: true, force: true }), 100)`, transientPath], { + cwd, + env: { ...process.env, HOME: home, USERPROFILE: home }, + stdio: "ignore", + }); + try { + const after = runScript([], { cwd, home }); + assert.equal(after.status, 0, after.stderr || after.stdout); + } finally { + cleanup.kill("SIGTERM"); + rmSync(transientPath, { recursive: true, force: true }); + } + }); +}); + test("ignores leaked temp dirs whose basenames appear in FUSION_TEST_ISOLATION_IGNORE_NAMES", () => { withFixture(({ cwd, home }) => { const before = runScript(["--before"], { cwd, home }); diff --git a/scripts/check-test-isolation.mjs b/scripts/check-test-isolation.mjs index 6813161979..e3b95eb1f6 100755 --- a/scripts/check-test-isolation.mjs +++ b/scripts/check-test-isolation.mjs @@ -278,7 +278,7 @@ function checkAgainstBaseline() { .map((name) => name.trim()) .filter(Boolean); for (const name of callerIgnoreNames) baselineNames.add(name); - const leaks = snapshotTmp().filter((e) => { + let leaks = snapshotTmp().filter((e) => { if (baselineNames.has(e.name)) { return false; } @@ -288,6 +288,16 @@ function checkAgainstBaseline() { return true; }); + // Vitest/Node worker roots can disappear a moment after the child process + // exits on macOS. Re-check candidate leaks after a short settle window so + // the guard still fails durable leaks while avoiding false failures for + // already-cleaned transient worker directories. + if (leaks.length > 0) { + sleepMs(500); + const settledNames = new Set(snapshotTmp().map((e) => e.name)); + leaks = leaks.filter((e) => settledNames.has(e.name)); + } + const baselineByDir = new Map((baseline.protectedFusion ?? []).map((entry) => [entry.dir, entry])); const unstableProtectedDirs = new Set(baseline.unstableProtectedDirs ?? []); const currentProtected = snapshotProtectedFusion();