diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 767536970..ea41fcd9a 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1034,3 +1034,4 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-25 | origin/execute-audit-remediation-plan (PR #1188 closed tip) | `8b8639113925601e1687bfe4f1f29c44a4308b61` | branch-cleanup | DELETED remote. Tip rejected (conflict markers + parse breakers); intentional maintainability work already on main via #1213 (`8e3a49d0`). IMP-04 mockup/export prune from tip commit `3bc391dff` was not ported (knip-only unexports; optional follow-up). Local Antigravity worktrees left untouched. | Content proof: notices/utils/Sheet autofocus on origin/main; tip marker count 12; `git push origin --delete execute-audit-remediation-plan`. No provider calls. | | 2026-07-25 | PR #1195 / `subagent-Asset-Optimization-Implementer-self-b295a5bb` | `c612c88aae386a29715cadfb5dbd2b95163e1dc9` | prlanded after squash merge | MERGED. Squash tip `c612c88aa` on main; remote feature branch deleted. Product content verified on main (`check:assets`, icon Cache-Control, SignedImage/non-PDF a11y). Local review worktree retained for optional cleanup. | gh pr view MERGED; two-dot product-file diff empty vs origin/main; no provider checks. | +| 2026-07-26 | PR #1238 / `cursor/header-hide-top-bar-only-4fd7` | `fdc20bfedb61a2c267c22a3d78ccc8214e6c0087` | Continue-executing: top-bar-only hide + CI green | APPROVE / MERGEABLE. Root cause fixed: collapse wraps only `header#search` (+ Therapy addon); sticky hosts pin outer [top bar \| search] below `chrome-safe-area-top` without translating search away; sticky-stack composers stay `relative`. Services rail overlay hardened (testids + center scrollIntoView); ui-tools accepts sticky ancestor; Therapy nav assert uses collapse-host top under safe-area spacer. Merged main safe-area + submitted-result focus rules. | Hosted PR required + Production UI SUCCESS on tip; contract 14/14; focused Playwright services/desktop composers 6/6, chrome-scroll 12/12, therapy-nav 1/1. No provider-backed checks. | diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 0cdb00540..fd53d8d66 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -19,7 +19,7 @@ This repo uses one shared search experience across the global shell, dashboard r 3. A visible fixed phone dock may include `var(--safe-area-bottom)` so the pill clears the home indicator. 4. A hidden phone dock must release the content-facing reserve to `0rem`; do not use `env(safe-area-inset-bottom)` or `var(--safe-area-bottom)` for hidden content padding. 5. Edge-to-edge phone dock mode is `left: 0; right: 0; bottom: 0; width: 100%`; inset the pill with padding, not with a non-zero bottom offset. -6. Header and footer chrome that share the same scroll signal should hide/reveal symmetrically: when the chrome controls are hidden, underlying content may reclaim that chrome height. The OS top safe-area band (`chrome-safe-area-top` / `var(--safe-area-top)`) is never released — scrolled text must not paint under the status-bar icons. Hidden bottom dock reserve stays `0rem` (invariant 4). Header hide/reveal is cross-breakpoint; the bottom search dock is phone-only. Read "Scroll hide/reveal" below before changing either. +6. Header and footer chrome that share the same scroll signal should hide/reveal symmetrically for the surfaces that actually hide: when the top bar is hidden, underlying content must be visible to the viewport edge. The OS top safe-area band (`chrome-safe-area-top` / `var(--safe-area-top)`) is never released — scrolled text must not paint under the status-bar icons. Top-bar hide/reveal is cross-breakpoint; the search field stays on tablet/desktop; 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. Keep collapse-budget policy geometry-aware: an in-flow collapsing header needs enough remaining runway to absorb header + dock clearance, while a fixed overlay that only releases bottom reserve may hide when its post-collapse range retains the top reveal band plus deliberate hide intent _and_ the current offset already fits that post-collapse range (no material near-bottom clamp). Do not use synthetic page padding to make the stricter gate pass. @@ -27,27 +27,31 @@ This repo uses one shared search experience across the global shell, dashboard r ## 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. +The universal **top bar** (mode, new chat, menu) hides on a deliberate scroll down and returns on a deliberate scroll up at **every** breakpoint. The search field does **not** hide with it on tablet or desktop — only the phone bottom search dock still scroll-hides, and that stays phone-only. Both the top bar and the 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) | `
` 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 top bar translates off; `
` keeps its top reserve; search stays | +| `ClinicalDashboard` (other modes) | `
` at every width | `strategy: "collapse", wide: "collapse"` | Top-bar 1fr -> 0fr grid row; released strip goes to content; search stays as a sibling | +| `GlobalSearchShell` | `#main-content` on phones, the document above | `strategy: "collapse", wide: "sticky"` | Sticky stack of [top bar \| search]; only the top-bar row collapses so search rises to the top | Rules that keep this working: +- **Hide the top bar, not the search field, above phones.** The collapse wrapper (`data-testid="universal-header-collapse"`) wraps `header#search` plus optional page chrome that must match top-bar hide/reveal (via `headerCollapseAddonSlotId`). Putting the inline search composer inside that wrapper is what made tablet/desktop search disappear with the mode bar — keep composers outside the collapse row. +- **Page chrome that must match the top bar portals into the collapse host.** Do not pin tool secondary nav with `position: sticky` inside `#main-content` on phones — after the header collapses that sticky row becomes a second stuck header. Therapy's section strip portals into `#therapy-header-collapse-addon-slot` below `max-sm` and stays in-flow sticky above that. - **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, and the collapse path returns a fragment (safe-area spacer + collapse wrapper) rather than a single root box. -- **Keep `chrome-safe-area-top` outside the hide mechanism.** Collapse hosts render an always-on `h-[var(--safe-area-top)]` spacer sibling; the 0fr grid / `-translate-y-full` only moves the controls. Sticky chrome pins at `top: var(--safe-area-top)` so the spacer stays put while the bar slides away. -- **Transform only while hidden.** A standing transform on the wrapper would become the containing block for the fixed-position menus and composers rendered inside it. +- **Sticky 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 stack's ancestor in `GlobalSearchShell` is `display: contents` above the phone breakpoint rather than a block, and collapse returns a fragment (safe-area spacer + stack) rather than a single root box. +- **Collapse only the top-bar row inside a sticky stack.** 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 always-on safe-area spacer. +- **Keep `chrome-safe-area-top` outside the hide mechanism.** Collapse hosts render an always-on `h-[var(--safe-area-top)]` spacer sibling; the 0fr grid only moves the top-bar controls. Sticky chrome pins at `top: var(--safe-area-top)` so the spacer stays put while the bar collapses. +- **Do not double-sticky the search inside an outer sticky stack.** When `wide: "sticky"` owns an outer 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. +- **Pinned search can cover wide side rails.** The sticky stack stays centred and can geometrically overlap `xl` decision rails (Services). Prefer `scroll-margin-top` on rail controls and centre scroll-into-view in tests; do not put the composer back to `fixed`/`sticky` inside the stack to “win” that fight. +- **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. - **Rebase the reporter on geometry switches.** Pass `resetKey` when the host changes the scrollport under it (`ClinicalDashboard` passes `searchMode`, which swaps `
`'s header reserve); otherwise the carried-over offset spends the first post-switch scroll on a spurious hide or reveal. - **Do not carry composer focus into submitted result views.** Focus pins both chrome edges for keyboard safety. `GlobalSearchShell` must not pass `focus: true` with `run: true`, must gate `queryInputAutoFocus` on `!hasSubmittedModeSearch`, and must blur the dock input when the result canvas scrolls so hide-on-scroll can reclaim the header and the bottom dock (including its white safe-area rail). -Coverage: `tests/header-scroll-hide-contract.test.ts` (wiring), `tests/use-hide-on-scroll.test.ts` (decision logic), `tests/ui-chrome-scroll.spec.ts` (tablet/desktop hide and reveal), `tests/ui-phone-scroll.spec.ts` (phone scroll geometry). +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 top-bar hide/reveal with search still visible), `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). ## Change checklist diff --git a/playwright.config.ts b/playwright.config.ts index f2b65893c..00378f9d9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,14 +16,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|chrome-scroll|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|therapy-nav-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|chrome-scroll|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|therapy-nav-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/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 8a62a05f3..9dd4a3d04 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -57,7 +57,11 @@ import { import { isLocalNoAuthMode, resolveClientDemoMode } from "@/lib/client-env"; import { documentsSearchHref } from "@/lib/document-flow-routes"; import { isInformationPage } from "@/lib/information-pages"; -import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; +import { + differentialsMobileCompareAddonSlotId, + modeHomeDesktopComposerSlotId, + therapyHeaderCollapseAddonSlotId, +} from "@/lib/mode-home-composer"; import { readSearchNavigationContext, type SearchNavigationOptions } from "@/lib/search-navigation-context"; import { shouldRenderClinicalDashboard, shouldRenderDashboardSearch } from "@/lib/search-route-ownership"; import type { SearchScopeFilters } from "@/lib/search-scope"; @@ -730,6 +734,13 @@ function GlobalStandaloneSearchShellClient({ mobileBottomSearchAddonSlotId={ differentialsCompareAddonActive ? differentialsMobileCompareAddonSlotId : undefined } + // Therapy non-home screens portal their section strip into the + // collapsing top-bar track so it hides/reveals with the header. + headerCollapseAddonSlotId={ + pathname.startsWith("/therapy-compass") && pathname !== "/therapy-compass" + ? therapyHeaderCollapseAddonSlotId + : undefined + } desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"} searchComposerVisible={shouldShowSearchComposer} desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} @@ -739,9 +750,10 @@ function GlobalStandaloneSearchShellClient({ // docking to the bottom edge. heroComposerBreakpoint="all" // Phones: #main-content owns vertical scroll, so hide-on-scroll - // collapses the header/composer to hand space back to content. - // Tablet/desktop: the document scrolls, so the chrome sticks to the - // viewport top and slides away instead of releasing flow space. + // collapses the top bar to hand space back to content. + // Tablet/desktop: the document scrolls, so an outer sticky stack + // pins [top bar | search] and only the top-bar row collapses — + // translating the whole stack would take the search field with it. hideOnScroll={{ strategy: "collapse", wide: "sticky", scrollHidden: chromeScrollHide.hidden }} onBottomComposerHiddenChange={setBottomComposerHidden} queryInputAutoFocus={requestedFocus && !hasSubmittedModeSearch} diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index dd50c3237..09d18a4e2 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -185,6 +185,7 @@ export function MasterSearchHeader({ desktopHomeComposerSlotId, heroComposerBreakpoint = "all", mobileBottomSearchAddonSlotId, + headerCollapseAddonSlotId, mobileLeadingAction = "menu", onMobileBack, hideOnScroll, @@ -249,6 +250,12 @@ export function MasterSearchHeader({ heroComposerBreakpoint?: "all" | "sm-up"; /** Mobile/tablet slot rendered above the search pill for page-specific composer addons. */ mobileBottomSearchAddonSlotId?: string; + /** + * Optional host rendered inside the collapsing top-bar track (after `header#search`) + * for page chrome that must hide/reveal with the universal top bar — e.g. Therapy + * section nav on phones. Keep search composers out of this slot. + */ + headerCollapseAddonSlotId?: string; mobileLeadingAction?: "menu" | "back"; onMobileBack?: () => void; /** Phone-only hide-on-scroll for the universal header and bottom search dock. @@ -270,14 +277,15 @@ export function MasterSearchHeader({ * 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 + * "collapse" releases the top bar'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. + * "sticky" pins an outer stack (top bar + search) 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). The + * search composer must stay outside the collapsing row so tablet/desktop + * keep a usable search field. */ wide?: "collapse" | "sticky"; /** Parent-owned hidden state for hosts that report scroll via React `onScroll`. */ @@ -1374,6 +1382,18 @@ export function MasterSearchHeader({ ); } + const hideStrategy = hideOnScroll?.strategy; + // Overlay hosts that opt into all breakpoints take the header fully out of + // 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"; + function renderSearchComposer(placement: "default" | "desktop-home") { const isDesktopHomeComposer = placement === "desktop-home"; const usesAnswerFooterStyle = isAnswerFooterComposer && !isDesktopHomeComposer; @@ -1411,6 +1431,18 @@ export function MasterSearchHeader({ const showsComposerPrivacyNotice = usesPhoneSearchLayout ? isDesktopHomeComposer : true; const commandSurfacePlacement = usesBottomComposerPlacement ? "bottom-dock" : "inline"; + // Search sits outside the collapsing top-bar row. Sticky hosts already pin an + // outer [top bar | search] stack, so the composer must stay in normal flow + // inside that stack — a second sticky + top offset would overlay page + // controls once the top bar collapses. Collapse-everywhere hosts have no + // outer stack, so their composer keeps its own sticky and drops the top-bar + // clearance while the bar is hidden. + const stickySearchOwnedByOuterStack = sticksAbovePhones; + const stickySearchClearsTopBar = !(hideStrategy === "collapse" && headerChromeHidden); + const stickySearchTopClass = stickySearchClearsTopBar + ? "top-[calc(4.75rem+env(safe-area-inset-top))] sm:top-[calc(4.75rem+env(safe-area-inset-top))]" + : "top-0 sm:top-0"; + const stickySearchPositionClass = cn("sm:sticky", stickySearchTopClass); return (
, 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), @@ -1732,345 +1760,350 @@ export function MasterSearchHeader({ } : undefined; - const headerAndComposer = ( - <> - ); + const searchComposer = searchComposerVisible ? ( + <> + {(desktopHomeComposerActive && desktopHomeComposerHost) || + (desktopHomeComposerSlotId && !desktopHomeComposerFallback) + ? null + : renderSearchComposer("default")} + {desktopHomeComposerActive && desktopHomeComposerHost + ? createPortal(renderSearchComposer("desktop-home"), desktopHomeComposerHost) + : null} + + ) : null; + if (hideStrategy === "collapse") { - // 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. + // Collapse hide-on-scroll applies to the TOP BAR only (mode / new chat). The + // search composer stays a sibling so tablet/desktop keep a usable search + // field while the top bar hides. A 1fr -> 0fr grid row animates the collapse + // without height measurement; the bottom-anchored inner track slides the + // top bar up out of the viewport. Fixed-position phone docks escape this + // tree via `position: fixed`; hero composers portal out. // - // 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 below the always-on - // safe-area spacer and translates away. Releasing the row there would pull - // the whole page up by the header height mid-scroll; translating leaves the - // geometry alone. - // - // Return a fragment (never a wrapping block): GlobalSearchShell uses - // `sm:contents` on the chrome parent so the sticky wrapper can travel - // against the viewport. A single root box would recreate the zero-travel - // sticky bug. - return ( - <> - {/* - OS status-bar inset stays outside the 0fr collapse / -translate hide. - Hide-on-scroll may reclaim the chrome controls, never the safe-area - band — otherwise scrolled content paints under the system clock/signal - icons (service detail / info pages on notched phones). - */} -