Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -979,5 +979,6 @@ 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. |
| 2026-07-25 | PR #1213 / `cursor/pr1188-fix-build-breakers-6ee0` | `c45189c76502050002e579cf2038157416a65f2d` | Deduplicate ledger rows before land | Removed 2 exact duplicate table row(s) introduced by union-merge churn (no unique review content lost). Production UI green after Sheet autofocus upgrade. | check:branch-review-ledger; hosted Production UI success on tip. |
| 2026-07-25 | PR #1213 / `cursor/pr1188-fix-build-breakers-6ee0` (supersedes #1188) | merge `8e3a49d0449ec2c1b8e4e10f6cd500c0c8c9550b` | prlanded after safe close+#1213 land | LANDED. #1188 CLOSED (not merged). #1213 squash-merged to main after PR required green (Production UI/Static/Unit/Build/Migration). Content verified on main: dashboard-notices extract, indexing-v3 utils `export async function sha256Hex`, Sheet autofocus defense. Remote feature branch deleted by merge. | Hosted CI run 30165248633 success; gh pr merge squash; git cat-file content checks on origin/main. No provider calls. |
25 changes: 24 additions & 1 deletion docs/search-chrome-behaviour.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,32 @@ 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.
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.

## 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) | `<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 |

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 `<main>`'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:
Expand All @@ -32,4 +54,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.
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|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
Expand Down
24 changes: 13 additions & 11 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -344,13 +344,13 @@ export function ClinicalDashboard({
const [answerThreadBootstrapped, setAnswerThreadBootstrapped] = useState(false);
const [query, setQuery] = useState(initialQuery);
const [searchMode, setSearchMode] = useState<AppModeId>(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 <main>'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"),
);
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -3356,12 +3356,14 @@ export function ClinicalDashboard({
// Answer view: the header overlays the scrolling <main> 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 <main> 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}
/>
Expand Down
37 changes: 27 additions & 10 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -215,12 +219,17 @@ function GlobalStandaloneSearchShellClient({
const searchParams = useSearchParams();
const inputRef = useRef<HTMLInputElement>(null);
const [mainElement, setMainElement] = useState<HTMLDivElement | null>(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;
Expand Down Expand Up @@ -527,7 +536,7 @@ function GlobalStandaloneSearchShellClient({

function handleMainScroll(event: UIEvent<HTMLDivElement>) {
const target = event.currentTarget;
phoneScrollHide.reportScroll({
chromeScrollHide.reportScroll({
offset: target.scrollTop,
maxOffset: Math.max(0, target.scrollHeight - target.clientHeight),
collapseBudget: readChromeCollapseBudget(target),
Expand All @@ -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
Expand Down Expand Up @@ -623,7 +632,13 @@ function GlobalStandaloneSearchShellClient({
) : null}

<div className="flex min-w-0 flex-col max-sm:h-full max-sm:min-h-0 max-sm:overflow-hidden sm:min-h-dvh">
<div className={mobileChromeVisible ? undefined : "hidden lg:block"}>
{/*
`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).
*/}
<div className={mobileChromeVisible ? "sm:contents" : "hidden lg:contents"}>
<MasterSearchHeader
demoMode={clientDemoMode}
documents={[]}
Expand Down Expand Up @@ -703,9 +718,11 @@ function GlobalStandaloneSearchShellClient({
// scrolls with the content, matching the answer home rather than
// docking to the bottom edge.
heroComposerBreakpoint="all"
// Phone-only: #main-content owns vertical scroll, so hide-on-scroll
// Phones: #main-content owns vertical scroll, so hide-on-scroll
// collapses the header/composer to hand space back to content.
hideOnScroll={{ strategy: "collapse", scrollHidden: phoneScrollHide.hidden }}
// Tablet/desktop: the document scrolls, so the chrome sticks to the
// viewport top and slides away instead of releasing flow space.
hideOnScroll={{ strategy: "collapse", wide: "sticky", scrollHidden: chromeScrollHide.hidden }}
onBottomComposerHiddenChange={setBottomComposerHidden}
queryInputAutoFocus={searchParams.get("focus") === "1"}
/>
Expand Down
Loading
Loading