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

|
||||
|
||||
|
||||
61
docs/solutions/ui-bugs/mobile-workflow-board-fill-chain.md
Normal file
61
docs/solutions/ui-bugs/mobile-workflow-board-fill-chain.md
Normal 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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user