Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/rufu-170-mobile-dropdown-scroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---

summary: The mobile project switcher and header overflow menu now scroll internally when their lists are long.
category: fix
dev: Capped .mobile-project-switch-dropdown and .mobile-overflow-menu with tokenized --dropdown-max-height / --dropdown-viewport-gutter, 100svh then 100dvh (never 100vh), overflow-y: auto, and overscroll-behavior: contain.
37 changes: 37 additions & 0 deletions packages/dashboard/app/components/Header.css
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,29 @@ non-notched devices, so this is a no-op there. Pair with viewport-fit=cover (ind
top: calc(100% + 4px);
right: 12px;
min-width: 220px;
/*
FNXC:MobileOverflowMenuScroll 2026-08-24-20:12:
RUFU-170: the mobile header overflow menu shared the uncapped pattern fixed on the
mobile project switch (.mobile-project-switch-dropdown in ProjectSelector.css):
with 8+ menu items the column grew past the viewport bottom and lower entries were
unreachable. Cap it with the desktop selector's exact viewport-aware declarations so the
menu scrolls internally, and contain overscroll so wheeling to the list end does not
scroll the page. The cap lives on the base class (not a media query) so a
short-landscape phone in JS mobile mode with a 769-1024px CSS width gets it too.

FNXC:MobileOverflowMenuScroll 2026-09-04-04:43:
Review P1 follow-up: do not fall back to 100vh. Unsupported dvh would keep the larger
layout-viewport calculation and leave lower entries below the visible screen. First
declaration is the tokenized cap (smaller than typical mobile chrome-visible height).
Then 100svh (smallest viewport, chrome expanded) and 100dvh (current visible).
*/
max-height: var(--dropdown-max-height);
max-height: min(var(--dropdown-max-height), calc(100svh - var(--dropdown-viewport-gutter)));
max-height: min(var(--dropdown-max-height), calc(100dvh - var(--dropdown-viewport-gutter)));
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: var(--text-dim) transparent;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
Expand All @@ -872,6 +895,20 @@ non-notched devices, so this is a no-op there. Pair with viewport-fit=cover (ind
gap: 2px;
}

/* RUFU-170: thin scrollbar mirroring .project-selector__dropdown::-webkit-scrollbar. */
.mobile-overflow-menu::-webkit-scrollbar {
width: var(--space-scrollbar);
}

.mobile-overflow-menu::-webkit-scrollbar-track {
background: transparent;
}

.mobile-overflow-menu::-webkit-scrollbar-thumb {
background-color: var(--text-dim);
border-radius: var(--radius-scrollbar);
}

.mobile-overflow-item {
display: flex;
align-items: center;
Expand Down
37 changes: 37 additions & 0 deletions packages/dashboard/app/components/ProjectSelector.css
Original file line number Diff line number Diff line change
Expand Up @@ -457,13 +457,50 @@
z-index: 100;
min-width: 200px;
max-width: 280px;
/*
FNXC:MobileProjectSwitchScroll 2026-08-24-20:07:
RUFU-170: the mobile switcher list must scroll internally instead of overflowing the
viewport. The mobile-only dropdown previously had no max-height/overflow-y, so with 8+
projects items below the viewport bottom were unreachable. Cap it with the desktop
selector's exact viewport-aware declarations (see .project-selector-dropdown above)
rather than a new variant, and contain overscroll so wheeling to the list end does not
scroll the page. The cap lives on the base class (not a media query) so a short-landscape
phone in JS mobile mode with a 769-1024px CSS width gets it too.

FNXC:MobileProjectSwitchScroll 2026-09-04-04:43:
Review P1 follow-up: do not fall back to 100vh. Unsupported dvh would keep the larger
layout-viewport calculation and leave lower entries below the visible screen. First
declaration is the tokenized cap (smaller than typical mobile chrome-visible height).
Then 100svh (smallest viewport, chrome expanded) and 100dvh (current visible).
*/
max-height: var(--dropdown-max-height);
max-height: min(var(--dropdown-max-height), calc(100svh - var(--dropdown-viewport-gutter)));
max-height: min(var(--dropdown-max-height), calc(100dvh - var(--dropdown-viewport-gutter)));
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: var(--text-dim) transparent;
padding: var(--space-xs);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
box-shadow: var(--shadow-lg);
}

/* RUFU-170: thin scrollbar mirroring .project-selector__dropdown::-webkit-scrollbar. */
.mobile-project-switch-dropdown::-webkit-scrollbar {
width: var(--space-scrollbar);
}

.mobile-project-switch-dropdown::-webkit-scrollbar-track {
background: transparent;
}

.mobile-project-switch-dropdown::-webkit-scrollbar-thumb {
background-color: var(--text-dim);
border-radius: var(--radius-scrollbar);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

.mobile-project-switch-item {
width: 100%;
display: flex;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* RUFU-170: mobile header dropdowns must scroll internally, never overflow the viewport.
*
* FNXC:MobileProjectSwitchScroll 2026-09-04-04:43:
* The mobile project switch (.mobile-project-switch-dropdown) and the mobile header overflow
* menu (.mobile-overflow-menu) had no max-height/overflow-y/overscroll-behavior, so with 8+
* projects (or menu items) the list grew past the viewport bottom and lower items were
* unreachable. The fix caps both with the desktop selector's viewport-aware cap, repeated
* with 100svh then 100dvh (never a larger 100vh first) so the cap tracks the VISIBLE
* mobile viewport. 100vh is the layout viewport and overflows with expanded browser
* chrome; unsupported dvh must not retain that overflow. overflow-y: auto and
* overscroll-behavior: contain. This regression test renders the real dropdowns with a
* long list and asserts the computed-style token/dvh scroll cap (red before the CSS
* fix, green after), and guards the desktop .project-selector-dropdown cap as a
* regression check (Case C).
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { Header } from "../Header";
import type { ProjectInfo } from "../../api";
import { loadComponentCss, loadStylesCss } from "../../test/cssFixture";

const mockFetchScripts = vi.fn();

vi.mock("../../api", async (importOriginal) => ({
...(await importOriginal<typeof import("../../api")>()),
fetchScripts: (...args: unknown[]) => mockFetchScripts(...args),
}));

/* FNXC:TestViewportMock 2026-08-24-20:07: mutable mode so one file covers the mobile
repro (Cases A+B) and the desktop regression guard (Case C); keep the mocked surface
complete — a missing export throws inside Header and the ErrorBoundary turns it into
a missing element instead of a red assertion. */
const viewportMocks = vi.hoisted(() => ({ mode: "mobile" as string }));

vi.mock("../../hooks/useViewportMode", () => ({
isTabletTouchViewport: (mode?: string) => mode === "tablet",
useViewportMode: () => viewportMocks.mode,
isShortViewport: () => false,
}));

function makeProject(id: string, name: string): ProjectInfo {
return {
id,
name,
path: `/projects/${id}`,
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}

/* 12 projects: 3 bookmarked + 9 plain — comfortably past the ~8 items that overflow a
phone viewport when the dropdown is uncapped. */
const projects: ProjectInfo[] = Array.from({ length: 12 }, (_, i) => {
const n = String(i + 1).padStart(2, "0");
return makeProject(`project-${n}`, `Project ${n}`);
});

/**
* Inject the real stylesheets (styles.css tokens + the two component files under test)
* into <head> so window.getComputedStyle resolves the rules, following the
* injectCommandCenterCss pattern (FN-6595). Removed and re-appended per case so each
* case reads the CSS on disk at that moment.
*/
function injectRufuCss() {
document.head.querySelector("style[data-testid='rufu-170-css']")?.remove();
const style = document.createElement("style");
style.setAttribute("data-testid", "rufu-170-css");
style.textContent = [
loadStylesCss(),
loadComponentCss("ProjectSelector.css"),
loadComponentCss("Header.css"),
].join("\n");
document.head.appendChild(style);
}

/* FNXC:MobileProjectSwitchScroll 2026-09-04-04:43:
Review P1 follow-up: the mobile cap's winning declaration must use the tokenized
visible-viewport formula (dvh, with svh/token fallbacks — never 100vh). The desktop
guard (Case C) keeps asserting the original vh cap, which is the pre-existing
upstream declaration and intentionally unchanged. */
const DESKTOP_CAP = "min(480px, calc(100vh - 120px))";

function readRootToken(name: string): string {
return window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}

/**
* The RUFU-170 invariant: the dropdown is viewport-capped and scrolls internally.
* Assert the computed max-height (not source text): it must use the shared tokens and
* a dynamic/small viewport unit, never a larger 100vh fallback.
*/
function assertMobileDropdownScrollCap(el: HTMLElement) {
const style = window.getComputedStyle(el);
const cap = readRootToken("--dropdown-max-height");
const gutter = readRootToken("--dropdown-viewport-gutter");
expect(cap.length).toBeGreaterThan(0);
expect(gutter.length).toBeGreaterThan(0);
expect(style.maxHeight).toMatch(/dvh|svh/);
expect(style.maxHeight).not.toMatch(/100vh/);
const tokenizedDvh = `min(var(--dropdown-max-height), calc(100dvh - var(--dropdown-viewport-gutter)))`;
const tokenizedSvh = `min(var(--dropdown-max-height), calc(100svh - var(--dropdown-viewport-gutter)))`;
const resolvedDvh = `min(${cap}, calc(100dvh - ${gutter}))`;
const resolvedSvh = `min(${cap}, calc(100svh - ${gutter}))`;
expect([cap, tokenizedDvh, tokenizedSvh, resolvedDvh, resolvedSvh]).toContain(style.maxHeight);
expect(style.overflowY).toBe("auto");
expect(style.overscrollBehavior).toBe("contain");
}

function assertDesktopDropdownScrollCap(el: HTMLElement) {
const style = window.getComputedStyle(el);
expect(style.maxHeight).toBe(DESKTOP_CAP);
expect(style.overflowY).toBe("auto");
expect(style.overscrollBehavior).toBe("contain");
}

describe("Header mobile dropdown scroll cap (RUFU-170)", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockFetchScripts.mockResolvedValue({});
injectRufuCss();
});

it("Case A (symptom): the mobile project switch scrolls internally with a long list", () => {
viewportMocks.mode = "mobile";
localStorage.setItem(
"fusion_project_bookmarks",
JSON.stringify(projects.slice(0, 3).map((p) => p.id)),
);

const { container } = render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
onOpenSettings={vi.fn()}
onOpenGitHubImport={vi.fn()}
/>,
);

fireEvent.click(screen.getByTestId("mobile-project-switch-trigger"));
const dropdown = screen.getByTestId("mobile-project-switch-dropdown");
// The cap must not silently collapse the panel: every project item still renders.
expect(dropdown.querySelectorAll(".mobile-project-switch-item").length).toBe(12);
assertMobileDropdownScrollCap(dropdown);
});

it("Case B: the mobile header overflow menu scrolls internally with a long list", () => {
viewportMocks.mode = "mobile";

const { container } = render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
onOpenSettings={vi.fn()}
onOpenGitHubImport={vi.fn()}
onOpenFiles={vi.fn()}
onOpenUsage={vi.fn()}
onOpenSchedules={vi.fn()}
onOpenActivityLog={vi.fn()}
/>,
);

/* mobileNavEnabled is deliberately NOT passed: it must stay falsy so the
compact overflow trigger/menu renders (hideFullNav = isMobile && mobileNavEnabled). */
const trigger = container.querySelector("button.compact-overflow-trigger");
expect(trigger).not.toBeNull();
fireEvent.click(trigger!);

const menu = container.querySelector(".mobile-overflow-menu");
expect(menu).not.toBeNull();
assertMobileDropdownScrollCap(menu as HTMLElement);
});

it("Case C (regression guard): the desktop project selector keeps its scroll cap", () => {
viewportMocks.mode = "desktop";

render(
<Header
projects={projects}
currentProject={projects[0]}
onSelectProject={vi.fn()}
onViewAllProjects={vi.fn()}
onOpenSettings={vi.fn()}
onOpenGitHubImport={vi.fn()}
/>,
);

fireEvent.click(screen.getByTestId("project-selector-trigger"));
const dropdown = screen.getByTestId("project-selector-dropdown");
assertDesktopDropdownScrollCap(dropdown);
});
});
14 changes: 14 additions & 0 deletions packages/dashboard/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,20 @@ html {
--radius-lg: 12px;
--radius-xl: 16px;
--radius-pill: 10px;
/*
FNXC:DashboardDropdownStyles 2026-09-04-04:43:
RUFU-170 mobile menus must cap against the VISIBLE viewport. 100vh is the larger layout
viewport and overflows when mobile chrome is expanded, so it is never the fallback.
Tokenize the desktop selector's 480px cap (15× --space-2xl) and 120px header offset
(10× --space-md), plus the existing 6px/3px thin-scrollbar size, so component CSS never
hardcodes px. The first max-height is the tokenized cap alone (smaller than a typical
phone's visible area with chrome), then 100svh (smallest, chrome expanded), then 100dvh
(current visible).
*/
--dropdown-max-height: calc(var(--space-2xl) * 15);
--dropdown-viewport-gutter: calc(var(--space-md) * 10);
--space-scrollbar: calc(var(--space-sm) - var(--space-3xs));
--radius-scrollbar: calc(var(--radius-sm) * 0.75);

/* Component-specific tokens */
--btn-padding: var(--space-sm) var(--space-lg);
Expand Down
Loading