FN-6397: fix mobile workflow board fill chain

Ensure workflow-mode boards fill the mobile viewport while preserving internal column scrolling.

- Reassert the mobile flex fill chain for project content, workflow board wrappers, and workflow columns.
- Extend mobile board regression coverage across empty/populated workflow states with and without the toolbar.
- Document the workflow board collapse root cause and add a transient temp-dir isolation guard test.

Files changed:
 docs/dashboard-guide.md                            |   1 +
 .../ui-bugs/mobile-workflow-board-fill-chain.md    |  61 +++++++++++++
 .../__tests__/board-mobile-initial-render.test.tsx | 101 +++++++++++++++++++++
 packages/dashboard/app/styles.css                  |  52 +++++++++++
 scripts/__tests__/check-test-isolation.test.mjs    |  23 +++++
 scripts/check-test-isolation.mjs                   |  12 ++-
 6 files changed, 249 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-6397
Fusion-Task-Lineage: ddee773d-6d3a-46d1-b1c7-f601a961365e
This commit is contained in:
gsxdsm
2026-06-13 15:07:44 -07:00
parent e8c2d516af
commit 7fa8c4e5f4
6 changed files with 249 additions and 1 deletions

View File

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

View File

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

View File

@@ -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(
<Board
{...boardProps}
onCreateWorkflow={vi.fn()}
onOpenWorkflowEditor={vi.fn()}
/>,
);
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(
<Board
{...boardProps}
onCreateWorkflow={vi.fn()}
onOpenWorkflowEditor={vi.fn()}
tasks={[
{ id: "FN-1", title: "Workflow planning task", column: "triage" },
{ id: "FN-2", title: "Workflow todo task", column: "todo" },
] as any}
/>,
);
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(<Board {...boardProps} />);
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);

View File

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

View File

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

View File

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