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.

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