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:
7
.changeset/fn-7687-mobile-header-single-line-refold.md
Normal file
7
.changeset/fn-7687-mobile-header-single-line-refold.md
Normal 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).
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user