From c79b4ced4d919e60d3642c6755fbf78acef83dab Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:57:06 +0800 Subject: [PATCH 1/6] refactor(chrome): let the scroll-hide reporter serve every breakpoint Renames the phone-gated active helper, adds a resetKey so hosts can rebase the reporter on a geometry switch, and adds a document-scroll reporter for shells whose page scrolls the document above the phone breakpoint. readChromeCollapseBudget now only counts the header strip while the collapse wrapper is actually a grid - the sticky/translate mode releases no layout. --- .../clinical-dashboard/use-hide-on-scroll.ts | 77 +++++++++++++++---- 1 file changed, 63 insertions(+), 14 deletions(-) diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index 24033c964..a57e8bd5d 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -7,7 +7,8 @@ import { mobileComposerHiddenReserveRem } from "@/components/clinical-dashboard/ // Matches phoneSearchLayoutMediaQuery in master-search-header.tsx — the repo's // phone/tablet seam. Hide-on-scroll runs below the sm breakpoint unless the -// host opts into all breakpoints (the ClinicalDashboard glass-header overlay). +// host opts into all breakpoints (both app shells do this for the header; the +// bottom search dock stays phone-only). const phoneMediaQuery = "(max-width: 639px)"; // Scroll offset (px) that must be passed before the chrome may hide; the @@ -165,7 +166,14 @@ export function computeScrollHideUpdate(params: { */ export function readChromeCollapseBudget(scroller: HTMLElement): number { const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); - const headerRelease = collapse instanceof HTMLElement ? collapse.getBoundingClientRect().height : 0; + // The 1fr -> 0fr grid IS the collapse mechanism, so the wrapper only hands + // layout back while it is a grid at the current width. Where it sticks and + // translates instead (GlobalSearchShell above the phone breakpoint, which + // hands scrolling back to the document), hiding costs the scroller nothing. + const headerRelease = + collapse instanceof HTMLElement && window.getComputedStyle(collapse).display === "grid" + ? collapse.getBoundingClientRect().height + : 0; const rootFontSize = Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16; const hiddenPadPx = mobileComposerHiddenReserveRem * rootFontSize; const padRelease = (element: Element | null): number => { @@ -192,7 +200,7 @@ function readPhoneMedia() { const usePhoneMediaStore = createBrowserStore(subscribeToPhoneMedia, readPhoneMedia, false); -function usePhoneScrollHideActive(disabled = false, allowAllBreakpoints = false) { +function useScrollHideActive(disabled = false, allowAllBreakpoints = false) { const isPhone = usePhoneMediaStore(); return (allowAllBreakpoints || isPhone) && !disabled; } @@ -201,9 +209,11 @@ function usePhoneScrollHideActive(disabled = false, allowAllBreakpoints = false) * Imperative scroll-offset reporter for hosts that already own a React `onScroll` * handler on the scrolling element (for example ClinicalDashboard `
`). * Pass `allowAllBreakpoints` when the consumer hides chrome at every width - * (the all-breakpoints glass-header overlay) instead of phones only. + * (both app shells do this for the header) instead of phones only, and + * `resetKey` when the host changes the scroll geometry under the reporter + * without remounting. */ -export function useScrollHideReporter(disabled = false, allowAllBreakpoints = false) { +export function useScrollHideReporter(disabled = false, allowAllBreakpoints = false, resetKey?: unknown) { const [hidden, setHidden] = useState(false); const hiddenRef = useRef(false); const lastOffsetRef = useRef(0); @@ -211,7 +221,7 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa const directionTravelRef = useRef(0); const scrollSourceRef = useRef(null); const hasScrollSourceRef = useRef(false); - const active = usePhoneScrollHideActive(disabled, allowAllBreakpoints); + const active = useScrollHideActive(disabled, allowAllBreakpoints); const reportScroll = useCallback( (report: number | ScrollMetrics) => { @@ -259,12 +269,11 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa return () => window.cancelAnimationFrame(frame); }, [active]); - // The gate widening/narrowing (e.g. ClinicalDashboard toggling answer mode) - // changes the scroll geometry underneath us (
gains/loses its header - // reserve), so a carried-over hidden flag or last offset would produce one - // spurious hide/reveal on the first post-switch scroll. Reset on the change - // itself — `active` can stay true across it on phones, so the effect above - // never fires there. + // A geometry switch under the reporter (e.g. ClinicalDashboard toggling answer + // mode, where
gains/loses its header reserve) would otherwise carry a + // stale hidden flag or last offset into the first post-switch scroll and + // produce one spurious hide/reveal. Reset on the change itself — `active` can + // stay true across it, so the effect above never fires there. useEffect(() => { hiddenRef.current = false; lastOffsetRef.current = 0; @@ -274,11 +283,51 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa hasScrollSourceRef.current = false; const frame = window.requestAnimationFrame(() => setHidden(false)); return () => window.cancelAnimationFrame(frame); - }, [allowAllBreakpoints]); + }, [allowAllBreakpoints, resetKey]); return { hidden: active && hidden, reportScroll }; } +/** + * Feeds document scroll into a {@link useScrollHideReporter} for hosts whose + * page scrolls the document above the phone breakpoint — GlobalSearchShell, + * where `#main-content` is the scrollport only on phones and its `onScroll` + * therefore never fires on tablet/desktop. Self-gating: while the document + * cannot scroll (the phone shell is `fixed inset-0`) no scroll event arrives, + * so the internal scroller stays the single source at that width. + */ +export function useDocumentScrollHideReporter(reportScroll: (metrics: ScrollMetrics) => void) { + useEffect(() => { + let frame = 0; + + const evaluate = () => { + frame = 0; + const scrollingElement = document.scrollingElement ?? document.documentElement; + const maxOffset = Math.max(0, scrollingElement.scrollHeight - window.innerHeight); + if (maxOffset <= 0) return; + reportScroll({ + offset: window.scrollY, + maxOffset, + // Chrome that sticks to the viewport and translates away releases no + // document layout, so there is no runway to protect here. + collapseBudget: 0, + source: window, + }); + }; + + const onScroll = () => { + if (frame) return; + frame = window.requestAnimationFrame(evaluate); + }; + + window.addEventListener("scroll", onScroll, { passive: true }); + return () => { + window.removeEventListener("scroll", onScroll); + if (frame) window.cancelAnimationFrame(frame); + }; + }, [reportScroll]); +} + interface UseHideOnScrollOptions { /** * Element that owns the scrolling. When omitted the window/document scroll @@ -307,7 +356,7 @@ export function useHideOnScroll({ resetKey, }: UseHideOnScrollOptions): boolean { const { hidden, reportScroll } = useScrollHideReporter(disabled); - const active = usePhoneScrollHideActive(disabled); + const active = useScrollHideActive(disabled); useEffect(() => { reportScroll(0); From 4a46f8411d25b5431720e2b22d3dccbb2f90be87 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:58:20 +0800 Subject: [PATCH 2/6] feat(chrome): hide the universal header on scroll at every breakpoint Adds hideOnScroll.wide to the collapse strategy. Hosts whose scrollport is internal at every width collapse the header row at every width; hosts that hand scrolling back to the document above the phone breakpoint pin the collapse wrapper to the viewport top and translate it away. The wrapper - not the header, which had no sticky travel inside its header-height parents - now owns that pinning. --- .../master-search-header.tsx | 92 +++++++++++++++---- 1 file changed, 74 insertions(+), 18 deletions(-) diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 9b631d10f..9a1ae48bb 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -266,6 +266,20 @@ export function MasterSearchHeader({ * matching top padding on its scroll container. */ allBreakpoints?: boolean; + /** + * Collapse-only: how the chrome hides above the phone breakpoint. Omitting + * it keeps the hide/reveal phone-only. + * + * "collapse" releases the header's layout row at every width — for hosts + * whose scrollport is an internal element at every width (ClinicalDashboard's + * `
`), where the released strip goes straight to the content. + * + * "sticky" pins the chrome to the viewport top and slides it away instead — + * for hosts that hand scrolling back to the document above the phone + * breakpoint (GlobalSearchShell), where releasing flow space would jump the + * page under the reader. + */ + wide?: "collapse" | "sticky"; /** Parent-owned hidden state for hosts that report scroll via React `onScroll`. */ scrollHidden?: boolean; }; @@ -1676,6 +1690,12 @@ export function MasterSearchHeader({ // flow (absolute over the scrolling
, which reserves matching top // padding) so content frosts under the glass bar at every width. const overlayAllBreakpoints = hideStrategy === "overlay" && Boolean(hideOnScroll?.allBreakpoints); + const wideCollapseBehaviour = hideStrategy === "collapse" ? hideOnScroll?.wide : undefined; + // Collapse hosts whose scrollport is internal at every width release the + // header row at every width too; hosts that hand scrolling back to the + // document above the phone breakpoint stick and translate there instead. + const collapsesAtEveryWidth = wideCollapseBehaviour === "collapse"; + const sticksAbovePhones = wideCollapseBehaviour === "sticky"; const chromeFocusProps = hideOnScroll ? { onFocusCapture: () => setHeaderChromeFocused(true), @@ -1705,13 +1725,19 @@ export function MasterSearchHeader({ "edge-glass-header universal-header z-30 py-2 pt-[max(0.5rem,env(safe-area-inset-top))] text-[color:var(--text)]", // Collapse hosts keep the header above an internally scrolling
, so // sticky is unnecessary on phones and fights the 0fr grid collapse by - // pinning the bar inside the viewport. All-breakpoints overlay hosts take - // the header out of flow entirely (absolute over the padded
) — - // sticky would be inert there because the scroll container is
, not - // an ancestor of the header. Legacy overlay hosts keep sticky (they ride - // document scroll) and can translate away with zero layout shift. + // pinning the bar inside the viewport. Above phones the pinning belongs + // to the collapse wrapper instead: this
sits inside two + // header-height boxes, which leaves a sticky rule here zero travel — the + // bar simply scrolled off with the page and only came back at scroll + // top. All-breakpoints overlay hosts take the header out of flow + // entirely (absolute over the padded
) — sticky would be inert + // there because the scroll container is
, not an ancestor of the + // header. Legacy overlay hosts keep sticky (they ride document scroll) + // and can translate away with zero layout shift. hideStrategy === "collapse" - ? "max-sm:relative sm:sticky sm:top-0" + ? sticksAbovePhones + ? "relative" + : "max-sm:relative sm:sticky sm:top-0" : overlayAllBreakpoints ? "absolute inset-x-0 top-0" : "sticky top-0", @@ -1933,32 +1959,62 @@ export function MasterSearchHeader({ ); if (hideStrategy === "collapse") { - // Collapse hide-on-scroll (phones): the host renders the header above an - // internally scrolling element, so hiding must also release the header's - // layout space. A 1fr -> 0fr grid row animates the collapse without any - // height measurement; the bottom-anchored inner track makes the chrome - // slide up out of the viewport top. Fixed-position composers (answer - // footer, mobile bottom search) escape the wrapper naturally because it - // never carries a transform, and everything is inert from sm up. + // Collapse hide-on-scroll: the host renders the header above an internally + // scrolling element, so hiding must also release the header's layout space. + // A 1fr -> 0fr grid row animates the collapse without any height + // measurement; the bottom-anchored inner track makes the chrome slide up + // out of the viewport top. Fixed-position composers (answer footer, mobile + // bottom search) escape the wrapper naturally because it carries no + // transform in this mode. + // + // 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. return (
{headerAndComposer} From 0199e76f0376cdc1cd51d192c73bbb87431d5c83 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:02:44 +0800 Subject: [PATCH 3/6] feat(chrome): share one header hide/reveal across phone, tablet and desktop GlobalSearchShell now reports document scroll as well as #main-content scroll and opts the header into every breakpoint, unwrapping the chrome-visibility div above phones so the sticky wrapper has travel. ClinicalDashboard hides in every mode rather than answer mode only. The phone bottom search dock is untouched - MasterSearchHeader still gates it on the phone layout - so tablet and desktop composers stay put. --- src/components/ClinicalDashboard.tsx | 24 ++++++------ .../global-search-shell.tsx | 37 ++++++++++++++----- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index e174204f9..2907d5b6a 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -344,13 +344,13 @@ export function ClinicalDashboard({ const [answerThreadBootstrapped, setAnswerThreadBootstrapped] = useState(false); const [query, setQuery] = useState(initialQuery); const [searchMode, setSearchMode] = useState(initialSearchMode); - // Answer mode hides the glass header at every breakpoint (all-breakpoints - // overlay); other modes keep the phone-only collapse, so the reporter only - // widens past the phone media gate while in answer mode. - const phoneScrollHide = useScrollHideReporter(false, searchMode === "answer"); + // The header hides at every breakpoint in every mode (answer mode through the + // all-breakpoints glass overlay, the rest through the collapse row). Switching + // mode swaps
's header reserve, so it also rebases the reporter. + const chromeScrollHide = useScrollHideReporter(false, true, searchMode); const [bottomComposerHidden, setBottomComposerHidden] = useState(false); - const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll); - reportPhoneScrollHideRef.current = phoneScrollHide.reportScroll; + const reportChromeScrollHideRef = useRef(chromeScrollHide.reportScroll); + reportChromeScrollHideRef.current = chromeScrollHide.reportScroll; const [modeSearchSubmitted, setModeSearchSubmitted] = useState(() => Boolean(autoRunSearch && initialQuery.trim() && initialSearchMode !== "tools"), ); @@ -2801,7 +2801,7 @@ export function ClinicalDashboard({ if (frame) return; frame = window.requestAnimationFrame(() => { frame = 0; - reportPhoneScrollHideRef.current({ + reportChromeScrollHideRef.current({ offset: main.scrollTop, maxOffset: Math.max(0, main.scrollHeight - main.clientHeight), collapseBudget: readChromeCollapseBudget(main), @@ -3395,12 +3395,14 @@ export function ClinicalDashboard({ // Answer view: the header overlays the scrolling
at every width // (main reserves matching top padding) so content frosts under the // glass bar, and it slides away/returns with scroll direction. Other - // modes keep the phone-only collapse (their sm+ composer renders - // in-flow below the header, which an absolute header would bury). + // modes collapse the header row instead — an absolute header would + // bury their in-flow composer — which works at every width here + // because
is the scrollport at every width, so the released + // strip goes straight to the content. hideOnScroll={ searchMode === "answer" - ? { strategy: "overlay", allBreakpoints: true, scrollHidden: phoneScrollHide.hidden } - : { strategy: "collapse", scrollHidden: phoneScrollHide.hidden } + ? { strategy: "overlay", allBreakpoints: true, scrollHidden: chromeScrollHide.hidden } + : { strategy: "collapse", wide: "collapse", scrollHidden: chromeScrollHide.hidden } } onBottomComposerHiddenChange={setBottomComposerHidden} /> diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 403ae6197..d155aa6aa 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -31,7 +31,11 @@ import { resolveMobileComposerReserve, resolveShellVisibleMobileComposerReserve, } from "@/components/clinical-dashboard/mobile-composer-reserve"; -import { readChromeCollapseBudget, useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll"; +import { + readChromeCollapseBudget, + useDocumentScrollHideReporter, + useScrollHideReporter, +} from "@/components/clinical-dashboard/use-hide-on-scroll"; import { ModeHomeRouteLoading } from "@/components/mode-home-page-skeleton"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { @@ -215,12 +219,17 @@ function GlobalStandaloneSearchShellClient({ const searchParams = useSearchParams(); const inputRef = useRef(null); const [mainElement, setMainElement] = useState(null); - const phoneScrollHide = useScrollHideReporter(); - const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll); + // The header hides at every breakpoint; only the phone bottom dock stays + // phone-gated (MasterSearchHeader keeps that behind its own phone layout + // check). #main-content is the scrollport on phones and the document is the + // scrollport above them, so both sources feed the same reporter. + const chromeScrollHide = useScrollHideReporter(false, true); + const reportChromeScrollHideRef = useRef(chromeScrollHide.reportScroll); const [bottomComposerHidden, setBottomComposerHidden] = useState(false); + useDocumentScrollHideReporter(chromeScrollHide.reportScroll); useEffect(() => { - reportPhoneScrollHideRef.current = phoneScrollHide.reportScroll; - }, [phoneScrollHide.reportScroll]); + reportChromeScrollHideRef.current = chromeScrollHide.reportScroll; + }, [chromeScrollHide.reportScroll]); const visibleShellModes = useMemo(() => { const modes = visibleAppModeDefinitions(); if (!availableModeIds?.length) return modes; @@ -527,7 +536,7 @@ function GlobalStandaloneSearchShellClient({ function handleMainScroll(event: UIEvent) { const target = event.currentTarget; - phoneScrollHide.reportScroll({ + chromeScrollHide.reportScroll({ offset: target.scrollTop, maxOffset: Math.max(0, target.scrollHeight - target.clientHeight), collapseBudget: readChromeCollapseBudget(target), @@ -550,7 +559,7 @@ function GlobalStandaloneSearchShellClient({ const target = event.target; if (!(target instanceof HTMLElement) || !main.contains(target)) return; if (target.scrollHeight <= target.clientHeight + 1) return; - reportPhoneScrollHideRef.current({ + reportChromeScrollHideRef.current({ offset: target.scrollTop, maxOffset: Math.max(0, target.scrollHeight - target.clientHeight), // Collapsing chrome releases layout into nested scrollers too (their @@ -623,7 +632,13 @@ function GlobalStandaloneSearchShellClient({ ) : null}
-
+ {/* + `contents` above the phone breakpoint: the chrome wrapper pins itself + to the viewport top there, and a plain block here would be a + header-height containing block that leaves that sticky rule no travel + (the header then just scrolled off the page with the content). + */} +
From 39bb167798d41bc54b8b42fdf9c4d3f11228ad92 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:32:29 +0800 Subject: [PATCH 4/6] test(chrome): prove the header returns mid-page on tablet and desktop Adds the Chromium sweep the reported defect needed - the reveal must clear while the reader is still hundreds of pixels down, with the bar back on screen - plus a static contract over the wiring that proof depends on: scroll source per width, collapse-versus-sticky per host, and the display:contents ancestor that gives the sticky wrapper travel. Also drops the now-redundant sticky rule from header#search wherever its row collapses, and documents the model in search-chrome-behaviour.md. --- docs/search-chrome-behaviour.md | 24 +++ playwright.config.ts | 4 +- .../master-search-header.tsx | 24 +-- tests/header-scroll-hide-contract.test.ts | 98 ++++++++++ tests/ui-chrome-scroll.spec.ts | 185 ++++++++++++++++++ 5 files changed, 321 insertions(+), 14 deletions(-) create mode 100644 tests/header-scroll-hide-contract.test.ts create mode 100644 tests/ui-chrome-scroll.spec.ts diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 54835a095..c06a8eab4 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -20,9 +20,32 @@ This repo uses one shared search experience across the global shell, dashboard r 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. +6a. Header hide/reveal is cross-breakpoint; the bottom search dock is phone-only. See "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. +## Scroll hide/reveal + +The universal header hides on a deliberate scroll down and returns on a deliberate scroll up at **every** breakpoint. The bottom search dock keeps that behaviour on phones only — on tablet and desktop the composer sits in the hero or the header, where there is nothing to reclaim. Both read one `useScrollHideReporter` per host, so the header and the phone dock can never disagree about direction. + +Choose the hide mechanism from where the host's scrollport lives, because that decides what hiding costs the reader: + +| Host | Scrollport | `hideOnScroll` | Mechanism | +| --------------------------------- | ---------------------------------------------- | ------------------------------------- | -------------------------------------------------------------------- | +| `ClinicalDashboard` (answer view) | `
` at every width | `strategy: "overlay", allBreakpoints` | Absolute glass bar translates off; `
` keeps its top reserve | +| `ClinicalDashboard` (other modes) | `
` at every width | `strategy: "collapse", wide: "collapse"` | 1fr -> 0fr grid row; the released strip goes straight to the content | +| `GlobalSearchShell` | `#main-content` on phones, the document above | `strategy: "collapse", wide: "sticky"` | Grid collapse on phones; sticks to the viewport top and translates above | + +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. +- **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. + +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). + ## Change checklist Before changing search bar behaviour: @@ -32,4 +55,5 @@ Before changing search bar behaviour: - Update the reserve helper and CSS token together when changing clearances. - Add or update a focused static contract test for new constants or exceptions. - For visual/scroll changes, run the relevant phone-scroll/overlap Playwright coverage through `npm run ensure` and `npm run verify:ui` when the environment supports the repo runtime. +- For hide-on-scroll changes, re-read "Scroll hide/reveal" and prove the reveal at tablet and desktop, not just the hide. - If a new route has a page-owned composer, document it here and add it to the route/search coverage rather than relying on comments in a component. diff --git a/playwright.config.ts b/playwright.config.ts index 897d06694..f197fce44 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -13,14 +13,14 @@ const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; // they share a spec file. Every required browser project uses the same // production matcher and tag exclusion. const productionSpecPattern = - /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|tools|overlap|universal-search|specifiers|formulation|phone-scroll|pwa|route-coverage|visual-artifacts|hydration))\.spec\.ts/; + /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|tools|overlap|universal-search|specifiers|formulation|chrome-scroll|phone-scroll|pwa|route-coverage|visual-artifacts|hydration))\.spec\.ts/; const mockupSpecPattern = /.*ui-(tools|tools-collapse|tools-task-directory)\.spec\.ts/; const mockupTag = /@mockup/; export default defineConfig({ testDir: "./tests", testMatch: - /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|tools|tools-collapse|tools-task-directory|overlap|universal-search|specifiers|formulation|phone-scroll|pwa|route-coverage|visual-artifacts|hydration))\.spec\.ts/, + /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|tools|tools-collapse|tools-task-directory|overlap|universal-search|specifiers|formulation|chrome-scroll|phone-scroll|pwa|route-coverage|visual-artifacts|hydration))\.spec\.ts/, timeout: 60_000, retries: 0, // Fail the run if a stray `test.only` is committed: otherwise it silently diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 9a1ae48bb..bdbbc47c4 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1723,19 +1723,19 @@ export function MasterSearchHeader({ // 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 keep the header above an internally scrolling
, so - // sticky is unnecessary on phones and fights the 0fr grid collapse by - // pinning the bar inside the viewport. Above phones the pinning belongs - // to the collapse wrapper instead: this
sits inside two - // header-height boxes, which leaves a sticky rule here zero travel — the - // bar simply scrolled off with the page and only came back at scroll - // top. All-breakpoints overlay hosts take the header out of flow - // entirely (absolute over the padded
) — sticky would be inert - // there because the scroll container is
, not an ancestor of the - // header. Legacy overlay hosts keep sticky (they ride document scroll) - // and can translate away with zero layout shift. + // 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 + // sticks instead, the pinning belongs to the collapse wrapper: this + //
sits inside two header-height boxes, which leaves a sticky + // rule here zero travel — the bar simply scrolled off with the page + // and only came back at scroll top. All-breakpoints overlay hosts take + // the header out of flow entirely (absolute over the padded
) — + // sticky would be inert there because the scroll container is
, + // not an ancestor of the header. Legacy overlay hosts keep sticky + // (they ride document scroll) and translate away with no layout shift. hideStrategy === "collapse" - ? sticksAbovePhones + ? sticksAbovePhones || collapsesAtEveryWidth ? "relative" : "max-sm:relative sm:sticky sm:top-0" : overlayAllBreakpoints diff --git a/tests/header-scroll-hide-contract.test.ts b/tests/header-scroll-hide-contract.test.ts new file mode 100644 index 000000000..65d33a23f --- /dev/null +++ b/tests/header-scroll-hide-contract.test.ts @@ -0,0 +1,98 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +/** + * Static contract for the shared header hide/reveal. + * + * The header hides on scroll down and returns on scroll up at every breakpoint, + * while the bottom search dock stays a phone-only behaviour. Getting that right + * depends on three things no runtime unit test can see: which scroll source + * feeds the reporter at each width, whether the chrome collapses its layout row + * or sticks and translates, and whether the sticky wrapper actually has any + * travel against the viewport. Each was individually wrong at some point while + * the desktop header "only came back at the top of the page". + * + * The Chromium proof lives in tests/ui-chrome-scroll.spec.ts; this file keeps + * the cheap suite honest about the wiring that proof depends on. + */ + +const read = (relativePath: string) => readFileSync(new URL(`../${relativePath}`, import.meta.url), "utf8"); +const hookSource = read("src/components/clinical-dashboard/use-hide-on-scroll.ts"); +const headerSource = read("src/components/clinical-dashboard/master-search-header.tsx"); +const shellSource = read("src/components/clinical-dashboard/global-search-shell.tsx"); +const dashboardSource = read("src/components/ClinicalDashboard.tsx"); +const behaviourDocSource = read("docs/search-chrome-behaviour.md"); + +describe("shared header hide/reveal wiring", () => { + it("widens both app shells past the phone media gate", () => { + // Second argument is `allowAllBreakpoints`; leaving it off is what pinned + // hide-on-scroll to phones. + expect(shellSource).toContain("useScrollHideReporter(false, true)"); + expect(dashboardSource).toContain("useScrollHideReporter(false, true, searchMode)"); + expect(hookSource).toContain("export function useScrollHideReporter(disabled = false, allowAllBreakpoints = false"); + }); + + it("feeds GlobalSearchShell the document scroll it uses above the phone breakpoint", () => { + // #main-content is the scrollport only on phones there, so its React + // onScroll never fires on tablet/desktop and the chrome could never hide. + expect(hookSource).toContain("export function useDocumentScrollHideReporter"); + expect(shellSource).toContain("useDocumentScrollHideReporter(chromeScrollHide.reportScroll)"); + }); + + it("picks the hide mechanism from where each host's scrollport lives", () => { + // GlobalSearchShell hands scrolling back to the document above phones, so + // releasing the header's flow row there would jump the page mid-scroll. + expect(shellSource).toContain('hideOnScroll={{ strategy: "collapse", wide: "sticky"'); + // ClinicalDashboard's
is the scrollport at every width (the shell is + // dvh-tall and overflow-hidden), so the released strip goes to the content. + expect(dashboardSource).toContain('{ strategy: "collapse", wide: "collapse"'); + }); + + it("gives the sticky chrome wrapper real travel against the viewport", () => { + // A plain block here is a header-height containing block, which leaves the + // wrapper's sticky rule nowhere to stick: the bar scrolled off with the + // 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"); + }); + + it("transforms the sticky wrapper only while the chrome is hidden", () => { + // A standing transform would make the wrapper the containing block for the + // fixed-position menus and composers rendered inside it. + expect(headerSource).toContain('sticksAbovePhones && headerChromeHidden && "sm:-translate-y-full"'); + }); + + it("keeps the header out of sticky positioning wherever its row collapses", () => { + // Sticky pins the bar inside the viewport and fights the 1fr -> 0fr grid. + expect(headerSource).toContain('sticksAbovePhones || collapsesAtEveryWidth\n ? "relative"'); + }); + + it("counts the collapse budget only where the wrapper really collapses", () => { + // Chrome that sticks and translates releases no layout, so charging the + // header's height against the scroll runway would suppress valid hides. + expect(hookSource).toContain('window.getComputedStyle(collapse).display === "grid"'); + }); + + it("rebases the reporter when a host swaps its scroll geometry", () => { + // ClinicalDashboard toggling answer mode adds/removes
's header + // reserve; a carried-over offset spends the first post-switch scroll on a + // spurious hide or reveal. + expect(hookSource).toContain("}, [allowAllBreakpoints, resetKey]);"); + }); + + it("keeps the bottom search dock a phone-only behaviour", () => { + // The user-visible contract: the header hides everywhere, the footer search + // bar hides on phones only. Both gates below require the phone layout. + expect(headerSource).toContain( + "const bottomComposerScrollHiddenActive = Boolean(hideOnScroll && phoneBottomSearchDockActive);", + ); + expect(headerSource).toMatch(/const phoneBottomSearchDockActive =\s*\n\s*usesPhoneSearchLayout &&/); + expect(headerSource).toContain("const usesPhoneFooterDock = usesBottomComposerPlacement && usesPhoneSearchLayout;"); + }); + + it("documents the cross-breakpoint behaviour alongside the code", () => { + expect(behaviourDocSource).toContain("Header hide/reveal is cross-breakpoint"); + }); +}); diff --git a/tests/ui-chrome-scroll.spec.ts b/tests/ui-chrome-scroll.spec.ts new file mode 100644 index 000000000..1a517391f --- /dev/null +++ b/tests/ui-chrome-scroll.spec.ts @@ -0,0 +1,185 @@ +import { expect, test, type Page } from "playwright/test"; + +/** + * Tablet/desktop header hide-and-return. + * + * The reported defect: above the phone breakpoint the header scrolled away with + * the page and only came back once the reader reached the very top. Two + * distinct causes, one per scroll-ownership model, so both are swept here: + * + * - GlobalSearchShell hands scrolling back to the document above phones, so + * `#main-content`'s React onScroll never fired and nothing reported scroll + * at all. Its chrome now sticks to the viewport top and translates away. + * - ClinicalDashboard keeps `
` as the scrollport at every width, but its + * collapse row was gated to `max-sm`, so the header simply never hid. + * + * The load-bearing assertion is the reveal *mid-page*: hidden must clear while + * the reader is still hundreds of pixels down, with the bar back on screen. + * Asserting only `data-scroll-hidden` would have passed against the old sticky + * header, which flipped the attribute while sitting far off the viewport top. + * + * The suite-wide `reducedMotion: "reduce"` is kept deliberately: the chrome + * carries `motion-reduce:transition-none`, so geometry settles in one frame and + * these position reads are exact. Transition timing is covered by + * ui-phone-scroll.spec.ts, which re-enables motion for that purpose. + */ + +const breakpoints = [ + { name: "tablet", viewport: { width: 834, height: 1112 } }, + { name: "desktop", viewport: { width: 1440, height: 900 } }, +]; + +// One surface per scroll-ownership model above the phone breakpoint, chosen for +// having real scroll runway at both sizes (short pages legitimately never hide). +const surfaces = [ + { name: "shell mode home", route: "/tools" }, + { name: "shell detail page", route: "/formulation/worry" }, + { name: "dashboard results", route: "/?mode=prescribing&q=a&run=1" }, +]; + +const requiredRunway = 700; + +async function blockExternalRequests(page: Page) { + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()); + if ( + (url.protocol === "http:" || url.protocol === "https:") && + !["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname) + ) { + await route.abort("blockedbyclient"); + return; + } + await route.fallback(); + }); +} + +interface ChromeState { + offset: number; + maxOffset: number; + hidden: boolean; + headerTop: number; + headerBottom: number; + hiddenBottomComposers: number; +} + +/** + * Reads whichever element actually scrolls at this width, so one spec can cover + * both the document-scrolled shell and the `
`-scrolled dashboard. + */ +function readChromeState(page: Page): Promise { + return page.evaluate(() => { + const main = document.getElementById("main-content"); + const mainScrolls = Boolean(main && main.scrollHeight > main.clientHeight + 1); + const doc = document.documentElement; + // Collapse hosts flip data-scroll-hidden on the grid wrapper; the answer + // view's overlay glass bar flips it on header#search itself. + const header = document.querySelector("header#search"); + const hideTarget = document.querySelector('[data-testid="universal-header-collapse"]') ?? header; + const rect = header?.getBoundingClientRect(); + return { + offset: Math.round(mainScrolls && main ? main.scrollTop : window.scrollY), + maxOffset: Math.round( + mainScrolls && main ? main.scrollHeight - main.clientHeight : doc.scrollHeight - window.innerHeight, + ), + hidden: hideTarget?.getAttribute("data-scroll-hidden") === "true", + headerTop: rect ? Math.round(rect.top) : Number.NaN, + headerBottom: rect ? Math.round(rect.bottom) : Number.NaN, + hiddenBottomComposers: document.querySelectorAll('form[data-scroll-hidden="true"]').length, + }; + }); +} + +/** Scrolls in per-frame steps so the reporter sees real directional intent. */ +async function scrollBy(page: Page, totalPx: number, stepPx: number) { + await page.evaluate( + async ({ total, step }) => { + const main = document.getElementById("main-content"); + const mainScrolls = Boolean(main && main.scrollHeight > main.clientHeight + 1); + const steps = Math.max(1, Math.ceil(Math.abs(total) / step)); + const direction = total < 0 ? -1 : 1; + for (let i = 0; i < steps; i += 1) { + if (mainScrolls && main) { + main.scrollTop += direction * step; + main.dispatchEvent(new Event("scroll", { bubbles: true })); + } else { + window.scrollBy(0, direction * step); + } + await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))); + } + }, + { total: totalPx, step: stepPx }, + ); +} + +/** + * These routes stream content in after first paint, so the runway a test needs + * does not exist at domcontentloaded. Waiting for it beats a fixed timeout. + */ +async function waitForRunway(page: Page, minimum: number) { + await page.waitForFunction( + (min) => { + const main = document.getElementById("main-content"); + const fromMain = main ? main.scrollHeight - main.clientHeight : 0; + const fromDocument = document.documentElement.scrollHeight - window.innerHeight; + return Math.max(fromMain, fromDocument) >= min; + }, + minimum, + { timeout: 20_000 }, + ); +} + +test.beforeEach(async ({ page }) => { + await blockExternalRequests(page); +}); + +for (const { name: sizeName, viewport } of breakpoints) { + for (const { name: surfaceName, route } of surfaces) { + test(`${sizeName}: header hides on scroll down and returns mid-page on ${surfaceName}`, async ({ page }) => { + await page.setViewportSize(viewport); + await page.goto(route, { waitUntil: "domcontentloaded" }); + await expect(page.locator("header#search").first()).toBeVisible({ timeout: 15_000 }); + await waitForRunway(page, requiredRunway); + await page.waitForTimeout(400); + + const atTop = await readChromeState(page); + expect(atTop.hidden, "header visible at the top").toBe(false); + expect(atTop.headerTop, "header starts at the viewport top").toBeLessThanOrEqual(8); + + await scrollBy(page, atTop.maxOffset + 320, 160); + await page.waitForTimeout(300); + + const scrolledDown = await readChromeState(page); + expect(scrolledDown.offset, "descent moved the scroller").toBeGreaterThan(requiredRunway - 200); + expect(scrolledDown.hidden, "header hides on a deliberate scroll down").toBe(true); + expect(scrolledDown.headerBottom, "hidden header is off the top of the viewport").toBeLessThanOrEqual(0); + + // Three deliberate upward steps — nowhere near the top of the page. + await scrollBy(page, -360, 120); + await page.waitForTimeout(300); + + const scrolledUp = await readChromeState(page); + expect(scrolledUp.offset, "the reveal happens well short of the top").toBeGreaterThan(200); + expect(scrolledUp.hidden, "header returns on a deliberate scroll up").toBe(false); + expect(scrolledUp.headerBottom, "returned header is actually on screen").toBeGreaterThan(0); + expect(scrolledUp.headerTop, "returned header sits at the viewport top").toBeLessThanOrEqual(8); + }); + + test(`${sizeName}: bottom search composer never scroll-hides on ${surfaceName}`, async ({ page }) => { + // The phone dock hide is phone-only by contract; above that breakpoint the + // composer lives in the hero or the header and has nothing to reclaim. + await page.setViewportSize(viewport); + await page.goto(route, { waitUntil: "domcontentloaded" }); + await expect(page.locator("header#search").first()).toBeVisible({ timeout: 15_000 }); + await waitForRunway(page, requiredRunway); + await page.waitForTimeout(400); + + const atTop = await readChromeState(page); + await scrollBy(page, atTop.maxOffset + 320, 160); + await page.waitForTimeout(300); + + const scrolledDown = await readChromeState(page); + expect(scrolledDown.hidden, "the header still hides").toBe(true); + expect(scrolledDown.hiddenBottomComposers, "no composer hides above the phone breakpoint").toBe(0); + }); + } +} From 6f749dfde45fee001704cb7bfd9d82aef2ab5bcf Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:58:28 +0800 Subject: [PATCH 5/6] docs(chrome): fold the cross-breakpoint note into invariant 6 --- docs/search-chrome-behaviour.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index c06a8eab4..2af97b45e 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -19,8 +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. -6a. Header hide/reveal is cross-breakpoint; the bottom search dock is phone-only. See "Scroll hide/reveal" below before changing either. +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. 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. @@ -30,11 +29,11 @@ The universal header hides on a deliberate scroll down and returns on a delibera Choose the hide mechanism from where the host's scrollport lives, because that decides what hiding costs the reader: -| Host | Scrollport | `hideOnScroll` | Mechanism | -| --------------------------------- | ---------------------------------------------- | ------------------------------------- | -------------------------------------------------------------------- | -| `ClinicalDashboard` (answer view) | `
` at every width | `strategy: "overlay", allBreakpoints` | Absolute glass bar translates off; `
` keeps its top reserve | -| `ClinicalDashboard` (other modes) | `
` at every width | `strategy: "collapse", wide: "collapse"` | 1fr -> 0fr grid row; the released strip goes straight to the content | -| `GlobalSearchShell` | `#main-content` on phones, the document above | `strategy: "collapse", wide: "sticky"` | Grid collapse on phones; sticks to the viewport top and translates above | +| Host | Scrollport | `hideOnScroll` | Mechanism | +| --------------------------------- | --------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------ | +| `ClinicalDashboard` (answer view) | `
` at every width | `strategy: "overlay", allBreakpoints` | Absolute glass bar translates off; `
` keeps its top reserve | +| `ClinicalDashboard` (other modes) | `
` at every width | `strategy: "collapse", wide: "collapse"` | 1fr -> 0fr grid row; the released strip goes straight to the content | +| `GlobalSearchShell` | `#main-content` on phones, the document above | `strategy: "collapse", wide: "sticky"` | Grid collapse on phones; sticks to the viewport top and translates above | Rules that keep this working: From ad4d7c6dab0c9267a1dbf40929240eaef710eea3 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:23:52 +0800 Subject: [PATCH 6/6] docs(ledger): record the cross-breakpoint header scroll verification for PR #1222 --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 74e9fca0a..9e962949d 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -971,3 +971,4 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-25 | cursor/pr1190-dark-mode-salvage-f453 (PR #1214) | pending-ci-retrigger-2 | CI unblock + skip-branch-sync | Repeated pr-branch-sync bot merges left CI `action_required`. Applied `skip-branch-sync` label and agent retrigger so required checks can finish for squash-merge; then close #1190. | Hosted CI pending on tip; no provider-backed checks. | | 2026-07-25 | cursor/pr1190-dark-mode-salvage-f453 (PR #1214) | `e19442240afbd7f28c321e399c6b4dcb0a7c9fdf` / squash `bb6b394617cbd906285ebe19e0e452912793320a` | prlanded after squash merge | MERGED. Two-dot content diff vs `origin/main` empty; clinical gate preserved; `no-hardcoded-hex` + theme CSS on main; PWA manifest theme colours intentionally absent. Hosted PR required SUCCESS (Unit/Build/Static/Production UI). Remote salvage branch deleted by squash `--delete-branch`. | `gh pr view` MERGED; `git diff origin/main e19442240` empty; trustGated grep on main; no provider-backed checks. | | 2026-07-25 | PR #1190 / `remediate-dark-mode-audit` | `00eca49b9b0d7e5fbfa5703a15e9e930963984a6` | Close without merge after #1214 salvage | CLOSED (not merged). Unsafe tip superseded by #1214. Remote branch `remediate-dark-mode-audit` retained pending optional cleanup; do not merge. | `gh pr view` state=CLOSED mergedAt=null; no provider-backed checks. | +| 2026-07-26 | cursor/global-header-scroll-hide-4fd7 (PR #1222) | `a7f6d81f8b1dd5613dda94a3ddef78d480de876e` | Cross-breakpoint header hide/reveal + tablet/desktop scroll coverage | Header now hides on scroll down and returns on scroll up at every breakpoint; bottom search dock stays phone-only. Two root causes fixed: GlobalSearchShell had no scroll source above phones (`#main-content` onScroll never fires there) and its sticky rule sat on `header#search`, which has zero travel inside two header-height parents; ClinicalDashboard's collapse row was `max-sm`-gated so it never hid. Red/green proof captured: with the four source files reverted to base `1aa64e94`, all 12 new Playwright tests and 8/10 static contract assertions fail. | `npm run verify:cheap` pass except pre-existing local `tests/pdf-extractor.test.ts` Python-OCR failure (reproduced identically at base `1aa64e94`); `npm run verify:ui` 284/284 Chromium on the main-synced tree; `check:migration-role`, `check:function-grants`, `check:branch-review-ledger` pass after the #1197 SQL sync; no provider-backed checks. |