From c56e5f52ed28f6c7e039049dc1e9aa3000413a8b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 10:02:56 +0000 Subject: [PATCH 1/4] 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"); }); }); From b76dad1fdf20f4841619d7e77ef1a68095e90aa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 10:35:01 +0000 Subject: [PATCH 2/4] fix(ui): unstick the safety-findings sheet, and settle the stopped-answer state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three phone defects reported from device screenshots. **The safety-findings sheet could not be scrolled; the page behind moved instead.** Two independent causes, both fixed: 1. The sheet body was given `flex flex-col`, which turned its single child — the findings card — into a shrinkable flex item. The card was compressed from its natural height to whatever was left, and because it clips its own overflow the findings past the fold were cut off rather than scrolled. The body then had no scroll range at all, so the gesture fell through to the page. Measured on a 390x400 phone: body scroll range 0px before, 230px after. The card also now carries `shrink-0` so no flex parent can clip it again. 2. The shared sheet scroll lock set `overflow: hidden` on ``, which does nothing here. `globals.css` sets `html { overflow-x: clip }`, and a root whose overflow is not `visible` stops body's overflow propagating to the viewport — so every sheet on every phone page left the document freely scrollable behind it. Measured with the sheet open: 272px of live range behind it, and one wheel gesture over the sheet ran the page to its bottom. The lock now also holds `overflow-y` on the root, and the sheet body carries `overscroll-contain` so a gesture reaching its end cannot chain out. **"Generation stopped" floated in the middle of the screen.** It is a status notice about the last action, not a description of the page, but it rendered inside the mode-home canvas where it was centred as one group with the shared home hero. It now renders with the other top-of-content notices, directly under the header. The condition stays on the empty-state chain so a stopped generation still short-circuits the no-results and error states. **The stopped state also kept a loading skeleton forever.** `submittedAnswerSearchActive` stays true after Stop and a cancel is not an `error`, so the pending branch shimmered on indefinitely underneath the notice saying the answer had been abandoned. Cancelled now clears pending. **More room at the bottom of the answer page.** `pb-4` was the smallest tail in the app and left the last card almost on the bottom edge once the composer dock scroll-hides and its reserve releases to zero. Phone answer results now use `pb-10`, matching the `sm:pb-10` every other mode wrapper already uses, and still far below the old padding that floated a long answer above the dock. Verified in Chromium on a 390px phone: typecheck and lint clean, and `test:focused` over the five changed files passed 1526 tests in 129 files. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Bbm7tQoyg6etyf7NqoA65d --- src/components/ClinicalDashboard.tsx | 72 +++++++++++++------ .../answer-result-surface.tsx | 9 ++- .../clinical-dashboard/evidence-panels.tsx | 5 +- src/components/ui/sheet-focus.ts | 14 ++++ src/components/ui/sheet.tsx | 8 ++- 5 files changed, 83 insertions(+), 25 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 81c2e4acb4..a601a4d6c7 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3007,8 +3007,19 @@ function ClinicalDashboardContent({ useEffect(() => { if (showSharedHome) document.title = sharedHomeDocumentTitle(searchMode); }, [searchMode, showSharedHome]); + // A stopped generation reports on the last action rather than describing the + // page, so the notice renders at the top of the content column while this same + // condition still short-circuits the mode-home empty-state chain below. + const showAnswerCancelledNotice = answerLifecycle.status === "cancelled" && activeModeResultKind === "answer"; + // `submittedAnswerSearchActive` stays true after the reader presses Stop, and a + // cancel is not an `error`, so without the cancelled guard the pending branch + // held its skeleton on screen indefinitely — a shimmering placeholder promising + // an answer that was already abandoned, directly beneath the notice saying so. const showAnswerPending = - activeModeResultKind === "answer" && !answer && (loading || (submittedAnswerSearchActive && !error)); + activeModeResultKind === "answer" && + !answer && + !showAnswerCancelledNotice && + (loading || (submittedAnswerSearchActive && !error)); const answerProgressCompleted = answerProgressEvents.at(-1)?.stage === "complete"; const showAnswerProgress = activeModeResultKind === "answer" && @@ -3511,9 +3522,13 @@ function ClinicalDashboardContent({ : // The
reserve already clears the fixed composer dock on // phones, so the old large mobile bottom padding only floated a // long answer's last line high above the dock (and padded a short - // answer's empty space further). Keep it small here; sm+/desktop + // answer's empty space further). This stays far below that, but + // `pb-4` was the smallest tail in the app and left the last card + // sitting almost on the bottom edge once the dock scroll-hides + // and its reserve releases to zero. `pb-10` matches the + // `sm:pb-10` every other mode wrapper already uses. sm+/desktop // keep the original generous padding. - "pb-4 sm:pb-36 lg:pb-40" + "pb-10 sm:pb-36 lg:pb-40" : hasMobileBottomSearch ? compactMobileModeHome ? "sm:pb-10 lg:pb-12" @@ -3522,6 +3537,30 @@ function ClinicalDashboardContent({ )} > + {/* "Generation stopped" is a status notice about the last action, not a + page state, so it belongs with the other top-of-content notices rather + than inside the mode-home canvas. In the canvas it was centred as one + group with the `SharedHomeEmptyState` hero, which on a phone left it + floating in the middle of the screen under a tall empty gap. */} + {showAnswerCancelledNotice ? ( + void ask(answerLifecycle.query ?? query)} + > +