Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
641c822
feat(ui): portal Therapy section nav into header collapse on phones
BigSimmo Jul 25, 2026
086c1dc
test(ui): contract and Playwright proof for Therapy nav collapse
BigSimmo Jul 25, 2026
69338e7
test: avoid false positive on max-sm translate-y contract
BigSimmo Jul 25, 2026
a594188
test(ui): prove tablet/desktop search stays while the top bar hides
BigSimmo Jul 25, 2026
f1f6b43
style: prettier format chrome hide and Therapy nav files
BigSimmo Jul 25, 2026
afb0ab2
test: tolerate prettier whitespace in header sticky contract
BigSimmo Jul 25, 2026
e3785be
fix(ui): drop sticky search top offset when top bar collapses
BigSimmo Jul 25, 2026
64503ba
test: contract sticky search top offset drops when top bar hides
BigSimmo Jul 25, 2026
eb1d9a9
Merge remote-tracking branch 'origin/main' into cursor/header-hide-to…
BigSimmo Jul 25, 2026
278cbe3
Merge branch 'main' into cursor/header-hide-top-bar-only-4fd7
github-actions[bot] Jul 25, 2026
57e7a27
test(ui): read Therapy canvas colour after phone nav portal
BigSimmo Jul 25, 2026
bb164e3
fix(ui): stop double-sticky search overlay and harden services Clear
BigSimmo Jul 25, 2026
7aceb32
fix(ui): keep sticky-stack search out of fixed positioning above phones
BigSimmo Jul 25, 2026
9419938
Merge branch 'main' into cursor/header-hide-top-bar-only-4fd7
github-actions[bot] Jul 25, 2026
c232c53
fix(ui): stop sticky-stack search overlay and update UI assertions
BigSimmo Jul 25, 2026
12a2125
Merge remote tip into header-hide-top-bar-only branch
BigSimmo Jul 25, 2026
94578f8
Merge branch 'main' into cursor/header-hide-top-bar-only-4fd7
github-actions[bot] Jul 25, 2026
79dcee5
chore: re-trigger CI after main sync left workflows pending approval
BigSimmo Jul 25, 2026
1280fcd
docs(ui): note sticky search can overlap wide decision rails
BigSimmo Jul 25, 2026
2321287
Merge branch 'main' into cursor/header-hide-top-bar-only-4fd7
BigSimmo Jul 25, 2026
fdc20bf
fix(test): account for safe-area spacer in Therapy nav hide assert
BigSimmo Jul 25, 2026
db4390b
docs(ledger): record PR #1238 header top-bar-only hide CI green
BigSimmo Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/branch-review-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
28 changes: 16 additions & 12 deletions docs/search-chrome-behaviour.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,35 +19,39 @@ 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.
10. Detect reserve-transition clamps from geometry, not a wider pixel tolerance: if the scroll range shrinks and the previous offset no longer fits inside the new maximum, rebase that frame as layout feedback. Once the range stabilizes, the same upward movement must reveal normally.

## 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) | `<main>` at every width | `strategy: "overlay", allBreakpoints` | Absolute glass bar translates off; `<main>` keeps its top reserve |
| `ClinicalDashboard` (other modes) | `<main>` 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) | `<main>` at every width | `strategy: "overlay", allBreakpoints` | Absolute glass top bar translates off; `<main>` keeps its top reserve; search stays |
| `ClinicalDashboard` (other modes) | `<main>` 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 `<main>`'s header reserve); otherwise the carried-over offset spends the first post-switch scroll on a spurious hide or reveal.
- **Do not carry composer focus into submitted result views.** Focus pins both chrome edges for keyboard safety. `GlobalSearchShell` must not pass `focus: true` with `run: true`, must gate `queryInputAutoFocus` on `!hasSubmittedModeSearch`, and must blur the dock input when the result canvas scrolls so hide-on-scroll can reclaim the header and the bottom dock (including its white safe-area rail).

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

Expand Down
4 changes: 2 additions & 2 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand Down
Loading
Loading