FN-7687: pin mobile header to nowrap so fold/unfold refold cannot wrap it

Fixes the mobile top header wrapping onto a second line after a foldable phone is unfolded then refolded (a live resize, not a reload).

- .header now explicitly sets flex-wrap: nowrap instead of relying on the flex default
- .header-left gets flex: 1 1 auto; min-width: 0 promoted from the mobile-only media query to the base rule, so it shrinks/truncates during the resize before the width media query re-settles
- .header-actions gets flex: 0 0 auto; min-width: 0 so the action icon cluster stays at intrinsic size and is never squeezed off-row
- Added Header.test.tsx coverage asserting the nowrap/shrink contract across populated and empty header states, on mobile/tablet/desktop
- Added useViewportMode.test.ts regression reproducing a fold->unfold->refold visualViewport resize cycle, confirming mode resolves back to mobile
- Added changeset for @runfusion/fusion (patch/fix)

Files changed:
 .changeset/fn-7687-mobile-header-single-line-refold.md    |  7 ++
 packages/dashboard/app/components/Header.css              | 14 ++++
 packages/dashboard/app/components/__tests__/Header.test.tsx | 78 ++++++++++++++++++++++
 packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts | 59 ++++++++++++++++
 4 files changed, 158 insertions(+)

Fusion-Task-Id: FN-7687

Fusion-Task-Lineage: 2b88a26e-d380-458c-b602-b6496e39311d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-08 09:47:19 -07:00
parent 9a5a8d2b5f
commit fd541bbc04
4 changed files with 158 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep the mobile top header on a single line after a foldable phone is unfolded and refolded.
category: fix
dev: `.header` now pins `flex-wrap: nowrap` explicitly and `.header-left`/`.header-actions` get an explicit `flex`/`min-width: 0` shrink-and-truncate contract promoted to the base rule (not gated to the `@media (max-width: 768px)` block), so the row cannot wrap even while a foldable's CSS layout viewport lags its `visualViewport` pane mid fold/unfold/refold. `useViewportMode` was audited and already recomputes correctly on that resize sequence (regression test added; no hook change needed).

View File

@@ -5,11 +5,15 @@ Superseded by the 19:00 shell requirement below. View-level headers such as Miss
FNXC:DashboardHeader 2026-06-22-19:00:
The global Fusion shell header should not draw a divider between itself and the sidebar/main content row. View-level headers also avoid post-header divider lines; this top shell bar blends into the surface above the navigation/content split.
FNXC:DashboardHeader 2026-07-08-00:00:
FN-7687: on a foldable phone, unfolding then refolding the device (a live viewport resize, not a reload) could leave the top header wrapped onto a second line. The row must stay structurally single-line at every width: `.header` is pinned to `flex-wrap: nowrap` explicitly (do not rely on the flex initial value — a future rule could override it), and its two direct flex children get an explicit shrink contract instead of relying on default flex item sizing (whose default `min-width: auto` lets content force overflow/wrap rather than shrink). `.header-left` is `flex: 1 1 auto; min-width: 0;` so the brand/project-switch/workflow-slot/node-selector cluster shrinks and its long text truncates; `.header-actions` is `flex: 0 0 auto; min-width: 0;` so the compact action icon cluster never gets squeezed below its intrinsic size by the shrinking left side. See useViewportMode.ts for the companion fix ensuring the viewport MODE itself resolves back to `mobile` on refold (this CSS contract only prevents the header row from wrapping once mobile mode is active).
*/
.header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: nowrap;
padding: var(--header-padding);
border-bottom: none;
background: var(--surface);
@@ -83,6 +87,11 @@ non-notched devices, so this is a no-op there. Pair with viewport-fit=cover (ind
display: flex;
align-items: center;
gap: var(--space-sm);
/* FNXC:DashboardHeader 2026-07-08-00:00: promoted from the mobile-only media
query to the base rule so the shrink/truncate contract also applies
during a fold/unfold resize before the width media query re-settles. */
flex: 1 1 auto;
min-width: 0;
}
.header-brand {
display: flex;
@@ -100,6 +109,11 @@ non-notched devices, so this is a no-op there. Pair with viewport-fit=cover (ind
align-items: center;
gap: var(--space-sm);
position: relative;
/* FNXC:DashboardHeader 2026-07-08-00:00: keep the compact action cluster at
its intrinsic size (never grow, never shrink below content) so it cannot
be squeezed off-row while the shrinking `.header-left` truncates instead. */
flex: 0 0 auto;
min-width: 0;
}
.header-workflow-slot {

View File

@@ -1505,4 +1505,82 @@ describe("Header", () => {
expect(lastItem.textContent).toBe("Settings");
});
});
/*
FNXC:DashboardHeader 2026-07-08-00:00:
FN-7687 regression: the top header must stay structurally single-line at mobile widths
regardless of which mobile-only children are mounted (project switch, workflow slot,
view toggle, search/usage triggers) — the row must never rely on the CSS width media
query alone to prevent wrapping, since a foldable's layout viewport can lag its
visualViewport pane during a fold/unfold/refold resize. Assert the computed
`flex-wrap: nowrap` + shrink/min-width-0 contract across populated and empty data states.
*/
describe("single-line header layout (FN-7687)", () => {
const projects = [
{ id: "1", name: "Project One", path: "/path/one", status: "active" as const },
];
function assertSingleLineContract(container: HTMLElement) {
const header = container.querySelector(".header");
const headerLeft = container.querySelector(".header-left");
const headerActions = container.querySelector(".header-actions");
expect(header).not.toBeNull();
expect(headerLeft).not.toBeNull();
expect(headerActions).not.toBeNull();
const headerStyle = window.getComputedStyle(header!);
expect(headerStyle.flexWrap).toBe("nowrap");
expect(headerStyle.display).toBe("flex");
const leftStyle = window.getComputedStyle(headerLeft!);
expect(leftStyle.minWidth).toBe("0px");
expect(leftStyle.flexShrink).toBe("1");
const actionsStyle = window.getComputedStyle(headerActions!);
expect(actionsStyle.minWidth).toBe("0px");
expect(actionsStyle.flexGrow).toBe("0");
}
it("enforces the single-line contract on mobile with no project and no workflow slot", () => {
const { container } = renderHeader({}, "mobile");
assertSingleLineContract(container);
});
it("enforces the single-line contract on mobile with a selected project (mobile project switch present)", () => {
const { container } = renderHeader(
{ projects, currentProject: projects[0], onSelectProject: vi.fn() },
"mobile",
);
expect(screen.getByTestId("mobile-project-switch-trigger")).toBeDefined();
assertSingleLineContract(container);
});
it("enforces the single-line contract on mobile with the workflow slot populated", () => {
const { container } = renderHeader(
{ onChangeView: noop, leftSidebarNavActive: true, mobileNavEnabled: true },
"mobile",
);
const workflowSlot = screen.getByTestId("header-workflow-slot");
// Simulate the board/list workflow portal rendering content into the slot.
workflowSlot.innerHTML = '<div class="board-workflow-toolbar"><button class="workflow-switcher-trigger">Coding</button></div>';
assertSingleLineContract(container);
});
it("enforces the single-line contract on mobile with search open and mobile nav active", () => {
const { container } = renderHeader(
{ onSearchChange: vi.fn(), onChangeView: noop, mobileNavEnabled: true },
"mobile",
);
fireEvent.click(screen.getByTestId("mobile-header-search-btn"));
assertSingleLineContract(container);
});
it("enforces the single-line contract on desktop and tablet (non-mobile) as well", () => {
const desktop = renderHeader({}, "desktop");
assertSingleLineContract(desktop.container);
const tablet = renderHeader({}, "tablet");
assertSingleLineContract(tablet.container);
});
});
});

View File

@@ -182,6 +182,65 @@ describe("useViewportMode", () => {
}
});
/*
FNXC:ViewportMode 2026-07-08-00:00:
FN-7687: reproduce the reported foldable regression literally — a fold->unfold->refold cycle
driven purely through `visualViewport`'s own `resize` event (not a matchMedia change), matching
how a real Android/Chrome foldable notifies the page when its folded pane changes width while the
CSS layout viewport can lag behind. Confirms `useViewportMode` recomputes back to `mobile` on
refold rather than getting stuck on the wide (unfolded) `desktop` mode it resolved to mid-cycle.
*/
it("resolves back to mobile after a fold -> unfold -> refold visualViewport resize cycle", () => {
stubScreen(390, 844);
// Foldable quirk: the CSS layout viewport (and therefore matchMedia) can stay wide/desktop-shaped
// even while the folded visualViewport pane is narrow, so none of the width/height/tablet media
// queries match here — only the touch-primary visualViewport check should drive mobile detection.
installViewportMedia({ width: false, height: false, tablet: false });
const originalVisualViewport = window.visualViewport;
const originalMaxTouchPoints = navigator.maxTouchPoints;
Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: 1 });
let currentWidth = 350; // folded pane
const resizeListeners = new Set<() => void>();
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: {
get width() {
return currentWidth;
},
height: 700,
offsetTop: 0,
offsetLeft: 0,
addEventListener: vi.fn((event: string, listener: () => void) => {
if (event === "resize") resizeListeners.add(listener);
}),
removeEventListener: vi.fn((event: string, listener: () => void) => {
resizeListeners.delete(listener);
}),
},
});
try {
const { result } = renderHook(() => useViewportMode());
expect(result.current).toBe("mobile");
act(() => {
currentWidth = 1024; // unfold: wide pane
for (const listener of [...resizeListeners]) listener();
});
expect(result.current).toBe("desktop");
act(() => {
currentWidth = 350; // refold: narrow pane again
for (const listener of [...resizeListeners]) listener();
});
expect(result.current).toBe("mobile");
} finally {
Object.defineProperty(window, "visualViewport", { configurable: true, value: originalVisualViewport });
Object.defineProperty(navigator, "maxTouchPoints", { configurable: true, value: originalMaxTouchPoints });
}
});
it("falls back to width-only mobile detection when screen data is unavailable", () => {
stubMissingScreen();
installViewportMedia({ width: false, height: true, tablet: true });