From c56e5f52ed28f6c7e039049dc1e9aa3000413a8b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 10:02:56 +0000 Subject: [PATCH] fix(ui): remove dead scroll on pages that fit the window Pages whose content already ended still carried a scroll range, so a scrollbar appeared on a page with nothing below the fold and a wheel notch jolted the page into its bottom stop. Measured in Chromium across 39 routes at five viewports: 8px on every standalone page, 38-46px on all 15 shared mode homes, 57px on routes carrying the header nav row, and up to 273px on a tall window. The cause was the same everywhere: page-fill floors written as `calc(100dvh - )`. No estimate could be right. `--shell-header-h` (4rem) covers the header's inner bar plus `pb-2` but not the bar's own `pt-[max(0.5rem,var(--safe-area-top))]`; nothing knew about the `header-collapse-addon` nav row on topic routes; nothing knew about `#main-content`'s own `sm:pb-8`; and the dashboard's `11rem` guess had to cover the header block, the wrapper padding, the desktop composer slot and the space-y gap in one number. Surfaces now grow into the box above them instead, which is exact by construction and cannot drift again: - shell `#main-content` grows into `.phone-viewport-frame` (`sm:grow`) - `mobile-composer-reserve-pad` becomes the fill box at sm+ - page shells grow into that pad (`sm:grow`) - the dashboard content wrapper is the fill box for the mode-home canvas, which grows into it (`sm:grow sm:shrink-0`) Phone geometry is untouched: below `sm` the document owns scrolling and there is no bounded box to fill. All 65 phone measurements are byte-identical before and after. Verified in Chromium against 39 routes x 5 viewports: 82 measurements improved, 113 unchanged, 0 increased, and no route gained horizontal overflow. Every page that fits now reports a scroll range of exactly 0; every page with real content reports the same range as before. Adds the "pages that fit the window have no scroll range" guard to tests/ui-chrome-scroll.spec.ts, records the rule as invariant 24 in docs/search-chrome-behaviour.md, and updates the two contract tests that pinned the old floors. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Bbm7tQoyg6etyf7NqoA65d --- docs/search-chrome-behaviour.md | 14 ++++++ src/app/globals.css | 15 +++++-- src/components/ClinicalDashboard.tsx | 11 ++++- .../favourites-command-library-page.tsx | 9 ++-- .../global-search-shell.tsx | 28 +++++++++++- .../clinical-dashboard/mode-home-canvas.ts | 19 ++++++-- .../differential-compare-queue-page.tsx | 4 +- ...ifferential-presentation-workflow-page.tsx | 2 +- .../differential-stream-workspace.tsx | 2 +- src/components/information-page-shell.tsx | 5 +-- src/components/mode-home-template.tsx | 2 +- src/components/therapy-compass/workspace.tsx | 5 +-- src/components/ui-primitives.tsx | 5 +-- tests/mobile-interaction-regressions.test.ts | 14 +++--- tests/ui-chrome-scroll.spec.ts | 44 +++++++++++++++++++ tests/ui-overlay-css-contract.test.ts | 13 +++++- 16 files changed, 158 insertions(+), 34 deletions(-) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index ab0f5b3cef..057795e747 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -458,6 +458,20 @@ in-page navigation work defaults to the DocumentViewer template above. omit `source-images` when `visualCount === 0`, and do not require a "Tables and diagrams" sheet row in smoke for the empty-images lithium demo doc. 23. Safari's status bar, collapsing address bar, and pixels outside `window.innerHeight` are native browser/system controls. Do not use negative safe-area overscan, a fixed app root, synthetic document padding, or an opaque viewport slab to make CSS appear to own those pixels. Acceptance is no contrasting **app-owned** band around the native controls, with a matching opaque root canvas. Use the labelled physical-device matrix in [phone-chrome-physical-acceptance.md](phone-chrome-physical-acceptance.md). +24. **A page fills the box it is in; it never subtracts a chrome estimate from `100dvh`.** + At `sm`+ the shell's `#main-content` grows into `.phone-viewport-frame` (`sm:grow`), the + `mobile-composer-reserve-pad` inside it is the fill box (`sm:flex sm:min-h-full sm:flex-col`), + and page shells grow into that pad (`sm:grow`). The dashboard mirrors this: its content + wrapper is `sm:flex sm:min-h-full sm:flex-col` and the mode-home canvas is `sm:grow sm:shrink-0`. + Do not reintroduce a `min-h-[calc(100dvh - )]` page floor. Three things such an + estimate cannot know, each measured as real dead scroll before this contract landed: + `--shell-header-h` (4rem) covers the header's inner bar plus `pb-2` but **not** its own + `pt-[max(0.5rem,var(--safe-area-top))]` (8px on every route); the `header-collapse-addon` nav row + on topic routes adds 49px more; and `#main-content`'s own `sm:pb-8` adds 32px. Pages whose + content had already ended carried 8-273px of scroll range as a result — a scrollbar on a page + that fits, and a wheel notch that jolts into the bottom stop. Phone floors are unaffected: + below `sm` the document owns scrolling and there is no bounded box to fill. Guarded by the + "pages that fit the window have no scroll range" cases in `tests/ui-chrome-scroll.spec.ts`. The PWA notice rules that use `:has(#main-content ...)` are a deliberately bounded post-hydration exception. `#main-content` can disappear briefly while diff --git a/src/app/globals.css b/src/app/globals.css index 8a05fe9424..da38a54136 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -552,9 +552,18 @@ nav subtracts it from its gutter and adds it back to its max-width so the ink — not the box — lands on the header's content edge at every width. */ --mode-nav-ink-offset: 1rem; - /* Borderless app-shell/header height (the min-h-14 bar). Page shells fill the - viewport below it via calc(100dvh - var(--shell-header-h)); one token so the - header height and those page-fill floors cannot silently drift apart. */ + /* Borderless app-shell/header height: the `min-h-14` bar plus its `pb-2`, and + NOT the bar's own `pt-[max(0.5rem, var(--safe-area-top))]`. The header's + in-flow box is therefore taller than this token by that top pad. + + Page shells no longer fill with `calc(100dvh - var(--shell-header-h))` for + exactly that reason: the estimate was 8px short on every route, a further + 49px short wherever the `header-collapse-addon` nav row mounts, and blind to + `#main-content`'s own `sm:pb-8` — so pages whose content had ended still + carried scroll range. They grow into their box instead (invariant 24 in + docs/search-chrome-behaviour.md). Keep this token for chrome offsets and for + `--phone-overlay-chrome-h` below, which adds the top pad back explicitly; + do not resurrect it as a page-fill floor. */ --shell-header-h: 4rem; /* Server-stable default for the phone overlay header's content clearance. `usePhoneOverlayChromeReserve` refines this to the measured stack height in diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index b6024f3bec..81c2e4acb4 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3486,7 +3486,16 @@ function ClinicalDashboardContent({ // overflow-x-CLIP, not -hidden: hidden makes this wrapper a scroll // container (overflow-y computes to auto), which clips the composer's // command dropdown mid-panel and shows a phantom inner scrollbar. - "mx-auto max-w-7xl space-y-4 overflow-x-clip px-3 py-4 sm:space-y-5 sm:px-4 sm:py-5 lg:px-8", + // + // `sm:flex sm:min-h-full sm:flex-col` makes this the box the mode-home + // canvas grows into. `#main-content` is a bounded scrollport with a + // definite height at `sm`+, so `min-h-full` resolves against it exactly + // — border-box, so this wrapper's own padding is inside the 100% and + // cannot push the column past the scrollport. That is what lets the + // canvas drop its `calc(100dvh - )` floor (see + // mode-home-canvas.ts) instead of guessing this padding, the desktop + // composer slot and the space-y gap in one hard-coded number. + "mx-auto max-w-7xl space-y-4 overflow-x-clip px-3 py-4 sm:flex sm:min-h-full sm:flex-col sm:space-y-5 sm:px-4 sm:py-5 lg:px-8", // Idle phone homes fill the already-padded
and centre // in that box. Extra py/space-y here double-counted overlay // chrome and manufactured a scrollbar. diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 16ffcfd282..514cb2db13 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -1593,7 +1593,7 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: return (
@@ -1637,11 +1637,14 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?: return (
)` floor. This + // element is already a flex child of `.phone-viewport-frame` + // (`flex flex-col sm:min-h-dvh`), so growing into the frame's free + // space ends it exactly at the viewport bottom whatever chrome sits + // above it. A subtracted estimate cannot: `--shell-header-h` (4rem) + // covers the header's inner bar plus `pb-2` but NOT its own + // `pt-[max(0.5rem,var(--safe-area-top))]`, so the old floor + // overshot by 8px on every route — and by 57px on routes that also + // mount the `header-collapse-addon` nav row, whose height no static + // token knows. Both left a permanent sliver of scroll on pages with + // nothing to scroll. Growth is exact and cannot drift again. + // Default `flex-shrink` is safe here: `min-height: auto` on a flex + // item stops it compressing below its content, so tall pages still + // extend the frame and scroll the document as before. + "phone-scroll-surface min-w-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)] max-sm:flex-1 sm:grow sm:overflow-x-clip", // sm+: static desktop clearance; use var(--safe-area-bottom) so tests // can simulate insets without depending on env() in Chromium. !reservesFloatingComposer @@ -1048,10 +1063,19 @@ function GlobalStandaloneSearchShellBody({ padding on the scrollport itself is omitted from scrollHeight in some flex/overflow combinations. The inner block box includes padding in its height, so end-of-page content clears the visible dock. + + At sm+ this pad is also the box page shells fill. `min-h-full` + resolves against #main-content — definite now that it grows into the + frame — and is border-box, so #main-content's own `sm:pb-8` stays + outside the 100%. Page shells therefore ask for `sm:grow` instead of + `calc(100dvh - var(--shell-header-h))`: that estimate knew neither the + header's top pad, nor the nav row on addon routes, nor this + scrollport's bottom padding, and over-reserved by 40-273px on a tall + window — scroll range on pages whose content had already ended. */}
{shouldShowSearchComposer && !isStandaloneModeHome && !isDictionaryCatalogue ? ( )`. The wrapper is `sm:flex sm:min-h-full + * sm:flex-col` (ClinicalDashboard), and `min-h-full` there resolves against + * `#main-content` — a bounded scrollport with a definite height — so the + * remaining space is exact. The old `calc(100dvh-11rem)` floor had to guess the + * header block, the wrapper's own `py`/`pb`, the desktop composer slot and the + * `space-y` gap in one number; it was 46px short at `lg` and 38px short at `sm`, + * which put a permanent scroll range on every mode home that had nothing to + * scroll. `grow` + `shrink-0` mirrors the phone treatment below: grow into free + * space, never compress, so a tall page still scrolls normally. */ export function resolveModeHomeCanvasClass({ activeModeResultKind, @@ -24,12 +35,14 @@ export function resolveModeHomeCanvasClass({ return cn( compactMobileModeHome ? cn( - "max-sm:flex max-sm:grow max-sm:shrink-0 max-sm:flex-col sm:min-h-[calc(100dvh-11rem)]", + "max-sm:flex max-sm:grow max-sm:shrink-0 max-sm:flex-col sm:grow sm:shrink-0", centeredModeHome && "max-sm:items-center max-sm:justify-center", ) : activeModeResultKind === "answer" && hasAnswer - ? "sm:min-h-[calc(100dvh-11rem)]" - : "min-h-[calc(100dvh-12.5rem)] sm:min-h-[calc(100dvh-11rem)]", + ? "sm:grow sm:shrink-0" + : // The phone floor stays a viewport calc: below `sm` the document owns + // scrolling and this canvas has no bounded scrollport to fill. + "min-h-[calc(100dvh-12.5rem)] sm:grow sm:shrink-0", centeredModeHome || showSharedHome ? compactMobileModeHome ? "w-full sm:grid sm:place-items-center" diff --git a/src/components/differentials/differential-compare-queue-page.tsx b/src/components/differentials/differential-compare-queue-page.tsx index 61559a568a..2b02eb4da7 100644 --- a/src/components/differentials/differential-compare-queue-page.tsx +++ b/src/components/differentials/differential-compare-queue-page.tsx @@ -35,7 +35,7 @@ export function DifferentialCompareQueuePage({ return (
@@ -88,7 +88,7 @@ export function DifferentialCompareQueuePage({ return (
diff --git a/src/components/differentials/differential-presentation-workflow-page.tsx b/src/components/differentials/differential-presentation-workflow-page.tsx index e34026844c..89b6cd9aec 100644 --- a/src/components/differentials/differential-presentation-workflow-page.tsx +++ b/src/components/differentials/differential-presentation-workflow-page.tsx @@ -671,7 +671,7 @@ export function DifferentialPresentationWorkflowPage({ />
diff --git a/src/components/differentials/differential-stream-workspace.tsx b/src/components/differentials/differential-stream-workspace.tsx index 08e97efb1e..24e0480688 100644 --- a/src/components/differentials/differential-stream-workspace.tsx +++ b/src/components/differentials/differential-stream-workspace.tsx @@ -682,7 +682,7 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }: return (
diff --git a/src/components/information-page-shell.tsx b/src/components/information-page-shell.tsx index 5bc8d32159..ccad2d319f 100644 --- a/src/components/information-page-shell.tsx +++ b/src/components/information-page-shell.tsx @@ -22,10 +22,9 @@ import { cn, pageContainer } from "@/components/ui-primitives"; export type InformationPageWidth = "default" | "narrow" | "bleed"; const shellPadding = - "max-sm:min-h-0 bg-[color:var(--background)] px-3 py-4 pb-4 text-[color:var(--text)] sm:min-h-[calc(100dvh-var(--shell-header-h))] sm:px-5 sm:py-6 sm:pb-10 lg:px-7"; + "max-sm:min-h-0 bg-[color:var(--background)] px-3 py-4 pb-4 text-[color:var(--text)] sm:grow sm:px-5 sm:py-6 sm:pb-10 lg:px-7"; -const bleedPadding = - "max-sm:min-h-0 bg-[color:var(--background)] text-[color:var(--text)] sm:min-h-[calc(100dvh-var(--shell-header-h))]"; +const bleedPadding = "max-sm:min-h-0 bg-[color:var(--background)] text-[color:var(--text)] sm:grow"; export function InformationPageShell({ children, diff --git a/src/components/mode-home-template.tsx b/src/components/mode-home-template.tsx index 07a32f2024..429231d0a4 100644 --- a/src/components/mode-home-template.tsx +++ b/src/components/mode-home-template.tsx @@ -197,7 +197,7 @@ export function ModeHomeMain({
-
+
{informationPage ? ( {children} ) : ( diff --git a/src/components/ui-primitives.tsx b/src/components/ui-primitives.tsx index 1f5137aef1..5794266f1f 100644 --- a/src/components/ui-primitives.tsx +++ b/src/components/ui-primitives.tsx @@ -220,14 +220,13 @@ export const searchPageCanvas = "bg-[color:var(--background)] text-[color:var(-- // Phone bottom-dock clearance lives on #main-content / dashboard
via // --mobile-composer-reserve so it can collapse when the dock hides. Do not bake // a second dock-sized safe-area pad into page shells. -export const searchPageShell = - "min-h-0 overflow-x-clip px-3 py-3 pb-4 sm:min-h-[calc(100dvh-var(--shell-header-h))] sm:px-5 sm:py-5 sm:pb-8 lg:px-6"; +export const searchPageShell = "min-h-0 overflow-x-clip px-3 py-3 pb-4 sm:grow sm:px-5 sm:py-5 sm:pb-8 lg:px-6"; // Standalone pages outside the search shell own the OS top inset themselves // (apple-mobile-web-app-status-bar-style=black-translucent). Bake max(safe-area) // into the top pad and omit py-* so cn() call sites never rely on Tailwind's // side-vs-axis utility sort order to win over searchPageShell's py-3/sm:py-5. export const searchPageShellStandalone = - "min-h-0 overflow-x-clip px-3 pt-[max(0.75rem,var(--safe-area-top))] pb-4 sm:min-h-[calc(100dvh-var(--shell-header-h))] sm:px-5 sm:pt-[max(1.25rem,var(--safe-area-top))] sm:pb-8 lg:px-6"; + "min-h-0 overflow-x-clip px-3 pt-[max(0.75rem,var(--safe-area-top))] pb-4 sm:grow sm:px-5 sm:pt-[max(1.25rem,var(--safe-area-top))] sm:pb-8 lg:px-6"; export const searchPageContainer = "mx-auto w-full max-w-[1500px]"; // Canonical content-page width. Detail pages (service / form / differential), // medication record + prescribing workspace, and the forms results view converge diff --git a/tests/mobile-interaction-regressions.test.ts b/tests/mobile-interaction-regressions.test.ts index 50250bbe09..e2d927734a 100644 --- a/tests/mobile-interaction-regressions.test.ts +++ b/tests/mobile-interaction-regressions.test.ts @@ -27,15 +27,19 @@ describe("mobile interaction regressions", () => { const favouritesSource = source("src/components/clinical-dashboard/favourites-command-library-page.tsx"); const differentialsHomeSource = source("src/components/clinical-dashboard/differentials-home.tsx"); + // `sm:grow`, not `sm:min-h-[calc(100dvh-…)]`: these surfaces fill the shell's + // reserve pad by growing into it. The viewport estimate over-reserved on + // every route and left dead scroll on pages with nothing left to show. expect(presentationSource).toMatch( - /data-testid="differential-presentation-page"\s+className="[^"]*min-h-0[^"]*overflow-x-clip[^"]*sm:min-h-\[calc\(100dvh-var\(--shell-header-h\)\)\]/, + /data-testid="differential-presentation-page"\s+className="[^"]*min-h-0[^"]*overflow-x-clip[^"]*sm:grow/, ); + expect(presentationSource).not.toContain("sm:min-h-[calc(100dvh-var(--shell-header-h))]"); expect(favouritesSource).toMatch( - /data-testid="favourites-hub"\s+className="[^"]*min-h-0[^"]*overflow-x-clip[^"]*sm:min-h-\[calc\(100dvh-var\(--shell-header-h\)\)\]/, - ); - expect(favouritesSource).toContain( - '"grid min-h-0 min-w-0 overflow-x-clip sm:min-h-[calc(100dvh-var(--shell-header-h))]"', + /data-testid="favourites-hub"\s+className="[^"]*min-h-0[^"]*overflow-x-clip[^"]*sm:grow/, ); + // The xl split rail still reaches the bottom, now against the grown hub. + expect(favouritesSource).toContain('"grid min-h-0 min-w-0 overflow-x-clip sm:min-h-full"'); + expect(favouritesSource).not.toContain("sm:min-h-[calc(100dvh-var(--shell-header-h))]"); // overflow-x-hidden would force overflow-y:auto and nest a scrollport under #main-content. expect(differentialsHomeSource).toMatch( /data-testid="differentials-search-results"[\s\S]*?className="[^"]*overflow-x-clip[^"]*"/, diff --git a/tests/ui-chrome-scroll.spec.ts b/tests/ui-chrome-scroll.spec.ts index 8a155a0cf8..7c41f43625 100644 --- a/tests/ui-chrome-scroll.spec.ts +++ b/tests/ui-chrome-scroll.spec.ts @@ -274,3 +274,47 @@ for (const { name: sizeName, viewport } of breakpoints) { }); } } + +/** + * Dead scroll: a scroll range on a page whose content has already ended. + * + * Page-fill floors used to be written as `calc(100dvh - )`. + * Every estimate was short — `--shell-header-h` (4rem) omits the header's own + * `pt-[max(0.5rem,var(--safe-area-top))]`, nothing knew about the + * `header-collapse-addon` nav row on topic routes, and nothing knew about + * `#main-content`'s own `sm:pb-8`. The result was a permanent 8-273px of scroll + * on pages with nothing left to show: a scrollbar on a page that fits, and a + * wheel notch that jolts the page and slams into the bottom. + * + * These surfaces now grow into the box above them instead, so the range must be + * exactly zero. The viewport is deliberately tall enough that every one of these + * routes fits; a route whose content genuinely exceeds it belongs in the + * scrolling suites above, not here. + */ +test.describe("pages that fit the window have no scroll range", () => { + const fitsWithoutScrolling = [ + { name: "shared home", route: "/" }, + { name: "dashboard mode home", route: "/?mode=documents" }, + { name: "standalone mode home", route: "/medications" }, + { name: "addon nav row route", route: "/factsheets/topics" }, + ]; + + for (const { name, route } of fitsWithoutScrolling) { + test(`desktop: ${name} has zero scroll range`, async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 1200 }); + await page.goto(route, { waitUntil: "domcontentloaded" }); + await expect(page.locator("header#search").first()).toBeVisible({ timeout: 15_000 }); + // Late chrome (composer portal, nav row, notices) mounts after first paint + // and is exactly what a static estimate would miss, so settle before + // reading — then read a second time. A single early read could catch the + // page before the nav row lands and pass on a range that is about to grow. + await page.waitForTimeout(800); + const settled = await readPrimaryScrollGeometry(page); + expect(settled.maxScrollTop, `${route} reserves ${settled.maxScrollTop}px of scroll past its content`).toBe(0); + + await page.waitForTimeout(400); + const stable = await readPrimaryScrollGeometry(page); + expect(stable.maxScrollTop, `${route} grew a scroll range after late chrome mounted`).toBe(0); + }); + } +}); diff --git a/tests/ui-overlay-css-contract.test.ts b/tests/ui-overlay-css-contract.test.ts index f8ad134c90..dc5e54063f 100644 --- a/tests/ui-overlay-css-contract.test.ts +++ b/tests/ui-overlay-css-contract.test.ts @@ -223,9 +223,18 @@ describe("overlay and global CSS contracts", () => { expect(differentialPresentationSource).not.toContain('className="fixed inset-x-0 bottom-0'); expect(globalSearchShellSource).toContain("phone-viewport-shell"); expect(clinicalDashboardSource).toContain("phone-viewport-shell"); - expect(uiPrimitivesSource).toContain('"min-h-0 overflow-x-clip px-3 py-3 pb-4 sm:min-h-['); + // Page shells fill the shell's `mobile-composer-reserve-pad` box by growing + // into it, never by claiming `calc(100dvh - )`. That + // estimate could not know the header's own top pad, the addon nav row, or + // #main-content's bottom padding, so it left 40-273px of scroll range on + // pages whose content had already ended. Growth is exact; keep it that way. + expect(uiPrimitivesSource).toContain('"min-h-0 overflow-x-clip px-3 py-3 pb-4 sm:grow'); + expect(uiPrimitivesSource).not.toContain("sm:min-h-[calc(100dvh-var(--shell-header-h))]"); expect(therapyWorkspaceSource).toContain("data-therapy-root"); expect(therapyWorkspaceSource).toContain("min-h-0"); - expect(therapyWorkspaceSource).toContain("sm:min-h-[calc(100dvh-var(--shell-header-h))]"); + expect(therapyWorkspaceSource).toContain("sm:grow"); + expect(therapyWorkspaceSource).not.toContain("sm:min-h-[calc(100dvh-var(--shell-header-h))]"); + // The pad is the fill box those shells grow inside. + expect(globalSearchShellSource).toContain("sm:flex sm:min-h-full sm:flex-col"); }); });