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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/search-chrome-behaviour.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 - <chrome estimate>)]` 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
Expand Down
15 changes: 12 additions & 3 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 - <estimate>)` 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 <main> and centre
// in that box. Extra py/space-y here double-counted overlay
// chrome and manufactured a scrollbar.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1593,7 +1593,7 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?:
return (
<main
data-testid="favourites-hub"
className="min-h-0 overflow-x-clip bg-[color:var(--background)] pb-4 text-[color:var(--text)] sm:min-h-[calc(100dvh-var(--shell-header-h))] sm:pb-32 md:pb-0"
className="min-h-0 overflow-x-clip bg-[color:var(--background)] pb-4 text-[color:var(--text)] sm:grow sm:pb-32 md:pb-0"
>
<div className="mx-auto grid min-w-0 max-w-[40rem] gap-4 px-4 py-8 sm:px-6">
<header data-testid="favourites-command-library" className="flex min-w-0 flex-wrap items-baseline gap-x-3">
Expand Down Expand Up @@ -1637,11 +1637,14 @@ export function FavouritesCommandLibraryPage({ query = "", demoMode }: { query?:
return (
<main
data-testid="favourites-hub"
className="min-h-0 overflow-x-clip bg-[color:var(--background)] pb-4 text-[color:var(--text)] sm:min-h-[calc(100dvh-var(--shell-header-h))] sm:pb-32 md:pb-0"
className="min-h-0 overflow-x-clip bg-[color:var(--background)] pb-4 text-[color:var(--text)] sm:grow sm:pb-32 md:pb-0"
>
<div
className={cn(
"grid min-h-0 min-w-0 overflow-x-clip sm:min-h-[calc(100dvh-var(--shell-header-h))]",
// min-h-full, not a viewport calc: the hub above grows to the shell's
// fill box, so 100% of it is the real remaining height and the xl
// split rail still reaches the bottom edge.
"grid min-h-0 min-w-0 overflow-x-clip sm:min-h-full",
// The left library rail is gone — sets, quick views and types are one
// chip rail now (ledger #164), so the workspace is a single column
// that only splits when the item workspace opens.
Expand Down
28 changes: 26 additions & 2 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1031,7 +1031,22 @@ function GlobalStandaloneSearchShellBody({
// overflow-y: visible into an element scroller. Standalone mode
// overrides this semantic surface to the bounded app scrollport.
// sm+ keeps document ownership for sticky page descendants.
"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:min-h-[calc(100dvh-var(--shell-header-h))] sm:overflow-x-clip",
//
// `sm:grow`, not a `calc(100dvh - <chrome estimate>)` 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
Expand All @@ -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.
*/}
<div
data-testid="mobile-composer-reserve-pad"
className="max-sm:pt-[var(--phone-overlay-chrome-h)] max-sm:pb-[var(--mobile-composer-reserve)]"
className="max-sm:pt-[var(--phone-overlay-chrome-h)] max-sm:pb-[var(--mobile-composer-reserve)] sm:flex sm:min-h-full sm:flex-col"
>
{shouldShowSearchComposer && !isStandaloneModeHome && !isDictionaryCatalogue ? (
<DesktopComposerPortalSlot
Expand Down
19 changes: 16 additions & 3 deletions src/components/clinical-dashboard/mode-home-canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ type ModeHomeCanvasClassInput = {
* Keeps the shared dashboard's phone-home geometry outside the render monolith.
* The flex column fills the already-padded main pane, while only centred homes
* gain flex centring. Registry homes retain their previous top alignment.
*
* At `sm` and above the canvas grows into the content wrapper rather than
* claiming `calc(100dvh - <estimate>)`. 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,
Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function DifferentialCompareQueuePage({
return (
<main
data-testid="differential-compare-empty"
className="min-h-[calc(100dvh-var(--shell-header-h))] bg-[color:var(--background)] px-4 py-10 text-[color:var(--text)] sm:px-6 lg:px-8"
className="min-h-[calc(100dvh-var(--shell-header-h))] bg-[color:var(--background)] px-4 py-10 text-[color:var(--text)] sm:min-h-0 sm:grow sm:px-6 lg:px-8"
>
<div className="mx-auto grid w-full max-w-4xl gap-6">
<section className="overflow-hidden rounded-2xl border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] shadow-[var(--e2)]">
Expand Down Expand Up @@ -88,7 +88,7 @@ export function DifferentialCompareQueuePage({
return (
<main
data-testid="differential-compare-queue"
className="min-h-[calc(100dvh-var(--shell-header-h))] bg-[color:var(--background)] px-4 py-8 text-[color:var(--text)] sm:px-6 sm:py-10 lg:px-8"
className="min-h-[calc(100dvh-var(--shell-header-h))] bg-[color:var(--background)] px-4 py-8 text-[color:var(--text)] sm:min-h-0 sm:grow sm:px-6 sm:py-10 lg:px-8"
>
<div className="mx-auto grid w-full max-w-4xl gap-5 sm:gap-6">
<section className="overflow-hidden rounded-2xl border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] shadow-[var(--e2)]">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,7 @@ export function DifferentialPresentationWorkflowPage({
/>
<main
data-testid="differential-presentation-page"
className="min-h-0 overflow-x-clip bg-[color:var(--background)] px-3 pb-[calc(6.25rem+env(safe-area-inset-bottom))] pt-4 text-[color:var(--text)] sm:min-h-[calc(100dvh-var(--shell-header-h))] sm:px-5 md:pb-8 xl:px-7 xl:pt-6"
className="min-h-0 overflow-x-clip bg-[color:var(--background)] px-3 pb-[calc(6.25rem+env(safe-area-inset-bottom))] pt-4 text-[color:var(--text)] sm:grow sm:px-5 md:pb-8 xl:px-7 xl:pt-6"
>
<div className="mx-auto grid w-full max-w-[94rem] gap-5 xl:grid-cols-[minmax(0,1fr)_23.5rem]">
<div className="min-w-0">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -682,7 +682,7 @@ export function DifferentialStreamWorkspace({ model, query, initialFocus = "" }:
return (
<main
data-testid="differentials-stream-workspace"
className="min-h-0 overflow-x-clip bg-[color:var(--background)] px-4 py-5 text-[color:var(--text)] sm:min-h-[calc(100dvh-var(--shell-header-h))] sm:px-6 sm:py-6 lg:px-8"
className="min-h-0 overflow-x-clip bg-[color:var(--background)] px-4 py-5 text-[color:var(--text)] sm:grow sm:px-6 sm:py-6 lg:px-8"
>
<div className="mx-auto grid w-full max-w-6xl gap-4">
<header data-testid="differentials-stream-header" className="grid gap-1 px-1">
Expand Down
5 changes: 2 additions & 3 deletions src/components/information-page-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/components/mode-home-template.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export function ModeHomeMain({
<main
data-testid={testId}
className={cn(
"flex min-h-0 w-full flex-1 flex-col items-center bg-[color:var(--background)] px-0 pb-4 text-[color:var(--text)] sm:min-h-[calc(100dvh-var(--shell-header-h))] sm:px-6 sm:pb-[clamp(1.75rem,5vh,3.25rem)] lg:px-8",
"flex min-h-0 w-full flex-1 flex-col items-center bg-[color:var(--background)] px-0 pb-4 text-[color:var(--text)] sm:grow sm:px-6 sm:pb-[clamp(1.75rem,5vh,3.25rem)] lg:px-8",
withoutJustifyUtilities(className),
MODE_HOME_MAIN_ALIGN_CLASS[contentAlign],
)}
Expand Down
5 changes: 1 addition & 4 deletions src/components/therapy-compass/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,7 @@ export function TherapyCompassWorkspace({ children }: { children: ReactNode }) {

return (
<TcProvider>
<div
data-therapy-root
className="min-h-0 bg-[color:var(--background)] text-[color:var(--text)] sm:min-h-[calc(100dvh-var(--shell-header-h))]"
>
<div data-therapy-root className="min-h-0 bg-[color:var(--background)] text-[color:var(--text)] sm:grow">
{informationPage ? (
<TherapyCompassInformationRoute>{children}</TherapyCompassInformationRoute>
) : (
Expand Down
5 changes: 2 additions & 3 deletions src/components/ui-primitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,14 +220,13 @@ export const searchPageCanvas = "bg-[color:var(--background)] text-[color:var(--
// Phone bottom-dock clearance lives on #main-content / dashboard <main> 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
Expand Down
Loading
Loading