diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index a9b086bb..0cdb0054 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -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. @@ -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 `
`'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). diff --git a/src/app/globals.css b/src/app/globals.css index 4ed85541..c74775f0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 3cb19656..8a62a05f 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -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 @@ -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 @@ -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, }); } @@ -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) { @@ -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) { 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), @@ -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), @@ -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} /> diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 3830aa9f..dd50c323 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -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
). + "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
, // so sticky is unnecessary wherever the row collapses and fights the // 0fr grid by pinning the bar inside the viewport. Where the chrome @@ -1988,57 +1993,82 @@ export function MasterSearchHeader({ // // Above the phone breakpoint a `wide: "sticky"` host scrolls the document // instead, so this wrapper — not the
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 ( -
+ <> + {/* + 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). + */} + ); } diff --git a/tests/header-scroll-hide-contract.test.ts b/tests/header-scroll-hide-contract.test.ts index 65d33a23..96a7c9b6 100644 --- a/tests/header-scroll-hide-contract.test.ts +++ b/tests/header-scroll-hide-contract.test.ts @@ -55,7 +55,20 @@ describe("shared header hide/reveal wiring", () => { // page and only reappeared at scroll top. `contents` removes that box. expect(shellSource).toContain('className={mobileChromeVisible ? "sm:contents" : "hidden lg:contents"}'); expect(shellSource).not.toContain('mobileChromeVisible ? undefined : "hidden lg:block"'); - expect(headerSource).toContain("sm:sticky sm:top-0 sm:z-30 sm:transition-transform"); + // Collapse returns a fragment (spacer + wrapper). Wrapping both in a block + // would recreate the zero-travel sticky bug under `sm:contents`. + expect(headerSource).toContain('data-testid="chrome-safe-area-top"'); + expect(headerSource).toContain("sm:sticky sm:top-[var(--safe-area-top)] sm:z-30 sm:transition-transform"); + }); + + it("keeps the OS top safe-area outside the collapse / translate hide", () => { + // Releasing the status-bar inset with the chrome lets scrolled text paint + // under the system clock/signal icons on notched phones (service detail). + expect(headerSource).toContain('data-testid="chrome-safe-area-top"'); + expect(headerSource).toContain("h-[var(--safe-area-top)]"); + expect(headerSource).toContain("relative z-[32] h-[var(--safe-area-top)]"); + expect(headerSource).toContain('hideStrategy === "collapse" ? "pt-2" : "pt-[max(0.5rem,var(--safe-area-top))]"'); + expect(behaviourDocSource).toContain("OS top safe-area band"); }); it("transforms the sticky wrapper only while the chrome is hidden", () => { @@ -92,6 +105,18 @@ describe("shared header hide/reveal wiring", () => { expect(headerSource).toContain("const usesPhoneFooterDock = usesBottomComposerPlacement && usesPhoneSearchLayout;"); }); + it("does not carry dock focus into GlobalSearchShell submitted result views", () => { + // focus=1 + run=1 left the Forms/services dock focused, which pins both + // chrome edges and freezes hide-on-scroll with the bottom white rail visible. + expect(shellSource).toContain("focus: !trimmedQuery"); + expect(shellSource).toContain("queryInputAutoFocus={requestedFocus && !hasSubmittedModeSearch}"); + expect(shellSource).toContain("if (hasSubmittedModeSearch)"); + expect(shellSource).toContain( + "if (target.scrollTop > 8 && inputRef.current && document.activeElement === inputRef.current)", + ); + expect(behaviourDocSource).toContain("Do not carry composer focus into submitted result views"); + }); + it("documents the cross-breakpoint behaviour alongside the code", () => { expect(behaviourDocSource).toContain("Header hide/reveal is cross-breakpoint"); }); diff --git a/tests/ui-phone-scroll.spec.ts b/tests/ui-phone-scroll.spec.ts index 44ce61a2..9eb21a94 100644 --- a/tests/ui-phone-scroll.spec.ts +++ b/tests/ui-phone-scroll.spec.ts @@ -234,3 +234,108 @@ test("phone scroll stays smooth on /formulation at 430x932", async ({ page }) => expect(Math.abs(atBottom.scrollTop - atBottom.maxOffset)).toBeLessThanOrEqual(2); expect(await readFlipCount(page)).toBeLessThanOrEqual(1); }); + +/** + * Forms/services search used to keep focus=1 after submit. Composer focus + * pins both chrome edges, so neither header nor the bottom dock (white + * safe-area rail) could hide while scrolling results. + */ +test("phone forms search hides header and footer after submit without stale focus", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.setViewportSize(phoneViewport); + await gotoPhoneSurface(page, "/forms?q=Form&run=1&focus=1"); + await expect(page.locator("form.answer-footer-search-dock")).toBeVisible({ timeout: 20_000 }); + + // Stale focus=1 on a submitted result view must not win — the shell blurs. + const input = page.getByTestId("global-search-input"); + await expect(input).not.toBeFocused({ timeout: 5_000 }); + + const initial = await readGeometry(page); + expect(initial.headerHidden, "header visible at the top").toBe(false); + + await dragScrollBy(page, Math.min(Math.max(initial.maxOffset, 240), 800), 24); + await page.waitForTimeout(500); + + const afterHide = await page.evaluate(() => { + const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); + const dock = document.querySelector("form.answer-footer-search-dock"); + const reserve = document.querySelector('[data-testid="mobile-composer-reserve-pad"]'); + const dockRect = dock?.getBoundingClientRect(); + return { + headerHidden: collapse?.getAttribute("data-scroll-hidden") === "true", + dockHidden: dock?.getAttribute("data-scroll-hidden") === "true", + reservePb: reserve ? getComputedStyle(reserve).paddingBottom : "", + dockTop: dockRect?.top ?? -1, + viewportHeight: window.innerHeight, + inputFocused: document.activeElement?.getAttribute("data-testid") === "global-search-input", + }; + }); + + expect(afterHide.inputFocused, "scroll must not leave the dock focused").toBe(false); + expect(afterHide.headerHidden, "header hides on forms result scroll").toBe(true); + expect(afterHide.dockHidden, "footer dock hides on forms result scroll").toBe(true); + expect(afterHide.dockTop, "hidden dock clears the viewport bottom").toBeGreaterThanOrEqual( + afterHide.viewportHeight - 1, + ); + expect(Number.parseFloat(afterHide.reservePb) || 0, "hidden reserve releases the bottom rail").toBeLessThanOrEqual(1); +}); + +/** + * Status-bar safe-area guard: collapsing the phone header must reclaim the + * chrome controls, never the OS top inset. Without the always-on + * `chrome-safe-area-top` spacer, service-detail text painted under the + * system clock/signal icons after a deliberate scroll-hide. + */ +test("phone service detail keeps content below the status-bar inset after header hide", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.setViewportSize(phoneViewport); + await gotoPhoneSurface(page, "/services/13yarn"); + await expect(page.getByTestId("service-detail-page")).toBeVisible({ timeout: 20_000 }); + await expect(page.getByTestId("chrome-safe-area-top")).toBeVisible(); + + const initial = await readGeometry(page); + expect(initial.headerHidden, "header visible at the top").toBe(false); + + await dragScrollBy(page, Math.max(initial.maxOffset, 240), 24); + await page.waitForTimeout(500); + + const afterHide = await page.evaluate(() => { + const safeArea = document.querySelector('[data-testid="chrome-safe-area-top"]'); + const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); + const main = document.getElementById("main-content"); + const servicePage = document.querySelector('[data-testid="service-detail-page"]'); + const safeRect = safeArea?.getBoundingClientRect(); + const mainRect = main?.getBoundingClientRect(); + const rootStyles = getComputedStyle(document.documentElement); + const safeAreaTopPx = Number.parseFloat(rootStyles.getPropertyValue("--safe-area-top")) || 0; + // Geometry proof (not hit-testing): service cards may sit above the main + // box in document coordinates after scroll, but their visible paint is + // clipped to #main-content, which must start at/below the safe-area band. + const visibleServiceTop = servicePage ? Math.max(servicePage.getBoundingClientRect().top, mainRect?.top ?? 0) : -1; + return { + headerHidden: collapse?.getAttribute("data-scroll-hidden") === "true", + safeAreaHeight: safeRect?.height ?? 0, + safeAreaTop: safeRect?.top ?? -1, + mainTop: mainRect?.top ?? -1, + visibleServiceTop, + safeAreaTopPx, + safeAreaZ: safeArea ? getComputedStyle(safeArea).zIndex : "", + }; + }); + + expect(afterHide.headerHidden, "header collapses after a deliberate descent").toBe(true); + expect(afterHide.safeAreaHeight, "safe-area spacer keeps the status-bar band").toBeGreaterThanOrEqual( + afterHide.safeAreaTopPx - 1, + ); + expect(afterHide.safeAreaTop, "safe-area spacer stays pinned to the viewport top").toBeLessThanOrEqual(1); + expect(afterHide.mainTop, "scrollport starts below the status-bar inset").toBeGreaterThanOrEqual( + afterHide.safeAreaTopPx - 1, + ); + expect(afterHide.visibleServiceTop, "service content stays below the status-bar inset").toBeGreaterThanOrEqual( + afterHide.safeAreaTopPx - 1, + ); + expect( + Number.parseInt(afterHide.safeAreaZ, 10), + "safe-area spacer stacks above collapsing chrome", + ).toBeGreaterThanOrEqual(32); +});