From dbfeda1717cdebc13d1af11f42b027e07f7ab6b1 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:52:54 +0800 Subject: [PATCH 1/6] fix(ui): match tablet search header to desktop --- docs/search-chrome-behaviour.md | 47 +++++++++---------- ...dashboard-desktop-result-composer-slot.tsx | 2 +- .../global-search-shell.tsx | 7 ++- .../master-search-header.tsx | 23 ++++----- tests/header-scroll-hide-contract.test.ts | 17 +++---- tests/ui-chrome-scroll.spec.ts | 24 +++------- tests/ui-tools.spec.ts | 9 ---- 7 files changed, 52 insertions(+), 77 deletions(-) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 305d190724..1b86a4215f 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -4,15 +4,15 @@ This repo uses one shared search experience across the global shell, dashboard r ## Page ownership model -| Page state | Composer placement | Reserve owner | -| ----------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| Answer home / standalone mode homes | In-flow hero composer on phones and larger breakpoints | Page content; no fixed phone dock reserve | -| Submitted/search-result views | Compact bottom dock on phones; pinned below the header on tablets; in normal page flow on desktop | Shell/dashboard `--mobile-composer-reserve` on phones; page content on desktop | -| Answer result view | Overlaid glass header plus answer composer dock | Dashboard `#main-content` top/bottom reserves | -| Document detail/source routes | `DocumentViewer` floating composer | `DocumentViewer` content padding | -| Document section navigation | Header row disclosure (phone sheet) + rail index card at `lg` | None — adds no chrome and no reserve | -| Calculators (`/calculators`) | Page-owned composer (desktop top + phone bottom dock) | Calculators page pad; shell reserve stays `0` | -| Info/detail pages with no composer | No fixed composer | Idle shell padding only | +| Page state | Composer placement | Reserve owner | +| ----------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| Answer home / standalone mode homes | In-flow hero composer on phones and larger breakpoints | Page content; no fixed phone dock reserve | +| Submitted/search-result views | Compact bottom dock on phones; in normal page flow on tablets and desktops | Shell/dashboard `--mobile-composer-reserve` on phones; page content on desktop | +| Answer result view | Overlaid glass header plus answer composer dock | Dashboard `#main-content` top/bottom reserves | +| Document detail/source routes | `DocumentViewer` floating composer | `DocumentViewer` content padding | +| Document section navigation | Header row disclosure (phone sheet) + rail index card at `lg` | None — adds no chrome and no reserve | +| Calculators (`/calculators`) | Page-owned composer (desktop top + phone bottom dock) | Calculators page pad; shell reserve stays `0` | +| Info/detail pages with no composer | No fixed composer | Idle shell padding only | ## Invariants @@ -21,7 +21,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. Keep the dock form transparent and use its absolute `.answer-footer-search-backdrop` child for localized translucent gradient/blur around the pill. The gradient and every blur mask must return to fully transparent at the physical bottom edge. It must move and fade with the dock, then become `visibility: hidden` after the hide transition so WebKit cannot retain a safe-area compositor strip; it must never become a viewport-fixed or opaque slab. -6. Header and footer chrome that share the same scroll signal should hide/reveal symmetrically for the surfaces that actually hide. **Collapse motion:** when the phone top bar is hidden, `chrome-safe-area-top` and the controls both release to `0rem` so underlying content paints to the physical viewport edge. **Overlay motion (default for `GlobalSearchShell` phones; collapse remains for `isCollapseMotionPhoneRoute`):** the stack translates instead; `chrome-safe-area-top` stays inside the translated layer at a stable height, and the content-facing `--phone-overlay-chrome-h` clearance is constant across hide/reveal — zeroing it on hide would reintroduce the layout shift overlay exists to remove. The visible phone header still owns `var(--safe-area-top)`; tablet/desktop sticky chrome keeps its pinned inset. While visible that spacer is the top of the header, so it paints `var(--surface)` — the bar's own opaque phone colour — never `var(--background)`: the page colour there reads as a status-bar band above the bar, the seam overlay-strategy answer mode never shows because its header pads the inset itself. Keep it opaque so the sm+ pinned inset still hides scrolled content. Top-bar hide/reveal is cross-breakpoint; the search field stays pinned on tablets, while desktop search belongs to page flow and scrolls away naturally; the bottom search dock is phone-only. Hidden bottom dock reserve stays `0rem` (invariant 4). Read "Scroll hide/reveal" below before changing either. +6. Header and footer chrome that share the same scroll signal should hide/reveal symmetrically for the surfaces that actually hide. **Collapse motion:** when the phone top bar is hidden, `chrome-safe-area-top` and the controls both release to `0rem` so underlying content paints to the physical viewport edge. **Overlay motion (default for `GlobalSearchShell` phones; collapse remains for `isCollapseMotionPhoneRoute`):** the stack translates instead; `chrome-safe-area-top` stays inside the translated layer at a stable height, and the content-facing `--phone-overlay-chrome-h` clearance is constant across hide/reveal — zeroing it on hide would reintroduce the layout shift overlay exists to remove. The visible phone header still owns `var(--safe-area-top)`; tablet/desktop top-bar chrome keeps its pinned inset. While visible that spacer is the top of the header, so it paints `var(--surface)` — the bar's own opaque phone colour — never `var(--background)`: the page colour there reads as a status-bar band above the bar, the seam overlay-strategy answer mode never shows because its header pads the inset itself. Keep it opaque so the sm+ pinned inset still hides scrolled content. Top-bar hide/reveal is cross-breakpoint; the search field belongs to page flow and scrolls away naturally on tablets and desktops; the bottom search dock is phone-only. Hidden bottom dock reserve stays `0rem` (invariant 4). 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. Page-owned fixed phone composers follow the same release contract: calculators use the shared footer backdrop; DocumentViewer keeps its floating pill but synchronizes transform, opacity, pointer release, and its own zero-reserve content padding. In-flow hero composers remain free of fixed-footer glass. @@ -122,21 +122,21 @@ geometry and tap heights). ## Scroll hide/reveal -The universal **top bar** (mode, new chat, menu) is the only sticky desktop chrome: it hides on a deliberate scroll down and returns on a deliberate scroll up at **every** breakpoint. Tablet search stays pinned below it. Desktop search is mounted at the top of normal page content, so it scrolls away with that content and is independent of the header's hide state. Only the phone bottom search dock scroll-hides, and that stays phone-only. The top bar and phone dock read one `useScrollHideReporter` per host, so they can never disagree about direction. +The universal **top bar** (mode, new chat, menu) is the only sticky desktop chrome: it hides on a deliberate scroll down and returns on a deliberate scroll up at **every** breakpoint. Tablet and desktop search are mounted at the top of normal page content, so they scroll away with that content and are independent of the header's hide state. Only the phone bottom search dock scroll-hides, and that stays phone-only. The top bar and phone dock read one `useScrollHideReporter` per host, so they 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) | Document on browser phones; `
` in standalone and at `sm+` | `strategy: "overlay", allBreakpoints` | Absolute glass top bar translates off; `
` keeps its top reserve; search stays | -| `ClinicalDashboard` (other modes) | Document on browser phones; `
` in standalone and at `sm+` | `strategy: "collapse", wide: "collapse"` | Top-bar row collapses; tablet search stays sticky; desktop search portals into `
` page flow | -| `GlobalSearchShell` | Document in browser phones and at `sm+`; `#main-content` only in installed standalone mode | `strategy: "collapse", wide: "sticky"` | Tablet pins [top bar \| search]; desktop portals search into `#main-content`, leaving a sticky auto-hiding top bar | +| Host | Scrollport | `hideOnScroll` | Mechanism | +| --------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------- | +| `ClinicalDashboard` (answer view) | Document on browser phones; `
` in standalone and at `sm+` | `strategy: "overlay", allBreakpoints` | Absolute glass top bar translates off; `
` keeps its top reserve; search stays | +| `ClinicalDashboard` (other modes) | Document on browser phones; `
` in standalone and at `sm+` | `strategy: "collapse", wide: "collapse"` | Top-bar row collapses; tablet and desktop search portal into `
` page flow | +| `GlobalSearchShell` | Document in browser phones and at `sm+`; `#main-content` only in installed standalone mode | `strategy: "collapse", wide: "sticky"` | Tablet and desktop portal search into `#main-content`, leaving a sticky auto-hiding top bar | `GlobalSearchShell` defaults to `phoneMotion: "overlay"` on phones. The safe-area region, universal top bar, and any page navigation portaled into the collapse row form one fixed browser/absolute standalone layer, and the complete layer translates over content without changing the scrollport or content -geometry. Tablet and desktop continue using `wide: "sticky"`. +geometry. Tablet and desktop continue using `wide: "sticky"` for the top bar only. **Collapse remains the deliberate exception** for `isCollapseMotionPhoneRoute` (`/therapy-compass/*` and `/differentials/diagnoses/*`): those routes portal @@ -203,23 +203,22 @@ failure mode (see invariant 17). Rules that keep this working: -- **Hide the top bar, not the search field.** The collapse wrapper (`data-testid="universal-header-collapse"`) wraps `header#search` plus page navigation mounted through `PhoneHeaderCollapsePortal` into `#phone-header-collapse-addon-slot`. Keep composers outside the collapse row: tablet search stays pinned independently, and desktop search scrolls with page content rather than being translated by the header. +- **Hide the top bar, not the search field.** The collapse wrapper (`data-testid="universal-header-collapse"`) wraps `header#search` plus page navigation mounted through `PhoneHeaderCollapsePortal` into `#phone-header-collapse-addon-slot`. Keep composers outside the collapse row: tablet and desktop result search scroll with page content rather than being translated by the header. - **Every production phone navigation header has one collapse owner.** `PhoneHeaderCollapsePortal` moves Therapy section navigation, DocumentViewer navigation, and Differential detail navigation into `#phone-header-collapse-addon-slot` below `sm`; the same subtree stays in its existing page position at `sm+`. Do not add a second sticky/fixed phone header inside `#main-content`: the universal collapse row must own its safe area, focus pinning, timing, clipping, and measured release. Semantic content headings and modal/sheet headers are not viewport chrome and stay in their own flow/scroll context. - **Document phone headers overlay as one stable stack.** Document detail/source routes keep the complete phone header at a stable height and translate the safe area plus both header rows and the section track together. Hidden overlay chrome is transparent and non-interactive; revealed chrome frosts and covers the document. `readChromeCollapseMetrics` counts zero released top-header geometry for this overlay, while continuing to measure the independently hidden document composer reserve. Reveal must not change the active owner's scroll offset or a stable document/PDF anchor. - **Feed the reporter from the element that actually scrolls.** Both app hosts run `useDocumentScrollHideReporter` alongside their `
` reporter. In browser-mode phones the normal-flow shell and `overflow-y: visible` surface make the document the only vertical owner, which lets Safari minimize its browser UI. Installed standalone mode uses a normal-flow `100vh` shell plus bounded inner surface, so document scroll does not fire there. The hook measures the same collapse budget and blurs the same focused composer for either owner. Page-owned footer chrome must follow the same rule: `DocumentViewer` observes both the document and the inner surface, then combines the signals so only the active owner drives it. Its rendered footer, like calculator and differential page-owned footers, portals to the frame host so observing the inner scroll owner does not make the footer its descendant. - **Keep browser-phone chrome attached to the viewport without fixing the app root.** Collapse-mode headers use one phone-sticky wrapper; answer overlay headers are fixed only in browser mode and remain absolute over the inner surface in standalone mode. Footer layers are likewise viewport-fixed in browser mode and shell-absolute in standalone. While either header or reserve transition changes document geometry, the corresponding transition marker disables anchoring on the active document/inner scroller so synthetic reverse scroll cannot cause a hide/reveal loop or reading-position jump. - **Do not treat CSSOM bounds as physical iOS paint proof.** A fixed root can report perfect `getBoundingClientRect()` and hit-testing while WebKit leaves an app-external band. Keep browser/document and standalone/`100vh` static guards, then verify Safari and a freshly relaunched Home Screen app on a physical phone before merge. If iOS reports a web viewport shorter than `screen.height`, pixels outside that viewport are system-owned; keep the root canvas opaque and matching, but do not fake reachability with negative safe-area overscan. -- **Viewport stickiness belongs on the outer [top bar \| search] stack, not on `header#search`.** The top bar sits inside header-height boxes, which leaves a sticky rule on it zero travel. For the same reason the visible stack's ancestor in `GlobalSearchShell` is `display: contents` at every breakpoint rather than a block. The collapse result owns one phone-sticky wrapper (safe-area spacer + stack), while its `sm:` children retain the tablet/desktop offsets. At desktop widths the search portal leaves that same outer stack holding only the top bar. -- **Collapse only the top-bar row inside a sticky stack.** On tablets, translating the whole stack would take the search field off-screen; collapsing just the top bar lets search stay pinned at the viewport top below the wide-layout safe-area spacer. On desktop, the page-flow search is outside the stack entirely. +- **Viewport stickiness belongs on the outer top-bar stack, not on `header#search`.** The top bar sits inside header-height boxes, which leaves a sticky rule on it zero travel. For the same reason the visible stack's ancestor in `GlobalSearchShell` is `display: contents` at every breakpoint rather than a block. The collapse result owns one phone-sticky wrapper (safe-area spacer + stack), while its `sm:` children retain the tablet/desktop offsets. At tablet and desktop widths the search portal leaves that outer stack holding only the top bar. +- **Collapse only the top-bar row inside the sticky stack.** Tablet and desktop page-flow search sit outside this stack entirely; the stack hides and reveals only the universal top bar. - **Release the phone top inset with collapsing chrome.** For the default collapse motion, `chrome-safe-area-top` is a full-width sibling that is `h-[var(--safe-area-top)]` while the phone header is visible and `h-0` while hidden, using the same transition timing as the top-bar row. `readChromeCollapseMetrics` must charge that released phone height as well as the controls and dock reserve, or short pages clamp and oscillate at the bottom. The document overlay exception keeps this spacer inside the translated stack at a stable height and charges zero released top geometry. At `sm+` the spacer remains `h-[var(--safe-area-top)]`, and sticky chrome pins at `top: var(--safe-area-top)`. Do not leave a phone-only surface/status-bar band after collapsing controls hide. - **One transition, no jump.** Phone page navigation belongs inside the universal 1fr → 0fr grid rather than running another scroll hook. The shared reporter may emit one hide on a deliberate descent and one reveal on deliberate upward intent; geometry must move monotonically through the 240ms hide / 200ms reveal and remain still at the bottom edge. Reduced motion removes the animation but not the complete edge release. -- **Do not double-sticky tablet search inside an outer sticky stack.** When `wide: "sticky"` owns the tablet stack, the composer stays `relative` in that stack. A second sticky search with its own `top` overlays page controls (and blocks clicks) once the top bar collapses. -- **Desktop search is page-owned.** `desktop-page-search-composer-slot` is rendered at the top of normal shell/dashboard content and accepts the shared composer only at `min-width: 1024px`. The mode-home hero slot takes precedence. Never give the desktop page composer, its slot, or an ancestor `fixed`/`sticky` positioning. -- **Collapse-everywhere hosts still drop their own sticky search offset while the top bar is hidden.** Dashboard result composers that clear a visible top bar with `top: 4.75rem + safe-area` must switch to `top: 0` when collapse hide is active — otherwise a dead band the height of the mode bar remains above the search field. +- **Do not sticky-position tablet or desktop result search.** The composer belongs to page flow at these widths; anchoring it overlays page controls (and blocks clicks) once the top bar collapses. +- **Tablet and desktop search are page-owned.** `desktop-page-search-composer-slot` is rendered at the top of normal shell/dashboard content and accepts the shared composer at `min-width: 640px`. The mode-home hero slot takes precedence. Never give the page composer, its slot, or an ancestor `fixed`/`sticky` positioning. - **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. Shared mode-home shells should also reset on `pathname` so collapsed chrome/scroll offset does not carry across modes. - **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 both hosts must blur the dock input when the active result owner scrolls so hide-on-scroll can reclaim the header and bottom dock. -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 pinned-search behaviour and desktop page-flow search plus top-bar hide/reveal), `tests/ui-phone-scroll.spec.ts` (phone scroll geometry), `tests/ui-therapy-nav-scroll.spec.ts` (Therapy section nav hide/reveal with the top bar). +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 page-flow search plus top-bar hide/reveal), `tests/ui-phone-scroll.spec.ts` (phone scroll geometry), `tests/ui-therapy-nav-scroll.spec.ts` (Therapy section nav hide/reveal with the top bar). Run `npm run verify:phone-chrome` for phone-chrome work. For executable changes its classifier checks installed/lock parity first, runs focused static contracts and only the browser/PWA owners and route journeys implicated by the changed files, then escalates to `npm run verify:ui` automatically for shared chrome foundations. Documentation-only scopes run only documentation guards. Use `-- --dry-run` to inspect the plan, `-- --files ` for an explicit scope, and `-- --full=always|never` only for a deliberate override. diff --git a/src/components/clinical-dashboard/dashboard-desktop-result-composer-slot.tsx b/src/components/clinical-dashboard/dashboard-desktop-result-composer-slot.tsx index c9622a1329..6dc2dacf2c 100644 --- a/src/components/clinical-dashboard/dashboard-desktop-result-composer-slot.tsx +++ b/src/components/clinical-dashboard/dashboard-desktop-result-composer-slot.tsx @@ -8,7 +8,7 @@ export function DashboardDesktopResultComposerSlot({ slotId }: { slotId?: string ); } diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index d7a9529aa1..6893161ac2 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -857,9 +857,8 @@ function GlobalStandaloneSearchShellBody({ heroComposerBreakpoint="all" // Phones: #main-content owns vertical scroll, so hide-on-scroll // collapses the top bar to hand space back to content. - // Tablet: the document scrolls, so an outer sticky stack pins - // [top bar | search]. Desktop portals search into normal page flow, - // leaving this stack to own only the auto-hiding top bar. + // Tablet and desktop portal search into normal page flow. The outer + // sticky stack therefore owns only the auto-hiding top bar. hideOnScroll={{ strategy: "collapse", // Phones always overlay. The collapse mechanism is a 1fr -> 0fr @@ -934,7 +933,7 @@ function GlobalStandaloneSearchShellBody({ ) : null} {/* diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 068f6a66db..f3bfb6002a 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -82,7 +82,7 @@ const composerPrivacyWarningId = "answer-composer-privacy-warning"; const phoneSearchLayoutMediaQuery = "(max-width: 639px)"; const scopeSheetMediaQuery = "(max-width: 1023px)"; -const desktopPageComposerMediaQuery = "(min-width: 1024px)"; +const desktopPageComposerMediaQuery = "(min-width: 640px)"; const modeHomeComposerMediaQuery = "(min-width: 0px)"; const modeHomeComposerSmUpMediaQuery = "(min-width: 640px)"; @@ -248,8 +248,8 @@ export function MasterSearchHeader({ * middle of the hero instead of docking to the bottom edge. Which widths the * hero owns is controlled by `heroComposerBreakpoint`. */ desktopHomeComposerSlotId?: string; - /** Normal-flow page slot used by submitted/search views on desktop only. - * Below lg the existing phone dock/tablet sticky composer remains the owner. */ + /** Normal-flow page slot used by submitted/search views from tablet widths up. + * Phones keep the bottom dock. */ desktopPageComposerSlotId?: string; /** Widths where the mode-home hero slot hosts the composer. "all" keeps the * hero pill on phones too (the answer home); "sm-up" reserves the hero for @@ -290,9 +290,9 @@ export function MasterSearchHeader({ * * "sticky" pins an outer stack to the viewport top above phones and still * collapses only the top-bar row inside that stack — for hosts that hand - * scrolling back to the document (GlobalSearchShell). Tablet search stays - * in that stack; desktop result search may portal into page flow, leaving - * the same stack to own only the top bar. + * scrolling back to the document (GlobalSearchShell). Tablet and desktop + * result search portal into page flow, leaving the stack to own only the + * top bar. */ wide?: "collapse" | "sticky"; /** Parent-owned hidden state for hosts that report scroll via React `onScroll`. */ @@ -1074,7 +1074,8 @@ export function MasterSearchHeader({ // part of the tree had already removed, throwing a null-parentNode error. // Because the host is stable, React's portal container never disappears. // Hero slots retain their existing all/sm-up ownership. Generic page slots - // are desktop-only so phone docks and tablet sticky composers do not change. + // start at sm so tablets and desktops share normal-flow search behaviour, + // while phone docks remain unchanged. const host = document.createElement("div"); // Layout-transparent so the composer lays out as a direct child of the slot. host.style.display = "contents"; @@ -2102,8 +2103,8 @@ export function MasterSearchHeader({ // // Above the phone breakpoint a `wide: "sticky"` host scrolls the document, // so an outer sticky stack pins its chrome below the wide safe-area spacer. - // Tablet search remains in that stack; desktop result search portals - // into page flow, so the stack contains only the top bar there. The host + // Tablet and desktop result search portal into page flow, so the stack + // contains only the top bar there. The host // ancestor uses `display: contents`, allowing this semantic sticky owner to // travel against the browser viewport and become static in standalone. const collapsingTopBar = ( @@ -2223,8 +2224,8 @@ export function MasterSearchHeader({ viewport, landing near the top of the screen. Portal it to the frame footer host on phones — the mechanism invariant 21 already requires of every phone footer — while `sm+` keeps it inline in - this sticky [top bar | search] stack, because tablet must not - double-sticky the composer. + this sticky stack; result composers have already portaled into + page flow at tablet and desktop widths. */} {phoneOverlayMotion && usesPhoneBottomDock ? ( {searchComposer} diff --git a/tests/header-scroll-hide-contract.test.ts b/tests/header-scroll-hide-contract.test.ts index 1ae85c59fb..db86f19234 100644 --- a/tests/header-scroll-hide-contract.test.ts +++ b/tests/header-scroll-hide-contract.test.ts @@ -189,11 +189,11 @@ describe("shared header hide/reveal wiring", () => { expect(headerSource).toContain("max-sm:-translate-y-full"); }); - it("moves submitted search composers into normal page flow on desktop only", () => { + it("moves submitted search composers into normal page flow on tablets and desktops", () => { expect(composerSlotSource).toContain( 'export const desktopPageComposerSlotId = "desktop-page-search-composer-slot"', ); - expect(headerSource).toContain('const desktopPageComposerMediaQuery = "(min-width: 1024px)"'); + expect(headerSource).toContain('const desktopPageComposerMediaQuery = "(min-width: 640px)"'); expect(headerSource).toContain("desktopHomeComposerSlotId ?? desktopPageComposerSlotId"); expect(headerSource).toContain('placement: "default" | "desktop-home" | "desktop-page"'); expect(headerSource).toContain("data-composer-placement={placement}"); @@ -201,13 +201,13 @@ describe("shared header hide/reveal wiring", () => { '"document-mobile-search-edge universal-top-search-edge relative z-20 mx-auto w-full max-w-3xl px-4 py-3 lg:max-w-4xl"', ); expect(shellSource).toContain('data-testid="desktop-page-search-composer-slot"'); - expect(shellSource).toContain('className="hidden lg:block lg:empty:hidden"'); + expect(shellSource).toContain('className="hidden sm:block sm:empty:hidden"'); // Dashboard result slot lives in a budget-extracted helper so ClinicalDashboard // stays under the maintainability no-growth ceiling. expect(dashboardSource).toContain("DashboardDesktopResultComposerSlot"); expect(dashboardResultComposerSlotSource).toContain('data-testid="desktop-page-search-composer-slot"'); - expect(dashboardResultComposerSlotSource).toContain('className="hidden lg:block lg:empty:hidden"'); - expect(behaviourDocSource).toContain("Desktop search is page-owned"); + expect(dashboardResultComposerSlotSource).toContain('className="hidden sm:block sm:empty:hidden"'); + expect(behaviourDocSource).toContain("Tablet and desktop search are page-owned"); }); it("collapses only the top bar and keeps the search composer outside that row", () => { @@ -415,17 +415,14 @@ describe("shared header hide/reveal wiring", () => { expect(headerSource).toContain("const usesPhoneFooterDock = usesBottomComposerPlacement && usesPhoneSearchLayout;"); }); - it("documents tablet pinning and desktop page ownership independently from the top bar", () => { + it("documents tablet and desktop page ownership independently from the top bar", () => { expect(behaviourDocSource).toContain("Hide the top bar, not the search field"); expect(behaviourDocSource).toContain("Top-bar hide/reveal is cross-breakpoint"); expect(behaviourDocSource).toContain("Every production phone navigation header has one collapse owner"); expect(behaviourDocSource).toContain("One transition, no jump"); - expect(behaviourDocSource).toContain("Do not double-sticky tablet search inside an outer sticky stack"); + expect(behaviourDocSource).toContain("Do not sticky-position tablet or desktop result search"); expect(behaviourDocSource).toContain("desktop-page-search-composer-slot"); expect(behaviourDocSource).toContain("Release the phone top inset with collapsing chrome"); - expect(behaviourDocSource).toContain( - "Collapse-everywhere hosts still drop their own sticky search offset while the top bar is hidden", - ); }); it("does not carry dock focus into GlobalSearchShell submitted result views", () => { diff --git a/tests/ui-chrome-scroll.spec.ts b/tests/ui-chrome-scroll.spec.ts index 82c8e4ac9c..2f134a92ee 100644 --- a/tests/ui-chrome-scroll.spec.ts +++ b/tests/ui-chrome-scroll.spec.ts @@ -216,7 +216,7 @@ for (const { name: sizeName, viewport } of breakpoints) { expect(atTop.hidden, "top bar visible at the top").toBe(false); expect(atTop.headerTop, "top bar starts at the viewport top").toBeLessThanOrEqual(8); expect(atTop.searchVisible, "search starts on screen").toBe(true); - if (sizeName === "desktop") { + if (sizeName === "tablet" || sizeName === "desktop") { expect(atTop.searchPlacement).toBe("desktop-page"); expect(atTop.searchInsideDesktopPageSlot).toBe(true); expect(atTop.searchHasStickyAncestor).toBe(false); @@ -230,15 +230,7 @@ for (const { name: sizeName, viewport } of breakpoints) { expect(scrolledDown.offset, "descent moved the scroller").toBeGreaterThan(requiredRunway - 200); expect(scrolledDown.hidden, "top bar hides on a deliberate scroll down").toBe(true); expect(scrolledDown.headerBottom, "hidden top bar is off the top of the viewport").toBeLessThanOrEqual(0); - if (sizeName === "tablet") { - expect(scrolledDown.searchVisible, "header search stays on screen while the top bar is hidden").toBe(true); - expect( - scrolledDown.searchTop, - "header search sits near the viewport top after the top bar collapses", - ).toBeLessThanOrEqual(24); - } else { - expect(scrolledDown.searchVisible, "desktop page search scrolls away with page content").toBe(false); - } + expect(scrolledDown.searchVisible, "page search scrolls away with page content").toBe(false); // Three deliberate upward steps — nowhere near the top of the page. await scrollBy(page, -360, 120); @@ -249,16 +241,12 @@ for (const { name: sizeName, viewport } of breakpoints) { expect(scrolledUp.hidden, "top bar returns on a deliberate scroll up").toBe(false); expect(scrolledUp.headerBottom, "returned top bar is actually on screen").toBeGreaterThan(0); expect(scrolledUp.headerTop, "returned top bar sits at the viewport top").toBeLessThanOrEqual(8); - if (sizeName === "tablet") { - expect(scrolledUp.searchVisible, "header search remains on screen after the top bar returns").toBe(true); - } else { - expect(scrolledUp.searchVisible, "returning the desktop top bar does not re-anchor page search").toBe(false); - } + expect(scrolledUp.searchVisible, "returning the top bar does not re-anchor page search").toBe(false); }); test(`${sizeName}: search composer keeps its breakpoint owner on ${surfaceName}`, async ({ page }) => { - // data-scroll-hidden is reserved for the phone dock. Tablet search stays - // pinned; desktop search leaves by ordinary page scrolling instead. + // data-scroll-hidden is reserved for the phone dock. Tablet and desktop + // search leave by ordinary page scrolling instead. await page.setViewportSize(viewport); await page.goto(route, { waitUntil: "domcontentloaded" }); await expect(page.locator("header#search").first()).toBeVisible({ timeout: 15_000 }); @@ -277,7 +265,7 @@ for (const { name: sizeName, viewport } of breakpoints) { "no composer flips data-scroll-hidden above the phone breakpoint", ).toBe(0); if (sizeName === "tablet") { - expect(scrolledDown.searchVisible, "header search geometry stays on screen").toBe(true); + expect(scrolledDown.searchVisible, "tablet page search scrolls away with page content").toBe(false); } else { expect(atTop.searchInsideDesktopPageSlot).toBe(true); expect(scrolledDown.searchVisible, "desktop composer follows page flow off-screen").toBe(false); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 385f06cb4a..f5967efdb1 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -984,15 +984,6 @@ test.describe("Clinical KB tools launcher", () => { // bottom (safe-area is padding inside the form, not a `bottom` gap). expect(metrics?.formBottom ?? 0).toBeGreaterThanOrEqual(viewport.height - 2); } - } else if (viewport.width < 1024) { - // Sticky-stack shells pin via an outer wrapper; collapse-everywhere hosts - // still self-sticky the form. Tablet behaviour stays unchanged. - expect( - metrics?.position === "sticky" || metrics?.stickyAncestor, - `${route.path} at ${viewport.name} should stick via the form or its sticky stack`, - ).toBe(true); - expect(metrics?.formCenterY ?? viewport.height).toBeLessThan(viewport.height * 0.25); - await expect(page.locator(".answer-footer-search-chip:visible")).toHaveCount(0); } else { expect(metrics?.composerPlacement).toBe("desktop-page"); expect(metrics?.insideDesktopPageSlot).toBe(true); From df255ee5bf8269cca32b5ef426d1c37c1f7bcebc Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:24:46 +0800 Subject: [PATCH 2/6] test: describe tablet search as normal flow --- tests/ui-chrome-scroll.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/ui-chrome-scroll.spec.ts b/tests/ui-chrome-scroll.spec.ts index 2f134a92ee..e001b1242e 100644 --- a/tests/ui-chrome-scroll.spec.ts +++ b/tests/ui-chrome-scroll.spec.ts @@ -8,12 +8,12 @@ import { readPrimaryScrollGeometry } from "./playwright-scroll"; * * 1. Above the phone breakpoint the top bar used to scroll away with the page * and only return at the very top (no scroll reporter / sticky travel). - * 2. Search must stay pinned on tablets but belong to normal page flow on - * desktop, where it scrolls away independently from the sticky top bar. + * 2. Search belongs to normal page flow on tablets and desktops, where it + * scrolls away independently from the sticky top bar. * * The load-bearing assertions are (a) the reveal *mid-page* and (b) the search - * input staying pinned on tablet but leaving the viewport on desktop. Asserting only - * `data-scroll-hidden` would miss both. + * input leaving the viewport while the top bar can return mid-page. Asserting + * only `data-scroll-hidden` would miss both. * * The suite-wide `reducedMotion: "reduce"` is kept deliberately: the chrome * carries `motion-reduce:transition-none`, so geometry settles in one frame and From 2537c987d26d41562dd1abe90e6e6dae3b899cbf Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:24:54 +0800 Subject: [PATCH 3/6] docs: record tablet header review --- 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 ff8cdf6cff..b423b51548 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -172,3 +172,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-30 | claude/latency-findings-impl-s8g01v | e7ff5e933ba1f34d5adbd46dd77c38aced11ed44 | open PR changed-scope review | APPROVE: ordering-risk documentation is accurate and the near-bottom refusal guard now proves its geometry is non-vacuous before asserting no hide. | diff check PASS; focused test review; no unresolved threads; exact-head Production UI required | | 2026-07-30 | claude/ci-testing-review-2l8klp | 2e2160bc8b9d2d824209c217c67cb9cac1be3a8d | open PR changed-scope review | APPROVE: three-way UI sharding, critical-first gating, measured drag travel, and gate-manifest updates preserve required-check aggregation and deterministic Playwright settings. | check:github-actions PASS; check:ci-scope PASS; check:gate-manifest PASS; ledger guards PASS; exact-head sharded Production UI required | | 2026-07-30 | PR #1430 | a9ae22ac4915e86d51ee05787059382a39bd8ba8 | phone chrome diagnostics and merge repair | fixed and ready for CI | issues guard; ledger guard; 37 focused tests; phone-chrome dry-run | +| 2026-07-30 | PR-1435 | df255ee5bf8269cca32b5ef426d1c37c1f7bcebc | PR #1435 tablet search header flow | tablet composer now follows desktop normal-flow ownership; stale pinning assertion corrected; no remaining findings | 29 focused tests pass; typecheck and formatting pass; focused Chromium route 1 passed | From 2cea044352dd67e2fde9784178272f423db1c8dc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 00:27:10 +0000 Subject: [PATCH 4/6] fix(ui): tighten tablet page-flow search guards Assert tablet result search starts in the page slot in the chrome-scroll owner test, and update sticky-stack comments to match sm+ page-flow ownership. Co-authored-by: BigSimmo --- .../global-search-shell.tsx | 2 +- .../master-search-header.tsx | 24 ++++++++++--------- tests/ui-chrome-scroll.spec.ts | 8 ++----- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 6893161ac2..a7ef0d99d6 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -945,7 +945,7 @@ function GlobalStandaloneSearchShellBody({ Subnav (SpecifierSubnav / FormulationSubnav), so the shared mode bar is skipped for them to avoid a duplicate row on their workflow routes. Rendered in normal flow (sticky={false}) so it never contends with - the universal collapsing header / pinned search chrome. + the universal collapsing header or page-flow search chrome. */} {searchMode !== "specifiers" && searchMode !== "formulation" ? ( Date: Fri, 31 Jul 2026 00:33:08 +0000 Subject: [PATCH 5/6] docs: record PR #1435 tablet header readiness review Append the closed-PR readiness review for the current tip after Bugbot triage, main sync, and local chrome verification. Co-authored-by: BigSimmo --- 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 b9a597ce4c..c8ac094a20 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -330,3 +330,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-31 | claude/root-dir-coverage-gate-v2 | 398660144d93aeefc2e5649c156948a68925cb64 | docs:check-index repo-root coverage, stale script counts, ledger correction | MERGED as PR #1458 (squash 907fd9f4a). Root-directory coverage pass for docs:check-index, red-then-green proven (flagged .cursor/.design-sync/.vscode, then 49 entries vs 31). Main landed an equivalent pass independently in #1480, so the two overlapped; no duplication reached main. Row not recorded at the time - appended retrospectively | verify:cheap exit 0, 435 test files / 4574 tests pass; codebase-index-coverage 10/10 incl 4 new root cases; eslint clean; docs gates green; prettier clean | | 2026-07-31 | claude/pre-commit-fail-open | 7b96a09b8500adc917cf5549b1c61142b2244b39 | pre-commit hook fail-open when the inventory script is absent | MERGED as PR #1494 (squash 387c3b653). Resolves ledger #153: core.hooksPath is absolute to the primary checkout, so the hook ran in worktrees lacking scripts/update-docs-inventory.mjs and aborted with MODULE_NOT_FOUND. Guard drops the inventory task and re-checks the all-tasks-empty exit; grep carries \|\| true because set -e treats a fully-filtering grep as failure | isolated-repo probe with the script genuinely absent: prints skipping inventory sync, commit succeeds; sh -n clean; no-op when the script is present; prettier does not parse shell so format:check skips it | | 2026-07-31 | claude/ledger-relanding | 30ec06964e4235d9f0b4bb782f357e6b4fb59430 | re-land the three session findings lost when PR #1490 was closed | MERGED as PR #1508 (squash 7b551abc4). Ledger-only: #151 corrects the claim that CI is unreadable (PAT has Actions:read though not Checks:read), #152 re-lands the at-risk worktree inventory with the four preservation snapshots, #153 archives the hook fix. Verified landed by content on main, not by PR state or row id | CI, PR Policy, PR mergeability, SAST, Secret Scan all completed/success via the Actions API; check:outstanding-issues 151 rows 45 open unique ids next-id=154; docs:check-links 1414 refs; prettier clean | +| 2026-07-31 | codex/fix-header-behavior-on-medium-screens (PR #1435) | 2cea044352dd67e2fde9784178272f423db1c8dc | PR #1435 tablet search header readiness | READY closed; mergeable vs main; Bugbot none; fixed tablet page-slot assert + stale sticky comments; no P0/P1 | vitest header contracts 61/61; playwright ui-chrome-scroll 17/17; composer ownership 15/15; verify:cheap 2 env-only codex-cloud origin insteadOf fails; prior head CI green | From 936562e732034f74ef28f5aaddbce604f568d1c0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 31 Jul 2026 04:59:09 +0000 Subject: [PATCH 6/6] docs: record PR #1435 review+bugbot+fix after main sync Ledger the conflict resolution and empty Bugbot/thread pass at the post-merge tip. Co-authored-by: BigSimmo --- 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 6b6d73cd31..7206d145b9 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -508,3 +508,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-31 | PR-1520 | 6e6998464a6996a66fdaaadcd482388e39af611e | PR #1520 branch and worktree reconciliation records | APPROVE; 146 historical cleanup dispositions retained as ledger-only evidence with no repository mutation | check:branch-review-ledger PASS 444 live 1206 archived; diff check PASS; current-main merge clean | | 2026-07-31 | claude/issues-133-evidence | 5bb1bc8d8b1d3ba8aebdce5c348887c596f6b8e6 | docs/outstanding-issues.md: re-land #154 (id-allocation hazard) and #155 (--med-accent-soft) after PR #1506 closed unmerged | Recorded. Branch synced to origin/main; main had since taken #151 so the hazard row moved to #154 and --med-accent-soft landed as #155 (its fifth renumber) - both self-demonstrating the row's own claim. PR #1506 to be reopened by the user. | check:outstanding-issues exit 0 (153 rows, 45 open, 108 archived, unique ids, next-id=156, no ids deleted from base); verified zero origin/main ids lost after taking main's table as canonical; pre-push guard passed on pushed commit | | 2026-07-31 | claude/issues-133-evidence | 37f71f02f731175e4fed500f95529c3ef9eb568f | PR #1506 reopen prep: sync main, renumber hazard to #155, supersede #112 residual | READY — conflict cleared vs origin/main; main #154 preserved; hazard=#155 with archived #112 residual cross-link; med-accent=#156; false #155 evidence clause removed; Codex P2 addressed; Bugbot P1/P2 fixed; PR left CLOSED | check:outstanding-issues 154 rows/46 open next-id=157; check:branch-review-ledger 277 live; merge-tree clean da0c63d0; format no-op | +| 2026-07-31 | codex/fix-header-behavior-on-medium-screens | f1083c808473904152eaf92b78ad03a3213782aa | PR #1435 review+bugbot+fix | READY: merged main (real conflict in search-chrome-behaviour resolved); Bugbot 0; no P0/P1; no actionable threads | merge-tree clean; header-scroll-hide-contract 29/29; prior tip CI blocked by dirty; Bugbot/reviewThreads empty |