Skip to content
Merged
6 changes: 4 additions & 2 deletions docs/search-chrome-behaviour.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ This repo uses one shared search experience across the global shell, dashboard r
3. A visible fixed phone dock may include `var(--safe-area-bottom)` so the pill clears the home indicator.
4. A hidden phone dock must release the content-facing reserve to `0rem`; do not use `env(safe-area-inset-bottom)` or `var(--safe-area-bottom)` for hidden content padding.
5. Edge-to-edge phone dock mode is `left: 0; right: 0; bottom: 0; width: 100%`; inset the pill with padding, not with a non-zero bottom offset.
6. Header and footer chrome that share the same scroll signal should hide/reveal symmetrically: when hidden, underlying content must be visible to the viewport edge. Header hide/reveal is cross-breakpoint; the bottom search dock is phone-only. Read "Scroll hide/reveal" below before changing either.
6. Header and footer chrome that share the same scroll signal should hide/reveal symmetrically: when the chrome controls are hidden, underlying content may reclaim that chrome height. The OS top safe-area band (`chrome-safe-area-top` / `var(--safe-area-top)`) is never released — scrolled text must not paint under the status-bar icons. Hidden bottom dock reserve stays `0rem` (invariant 4). Header hide/reveal is cross-breakpoint; the bottom search dock is phone-only. Read "Scroll hide/reveal" below before changing either.
7. Do not add page-local dock-sized `pb-[calc(...safe-area...)]` under a shell-owned dock. Put clearance in the shared reserve or the page-owned composer, never both.
8. `GlobalSearchShell` uses an inner `mobile-composer-reserve-pad` so phone padding contributes to scroll height; do not move phone shell clearance back to scrollport padding without a browser proof.
9. Keep collapse-budget policy geometry-aware: an in-flow collapsing header needs enough remaining runway to absorb header + dock clearance, while a fixed overlay that only releases bottom reserve may hide when its post-collapse range retains the top reveal band plus deliberate hide intent _and_ the current offset already fits that post-collapse range (no material near-bottom clamp). Do not use synthetic page padding to make the stricter gate pass.
Expand All @@ -41,9 +41,11 @@ Rules that keep this working:

- **Feed the reporter from the element that actually scrolls.** `GlobalSearchShell`'s `#main-content` is the scrollport only on phones, so above that it also runs `useDocumentScrollHideReporter`. That hook self-gates: the phone shell is `fixed inset-0`, so the document cannot scroll and never fires.
- **Do not release flow space out of a document-scrolled page.** Collapsing the header row while the document scrolls pulls the whole page up by the header height mid-scroll. Stick and translate instead; `readChromeCollapseBudget` therefore charges the header's height against the scroll runway only while the wrapper is a grid at the current width.
- **Sticky belongs on the collapse wrapper, not on `header#search`.** The header sits inside two header-height boxes, which leaves a sticky rule on it zero travel — that is what made the desktop bar scroll away and only return at the top of the page. For the same reason the wrapper's ancestor in `GlobalSearchShell` is `display: contents` above the phone breakpoint rather than a block.
- **Sticky belongs on the collapse wrapper, not on `header#search`.** The header sits inside two header-height boxes, which leaves a sticky rule on it zero travel — that is what made the desktop bar scroll away and only return at the top of the page. For the same reason the wrapper's ancestor in `GlobalSearchShell` is `display: contents` above the phone breakpoint rather than a block, and the collapse path returns a fragment (safe-area spacer + collapse wrapper) rather than a single root box.
- **Keep `chrome-safe-area-top` outside the hide mechanism.** Collapse hosts render an always-on `h-[var(--safe-area-top)]` spacer sibling; the 0fr grid / `-translate-y-full` only moves the controls. Sticky chrome pins at `top: var(--safe-area-top)` so the spacer stays put while the bar slides away.
- **Transform only while hidden.** A standing transform on the wrapper would become the containing block for the fixed-position menus and composers rendered inside it.
- **Rebase the reporter on geometry switches.** Pass `resetKey` when the host changes the scrollport under it (`ClinicalDashboard` passes `searchMode`, which swaps `<main>`'s header reserve); otherwise the carried-over offset spends the first post-switch scroll on a spurious hide or reveal.
- **Do not carry composer focus into submitted result views.** Focus pins both chrome edges for keyboard safety. `GlobalSearchShell` must not pass `focus: true` with `run: true`, must gate `queryInputAutoFocus` on `!hasSubmittedModeSearch`, and must blur the dock input when the result canvas scrolls so hide-on-scroll can reclaim the header and the bottom dock (including its white safe-area rail).

Coverage: `tests/header-scroll-hide-contract.test.ts` (wiring), `tests/use-hide-on-scroll.test.ts` (decision logic), `tests/ui-chrome-scroll.spec.ts` (tablet/desktop hide and reveal), `tests/ui-phone-scroll.spec.ts` (phone scroll geometry).

Expand Down
9 changes: 6 additions & 3 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1845,18 +1845,21 @@ summary::-webkit-details-marker {
}

/* Must beat the edge-to-edge dock rule above (transform: none) so scroll-hide
actually slides the bar off-screen once data-scroll-hidden is set. */
actually slides the bar off-screen once data-scroll-hidden is set.
Use --safe-area-bottom (not env() alone) so Playwright/PWA inset simulation
and the visible dock's padding-bottom clear the same distance — otherwise
a white safe-area rail can peep after hide on simulated/notched phones. */
.answer-footer-search-dock.answer-footer-search-edge[data-scroll-hidden="true"],
.answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge[data-scroll-hidden="true"],
.answer-footer-search-dock.dashboard-composer-edge.answer-footer-search-edge[data-scroll-hidden="true"] {
transform: translateY(calc(100% + env(safe-area-inset-bottom)));
transform: translateY(calc(100% + var(--safe-area-bottom)));
pointer-events: none;
}

/* Compare addon adds in-flow chrome above the pill; clear a little extra so
the Compare bar does not leave a 1px/subpixel peep on the viewport edge. */
.answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge[data-footer-addon="differentials-compare"][data-scroll-hidden="true"] {
transform: translateY(calc(100% + 0.75rem + env(safe-area-inset-bottom)));
transform: translateY(calc(100% + 0.75rem + var(--safe-area-bottom)));
}

/* Document viewer owns its floating composer (not the shared dock). Lift it
Expand Down
30 changes: 25 additions & 5 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,15 @@ function GlobalStandaloneSearchShellClient({
}

useEffect(() => {
// Submitted result views must not keep the dock focused. Composer focus
// pins both chrome edges (keyboard safety), which is what left Forms /
// services search stuck with a visible header + bottom white rail while
// scrolling results. Match ClinicalDashboard: focus only the empty/home
// composer, and blur once a run=1 result view is showing.
if (hasSubmittedModeSearch) {
if (document.activeElement === inputRef.current) inputRef.current?.blur();
return undefined;
}
if (!requestedFocus) return undefined;
const focusInput = () => {
// The focus=1 hydration retry (rAF + 300ms) can land after a user/test opens
Expand All @@ -393,7 +402,7 @@ function GlobalStandaloneSearchShellClient({
window.cancelAnimationFrame(frame);
window.clearTimeout(timeout);
};
}, [pathname, requestedFocus, searchParamString]);
}, [pathname, requestedFocus, searchParamString, hasSubmittedModeSearch]);

// Recent queries are owner-scoped session state (2026-07-13 audit, finding 4):
// the legacy unscoped localStorage value could resurface another account's
Expand Down Expand Up @@ -486,7 +495,9 @@ function GlobalStandaloneSearchShellClient({
navigateToMode(searchMode, {
query: trimmedQuery || undefined,
run: Boolean(trimmedQuery),
focus: true,
// Running a search must not carry focus into the result dock — that pin
// disables hide-on-scroll for both chrome edges.
focus: !trimmedQuery,
});
}

Expand Down Expand Up @@ -516,7 +527,7 @@ function GlobalStandaloneSearchShellClient({

function pickRecentQuery(recentQuery: string) {
setMobileMenuOpen(false);
navigateToMode(searchMode, { query: recentQuery, focus: true, run: true });
navigateToMode(searchMode, { query: recentQuery, focus: false, run: true });
}

function crossModeSearch(mode: AppModeId, crossQuery: string) {
Expand All @@ -531,11 +542,17 @@ function GlobalStandaloneSearchShellClient({
setCommandScopes([]);
setSearchMode(mode);
setMobileMenuOpen(false);
navigateToMode(mode, { query: crossQuery, focus: true, run: true });
navigateToMode(mode, { query: crossQuery, focus: false, run: true });
}

function handleMainScroll(event: UIEvent<HTMLDivElement>) {
const target = event.currentTarget;
// Scrolling the result canvas while the dock input is focused (user
// retapped the pill) must release the focus pin before the hide reporter
// runs; otherwise both chrome edges stay locked for the whole session.
if (target.scrollTop > 8 && inputRef.current && document.activeElement === inputRef.current) {
inputRef.current.blur();
}
chromeScrollHide.reportScroll({
offset: target.scrollTop,
maxOffset: Math.max(0, target.scrollHeight - target.clientHeight),
Expand All @@ -559,6 +576,9 @@ function GlobalStandaloneSearchShellClient({
const target = event.target;
if (!(target instanceof HTMLElement) || !main.contains(target)) return;
if (target.scrollHeight <= target.clientHeight + 1) return;
if (target.scrollTop > 8 && inputRef.current && document.activeElement === inputRef.current) {
inputRef.current.blur();
}
reportChromeScrollHideRef.current({
offset: target.scrollTop,
maxOffset: Math.max(0, target.scrollHeight - target.clientHeight),
Expand Down Expand Up @@ -724,7 +744,7 @@ function GlobalStandaloneSearchShellClient({
// viewport top and slides away instead of releasing flow space.
hideOnScroll={{ strategy: "collapse", wide: "sticky", scrollHidden: chromeScrollHide.hidden }}
onBottomComposerHiddenChange={setBottomComposerHidden}
queryInputAutoFocus={searchParams.get("focus") === "1"}
queryInputAutoFocus={requestedFocus && !hasSubmittedModeSearch}
/>
</div>

Expand Down
122 changes: 76 additions & 46 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1741,7 +1741,12 @@ export function MasterSearchHeader({
// No backdrop-filter on the header itself: it would form a backdrop
// root and starve the .edge-glass-header-backdrop scrim (the single
// source of the bar's frost) of the real page behind it.
"edge-glass-header universal-header z-30 py-2 pt-[max(0.5rem,env(safe-area-inset-top))] text-[color:var(--text)]",
// Collapse hosts own the OS top inset via the always-on
// `chrome-safe-area-top` spacer outside the 0fr row, so this bar only
// needs its aesthetic 0.5rem pad. Overlay hosts still paint the inset
// themselves (answer mode keeps an equivalent reserve on <main>).
"edge-glass-header universal-header z-30 py-2 text-[color:var(--text)]",
hideStrategy === "collapse" ? "pt-2" : "pt-[max(0.5rem,var(--safe-area-top))]",
// Collapse hosts keep the header above an internally scrolling <main>,
// so sticky is unnecessary wherever the row collapses and fights the
// 0fr grid by pinning the bar inside the viewport. Where the chrome
Expand Down Expand Up @@ -1988,57 +1993,82 @@ export function MasterSearchHeader({
//
// Above the phone breakpoint a `wide: "sticky"` host scrolls the document
// instead, so this wrapper — not the <header> inside it, which has no
// sticky travel within its header-height parents — pins to the viewport top
// and translates away. Releasing the row there would pull the whole page up
// by the header height mid-scroll; translating leaves the geometry alone.
// sticky travel within its header-height parents — pins below the always-on
// safe-area spacer and translates away. Releasing the row there would pull
// the whole page up by the header height mid-scroll; translating leaves the
// geometry alone.
//
// Return a fragment (never a wrapping block): GlobalSearchShell uses
// `sm:contents` on the chrome parent so the sticky wrapper can travel
// against the viewport. A single root box would recreate the zero-travel
// sticky bug.
return (
<div
data-scroll-hidden={headerChromeHidden ? "true" : undefined}
data-testid="universal-header-collapse"
className={cn(
"motion-reduce:transition-none",
collapsesAtEveryWidth
? cn(
"grid transition-[grid-template-rows]",
headerChromeHidden
? "duration-[240ms] ease-[cubic-bezier(0.4,0,0.2,1)]"
: "duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]",
headerChromeHidden ? "[grid-template-rows:0fr]" : "[grid-template-rows:1fr]",
)
: cn(
"max-sm:grid max-sm:transition-[grid-template-rows]",
headerChromeHidden
? "max-sm:duration-[240ms] max-sm:ease-[cubic-bezier(0.4,0,0.2,1)]"
: "max-sm:duration-200 max-sm:ease-[cubic-bezier(0.22,1,0.36,1)]",
headerChromeHidden ? "max-sm:[grid-template-rows:0fr]" : "max-sm:[grid-template-rows:1fr]",
sticksAbovePhones &&
cn(
"sm:sticky sm:top-0 sm:z-30 sm:transition-transform",
headerChromeHidden
? "sm:duration-[240ms] sm:ease-[cubic-bezier(0.4,0,0.2,1)]"
: "sm:duration-200 sm:ease-[cubic-bezier(0.22,1,0.36,1)]",
),
// Transform only while hidden: a standing transform would make
// this wrapper the containing block for the fixed-position menus
// and composers rendered inside it.
sticksAbovePhones && headerChromeHidden && "sm:-translate-y-full",
),
)}
{...chromeFocusProps}
>
<>
{/*
OS status-bar inset stays outside the 0fr collapse / -translate hide.
Hide-on-scroll may reclaim the chrome controls, never the safe-area
band — otherwise scrolled content paints under the system clock/signal
icons (service detail / info pages on notched phones).
*/}
<div
aria-hidden="true"
data-testid="chrome-safe-area-top"
className={cn(
"max-sm:flex max-sm:min-h-0 max-sm:flex-col max-sm:justify-end",
collapsesAtEveryWidth && "sm:flex sm:min-h-0 sm:flex-col sm:justify-end",
// Clip only while hiding so the edge-glass-header gradient that
// extends below the header keeps painting when the chrome is shown.
headerChromeHidden && "max-sm:overflow-hidden",
collapsesAtEveryWidth && headerChromeHidden && "sm:overflow-hidden",
// relative + z above the collapsing header: the 0fr row still lets
// header#search's box extend upward into this band for layout, and
// without a higher stack the status-bar fill can lose to that paint.
"relative z-[32] h-[var(--safe-area-top)] shrink-0 bg-[color:var(--background)]",
sticksAbovePhones && "sm:sticky sm:top-0",
)}
/>
<div
data-scroll-hidden={headerChromeHidden ? "true" : undefined}
data-testid="universal-header-collapse"
className={cn(
"motion-reduce:transition-none",
collapsesAtEveryWidth
? cn(
"grid transition-[grid-template-rows]",
headerChromeHidden
? "duration-[240ms] ease-[cubic-bezier(0.4,0,0.2,1)]"
: "duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]",
headerChromeHidden ? "[grid-template-rows:0fr]" : "[grid-template-rows:1fr]",
)
: cn(
"max-sm:grid max-sm:transition-[grid-template-rows]",
headerChromeHidden
? "max-sm:duration-[240ms] max-sm:ease-[cubic-bezier(0.4,0,0.2,1)]"
: "max-sm:duration-200 max-sm:ease-[cubic-bezier(0.22,1,0.36,1)]",
headerChromeHidden ? "max-sm:[grid-template-rows:0fr]" : "max-sm:[grid-template-rows:1fr]",
sticksAbovePhones &&
cn(
"sm:sticky sm:top-[var(--safe-area-top)] sm:z-30 sm:transition-transform",
headerChromeHidden
? "sm:duration-[240ms] sm:ease-[cubic-bezier(0.4,0,0.2,1)]"
: "sm:duration-200 sm:ease-[cubic-bezier(0.22,1,0.36,1)]",
),
// Transform only while hidden: a standing transform would make
// this wrapper the containing block for the fixed-position menus
// and composers rendered inside it.
sticksAbovePhones && headerChromeHidden && "sm:-translate-y-full",
),
)}
{...chromeFocusProps}
>
{headerAndComposer}
<div
className={cn(
"max-sm:flex max-sm:min-h-0 max-sm:flex-col max-sm:justify-end",
collapsesAtEveryWidth && "sm:flex sm:min-h-0 sm:flex-col sm:justify-end",
// Clip only while hiding so the edge-glass-header gradient that
// extends below the header keeps painting when the chrome is shown.
headerChromeHidden && "max-sm:overflow-hidden",
collapsesAtEveryWidth && headerChromeHidden && "sm:overflow-hidden",
)}
>
{headerAndComposer}
</div>
</div>
</div>
</>
);
}

Expand Down
Loading
Loading