diff --git a/.changeset/rufu-170-mobile-dropdown-scroll.md b/.changeset/rufu-170-mobile-dropdown-scroll.md new file mode 100644 index 0000000000..5f58e24479 --- /dev/null +++ b/.changeset/rufu-170-mobile-dropdown-scroll.md @@ -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. diff --git a/packages/dashboard/app/components/Header.css b/packages/dashboard/app/components/Header.css index dc0d215baa..221a2e961b 100644 --- a/packages/dashboard/app/components/Header.css +++ b/packages/dashboard/app/components/Header.css @@ -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); @@ -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; diff --git a/packages/dashboard/app/components/ProjectSelector.css b/packages/dashboard/app/components/ProjectSelector.css index f8e9343cb3..5358f22169 100644 --- a/packages/dashboard/app/components/ProjectSelector.css +++ b/packages/dashboard/app/components/ProjectSelector.css @@ -457,6 +457,29 @@ 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); @@ -464,6 +487,20 @@ 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); +} + .mobile-project-switch-item { width: 100%; display: flex; diff --git a/packages/dashboard/app/components/__tests__/Header.mobile-project-switch-scroll.test.tsx b/packages/dashboard/app/components/__tests__/Header.mobile-project-switch-scroll.test.tsx new file mode 100644 index 0000000000..826022ad88 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/Header.mobile-project-switch-scroll.test.tsx @@ -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). + */ +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()), + 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 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( +
, + ); + + 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( +
, + ); + + /* 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( +
, + ); + + fireEvent.click(screen.getByTestId("project-selector-trigger")); + const dropdown = screen.getByTestId("project-selector-dropdown"); + assertDesktopDropdownScrollCap(dropdown); + }); +}); diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index d7aff9a891..01d50c7b04 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -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);