From c95135dcb53c7e697e2131011fa1d08973f8f1a2 Mon Sep 17 00:00:00 2001 From: Imrich Schindler Date: Tue, 25 Aug 2026 01:13:52 +0200 Subject: [PATCH 1/3] RUFU-170: make mobile project switcher and header overflow menu scroll internally Cap the mobile-only dropdowns at a viewport-aware max-height so long project lists and overflow menus scroll internally instead of growing past the viewport bottom with lower entries unreachable. - .mobile-project-switch-dropdown (ProjectSelector.css) and .mobile-overflow-menu (Header.css) now use the desktop selector's exact declarations: max-height: min(480px, calc(100vh - 120px)), overflow-y: auto, overscroll-behavior: contain, plus a thin scrollbar mirroring the desktop dropdown's ::-webkit-scrollbar. - The cap lives on the base class (not a media query) so short-landscape phones in JS mobile mode with a 769-1024px CSS width get it too. - Adds a regression test rendering the real dropdowns with a 12-item list asserting the computed-style scroll cap (red before the CSS fix, green after), plus a desktop-cap regression guard. - Adds a patch changeset. Files changed: .changeset/rufu-170-mobile-dropdown-scroll.md | 7 + packages/dashboard/app/components/Header.css | 29 ++++ .../dashboard/app/components/ProjectSelector.css | 29 ++++ .../Header.mobile-project-switch-scroll.test.tsx | 165 +++++++++++++++++++++ 4 files changed, 230 insertions(+) Fusion-Task-Id: RUFU-170 Fusion-Task-Lineage: dd8bef6a-2c31-4ce9-85df-0f24895259f4 Co-authored-by: Fusion --- .changeset/rufu-170-mobile-dropdown-scroll.md | 7 + packages/dashboard/app/components/Header.css | 29 +++ .../app/components/ProjectSelector.css | 29 +++ ...ader.mobile-project-switch-scroll.test.tsx | 165 ++++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 .changeset/rufu-170-mobile-dropdown-scroll.md create mode 100644 packages/dashboard/app/components/__tests__/Header.mobile-project-switch-scroll.test.tsx diff --git a/.changeset/rufu-170-mobile-dropdown-scroll.md b/.changeset/rufu-170-mobile-dropdown-scroll.md new file mode 100644 index 0000000000..d0bbe60e52 --- /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 to max-height: min(480px, calc(100vh - 120px)) with overflow-y: auto and overscroll-behavior: contain, matching the desktop project selector. diff --git a/packages/dashboard/app/components/Header.css b/packages/dashboard/app/components/Header.css index dc0d215baa..23a4c27348 100644 --- a/packages/dashboard/app/components/Header.css +++ b/packages/dashboard/app/components/Header.css @@ -861,6 +861,21 @@ 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. + */ + max-height: min(480px, calc(100vh - 120px)); + 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 +887,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: 6px; +} + +.mobile-overflow-menu::-webkit-scrollbar-track { + background: transparent; +} + +.mobile-overflow-menu::-webkit-scrollbar-thumb { + background-color: var(--text-dim); + border-radius: 3px; +} + .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..ae18ddc4d0 100644 --- a/packages/dashboard/app/components/ProjectSelector.css +++ b/packages/dashboard/app/components/ProjectSelector.css @@ -457,6 +457,21 @@ 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. + */ + max-height: min(480px, calc(100vh - 120px)); + 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 +479,20 @@ box-shadow: var(--shadow-lg); } +/* RUFU-170: thin scrollbar mirroring .project-selector__dropdown::-webkit-scrollbar. */ +.mobile-project-switch-dropdown::-webkit-scrollbar { + width: 6px; +} + +.mobile-project-switch-dropdown::-webkit-scrollbar-track { + background: transparent; +} + +.mobile-project-switch-dropdown::-webkit-scrollbar-thumb { + background-color: var(--text-dim); + border-radius: 3px; +} + .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..20859e8a30 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/Header.mobile-project-switch-scroll.test.tsx @@ -0,0 +1,165 @@ +/** + * RUFU-170: mobile header dropdowns must scroll internally, never overflow the viewport. + * + * FNXC:MobileProjectSwitchScroll 2026-08-24-20:07: + * 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 at the desktop selector's viewport-aware + * max-height: min(480px, calc(100vh - 120px)) with overflow-y: auto and + * overscroll-behavior: contain. This regression test renders the real dropdowns with a long + * list and asserts the computed-style 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); +} + +/** + * The RUFU-170 invariant: the dropdown is viewport-capped and scrolls internally. + * All three values are the desktop selector's exact literals (no parallel variant). + */ +function assertDropdownScrollCap(el: HTMLElement) { + const style = window.getComputedStyle(el); + expect(style.maxHeight).toBe("min(480px, calc(100vh - 120px))"); + 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); + assertDropdownScrollCap(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(); + assertDropdownScrollCap(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"); + assertDropdownScrollCap(dropdown); + }); +}); From bcb2d40471e51b87be498b744dda64ce4357f973 Mon Sep 17 00:00:00 2001 From: Imrich Schindler Date: Tue, 25 Aug 2026 20:48:24 +0200 Subject: [PATCH 2/3] fix(dashboard): restate mobile dropdown cap with dvh (RUFU-170 review P1) On mobile, vh tracks the LAYOUT viewport, so with expanded browser chrome '100vh - 120px' can exceed the visible area and the cap no longer guarantees the dropdown fits. Repeat the cap declaration with the dynamic-viewport unit (dvh) for supporting browsers; older browsers keep the vh fallback. Test now asserts the dvh cap for the mobile dropdowns and keeps asserting the original vh literal for the untouched desktop guard (Case C). Fusion-Task-Id: RUFU-170 --- packages/dashboard/app/components/Header.css | 7 ++++ .../app/components/ProjectSelector.css | 7 ++++ ...ader.mobile-project-switch-scroll.test.tsx | 32 ++++++++++++------- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/packages/dashboard/app/components/Header.css b/packages/dashboard/app/components/Header.css index 23a4c27348..23ff47edd3 100644 --- a/packages/dashboard/app/components/Header.css +++ b/packages/dashboard/app/components/Header.css @@ -870,8 +870,15 @@ non-notched devices, so this is a no-op there. Pair with viewport-fit=cover (ind 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-08-25-18:44: + Review P1: on mobile, vh tracks the LAYOUT viewport, so with the browser chrome expanded + `100vh - 120px` can exceed the visible area and the cap no longer guarantees the menu fits. + The repeated declaration restates the cap with the dynamic-viewport unit (dvh) for browsers + that support it; older browsers keep the vh fallback from the first declaration. */ max-height: min(480px, calc(100vh - 120px)); + max-height: min(480px, calc(100dvh - 120px)); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; diff --git a/packages/dashboard/app/components/ProjectSelector.css b/packages/dashboard/app/components/ProjectSelector.css index ae18ddc4d0..2cdcc5ba3d 100644 --- a/packages/dashboard/app/components/ProjectSelector.css +++ b/packages/dashboard/app/components/ProjectSelector.css @@ -466,8 +466,15 @@ 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-08-25-18:44: + Review P1: on mobile, vh tracks the LAYOUT viewport, so with the browser chrome expanded + `100vh - 120px` can exceed the visible area and the cap no longer guarantees the list fits. + The repeated declaration restates the cap with the dynamic-viewport unit (dvh) for browsers + that support it; older browsers keep the vh fallback from the first declaration. */ max-height: min(480px, calc(100vh - 120px)); + max-height: min(480px, calc(100dvh - 120px)); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; 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 index 20859e8a30..22fcac79b4 100644 --- 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 @@ -5,11 +5,13 @@ * 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 at the desktop selector's viewport-aware - * max-height: min(480px, calc(100vh - 120px)) with overflow-y: auto and - * overscroll-behavior: contain. This regression test renders the real dropdowns with a long - * list and asserts the computed-style scroll cap (red before the CSS fix, green after), and - * guards the desktop .project-selector-dropdown cap as a regression check (Case C). + * unreachable. The fix caps both with the desktop selector's viewport-aware cap, repeated + * with the dynamic-viewport unit (100dvh) so the cap tracks the VISIBLE mobile viewport + * (100vh is the layout viewport and can exceed the visible area with expanded browser + * chrome), with overflow-y: auto and overscroll-behavior: contain. This regression test + * renders the real dropdowns with a long list and asserts the computed-style 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"; @@ -73,13 +75,21 @@ function injectRufuCss() { document.head.appendChild(style); } +/* FNXC:MobileProjectSwitchScroll 2026-08-25-18:44: + Review P1: the mobile cap now resolves to the dvh restatement (last valid declaration + wins); the desktop guard (Case C) keeps asserting the original vh cap, which is the + pre-existing upstream declaration and intentionally unchanged. */ +const MOBILE_CAP = "min(480px, calc(100dvh - 120px))"; +const DESKTOP_CAP = "min(480px, calc(100vh - 120px))"; + /** * The RUFU-170 invariant: the dropdown is viewport-capped and scrolls internally. - * All three values are the desktop selector's exact literals (no parallel variant). + * The cap literal matches the desktop selector's exact declarations (no parallel variant), + * with the mobile cap additionally restated in dvh (see the CSS FNXC notes). */ -function assertDropdownScrollCap(el: HTMLElement) { +function assertDropdownScrollCap(el: HTMLElement, expectedMaxHeight: string) { const style = window.getComputedStyle(el); - expect(style.maxHeight).toBe("min(480px, calc(100vh - 120px))"); + expect(style.maxHeight).toBe(expectedMaxHeight); expect(style.overflowY).toBe("auto"); expect(style.overscrollBehavior).toBe("contain"); } @@ -113,7 +123,7 @@ describe("Header mobile dropdown scroll cap (RUFU-170)", () => { 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); - assertDropdownScrollCap(dropdown); + assertDropdownScrollCap(dropdown, MOBILE_CAP); }); it("Case B: the mobile header overflow menu scrolls internally with a long list", () => { @@ -141,7 +151,7 @@ describe("Header mobile dropdown scroll cap (RUFU-170)", () => { const menu = container.querySelector(".mobile-overflow-menu"); expect(menu).not.toBeNull(); - assertDropdownScrollCap(menu as HTMLElement); + assertDropdownScrollCap(menu as HTMLElement, MOBILE_CAP); }); it("Case C (regression guard): the desktop project selector keeps its scroll cap", () => { @@ -160,6 +170,6 @@ describe("Header mobile dropdown scroll cap (RUFU-170)", () => { fireEvent.click(screen.getByTestId("project-selector-trigger")); const dropdown = screen.getByTestId("project-selector-dropdown"); - assertDropdownScrollCap(dropdown); + assertDropdownScrollCap(dropdown, DESKTOP_CAP); }); }); From c815fb39259af020bf46ac91093d01325e1a259f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 3 Sep 2026 21:58:32 -0700 Subject: [PATCH 3/3] fix(dashboard): tokenize mobile dropdown caps and drop 100vh fallback (RUFU-170) Use shared --dropdown-max-height / --dropdown-viewport-gutter / --space-scrollbar / --radius-scrollbar tokens. Cap mobile menus with the tokenized height, then 100svh, then 100dvh so unsupported dvh cannot retain a larger layout-viewport overflow. --- .changeset/rufu-170-mobile-dropdown-scroll.md | 2 +- packages/dashboard/app/components/Header.css | 19 +++--- .../app/components/ProjectSelector.css | 19 +++--- ...ader.mobile-project-switch-scroll.test.tsx | 60 +++++++++++++------ packages/dashboard/app/styles.css | 14 +++++ 5 files changed, 76 insertions(+), 38 deletions(-) diff --git a/.changeset/rufu-170-mobile-dropdown-scroll.md b/.changeset/rufu-170-mobile-dropdown-scroll.md index d0bbe60e52..5f58e24479 100644 --- a/.changeset/rufu-170-mobile-dropdown-scroll.md +++ b/.changeset/rufu-170-mobile-dropdown-scroll.md @@ -4,4 +4,4 @@ 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 to max-height: min(480px, calc(100vh - 120px)) with overflow-y: auto and overscroll-behavior: contain, matching the desktop project selector. +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 23ff47edd3..221a2e961b 100644 --- a/packages/dashboard/app/components/Header.css +++ b/packages/dashboard/app/components/Header.css @@ -871,14 +871,15 @@ non-notched devices, so this is a no-op there. Pair with viewport-fit=cover (ind 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-08-25-18:44: - Review P1: on mobile, vh tracks the LAYOUT viewport, so with the browser chrome expanded - `100vh - 120px` can exceed the visible area and the cap no longer guarantees the menu fits. - The repeated declaration restates the cap with the dynamic-viewport unit (dvh) for browsers - that support it; older browsers keep the vh fallback from the first declaration. + 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: min(480px, calc(100vh - 120px)); - max-height: min(480px, calc(100dvh - 120px)); + 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; @@ -896,7 +897,7 @@ non-notched devices, so this is a no-op there. Pair with viewport-fit=cover (ind /* RUFU-170: thin scrollbar mirroring .project-selector__dropdown::-webkit-scrollbar. */ .mobile-overflow-menu::-webkit-scrollbar { - width: 6px; + width: var(--space-scrollbar); } .mobile-overflow-menu::-webkit-scrollbar-track { @@ -905,7 +906,7 @@ non-notched devices, so this is a no-op there. Pair with viewport-fit=cover (ind .mobile-overflow-menu::-webkit-scrollbar-thumb { background-color: var(--text-dim); - border-radius: 3px; + border-radius: var(--radius-scrollbar); } .mobile-overflow-item { diff --git a/packages/dashboard/app/components/ProjectSelector.css b/packages/dashboard/app/components/ProjectSelector.css index 2cdcc5ba3d..5358f22169 100644 --- a/packages/dashboard/app/components/ProjectSelector.css +++ b/packages/dashboard/app/components/ProjectSelector.css @@ -467,14 +467,15 @@ 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-08-25-18:44: - Review P1: on mobile, vh tracks the LAYOUT viewport, so with the browser chrome expanded - `100vh - 120px` can exceed the visible area and the cap no longer guarantees the list fits. - The repeated declaration restates the cap with the dynamic-viewport unit (dvh) for browsers - that support it; older browsers keep the vh fallback from the first declaration. + 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: min(480px, calc(100vh - 120px)); - max-height: min(480px, calc(100dvh - 120px)); + 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; @@ -488,7 +489,7 @@ /* RUFU-170: thin scrollbar mirroring .project-selector__dropdown::-webkit-scrollbar. */ .mobile-project-switch-dropdown::-webkit-scrollbar { - width: 6px; + width: var(--space-scrollbar); } .mobile-project-switch-dropdown::-webkit-scrollbar-track { @@ -497,7 +498,7 @@ .mobile-project-switch-dropdown::-webkit-scrollbar-thumb { background-color: var(--text-dim); - border-radius: 3px; + border-radius: var(--radius-scrollbar); } .mobile-project-switch-item { 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 index 22fcac79b4..826022ad88 100644 --- 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 @@ -1,17 +1,18 @@ /** * RUFU-170: mobile header dropdowns must scroll internally, never overflow the viewport. * - * FNXC:MobileProjectSwitchScroll 2026-08-24-20:07: + * 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 the dynamic-viewport unit (100dvh) so the cap tracks the VISIBLE mobile viewport - * (100vh is the layout viewport and can exceed the visible area with expanded browser - * chrome), with overflow-y: auto and overscroll-behavior: contain. This regression test - * renders the real dropdowns with a long list and asserts the computed-style scroll cap - * (red before the CSS fix, green after), and guards the desktop .project-selector-dropdown - * cap as a regression check (Case C). + * 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"; @@ -75,21 +76,42 @@ function injectRufuCss() { document.head.appendChild(style); } -/* FNXC:MobileProjectSwitchScroll 2026-08-25-18:44: - Review P1: the mobile cap now resolves to the dvh restatement (last valid declaration - wins); the desktop guard (Case C) keeps asserting the original vh cap, which is the - pre-existing upstream declaration and intentionally unchanged. */ -const MOBILE_CAP = "min(480px, calc(100dvh - 120px))"; +/* 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. - * The cap literal matches the desktop selector's exact declarations (no parallel variant), - * with the mobile cap additionally restated in dvh (see the CSS FNXC notes). + * 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 assertDropdownScrollCap(el: HTMLElement, expectedMaxHeight: string) { +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(expectedMaxHeight); + expect(style.maxHeight).toBe(DESKTOP_CAP); expect(style.overflowY).toBe("auto"); expect(style.overscrollBehavior).toBe("contain"); } @@ -123,7 +145,7 @@ describe("Header mobile dropdown scroll cap (RUFU-170)", () => { 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); - assertDropdownScrollCap(dropdown, MOBILE_CAP); + assertMobileDropdownScrollCap(dropdown); }); it("Case B: the mobile header overflow menu scrolls internally with a long list", () => { @@ -151,7 +173,7 @@ describe("Header mobile dropdown scroll cap (RUFU-170)", () => { const menu = container.querySelector(".mobile-overflow-menu"); expect(menu).not.toBeNull(); - assertDropdownScrollCap(menu as HTMLElement, MOBILE_CAP); + assertMobileDropdownScrollCap(menu as HTMLElement); }); it("Case C (regression guard): the desktop project selector keeps its scroll cap", () => { @@ -170,6 +192,6 @@ describe("Header mobile dropdown scroll cap (RUFU-170)", () => { fireEvent.click(screen.getByTestId("project-selector-trigger")); const dropdown = screen.getByTestId("project-selector-dropdown"); - assertDropdownScrollCap(dropdown, DESKTOP_CAP); + 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);