From 3c402629b01a73b2a1a26f260de1ac9799271ac7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 04:00:56 +0000 Subject: [PATCH 01/16] fix(ui): synchronize mobile answer chrome Co-authored-by: BigSimmo --- docs/search-chrome-behaviour.md | 1 + src/components/ClinicalDashboard.tsx | 4 +- .../global-search-shell.tsx | 6 +- .../master-search-header.tsx | 24 ++--- .../mobile-composer-reserve.ts | 2 +- .../clinical-dashboard/use-hide-on-scroll.ts | 64 +++++++++---- tests/mobile-composer-reserve.test.ts | 2 +- tests/ui-smoke.spec.ts | 89 +++++++++++++++++++ tests/use-hide-on-scroll.test.ts | 34 +++++++ 9 files changed, 193 insertions(+), 33 deletions(-) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 54835a095b..c1200edea1 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -22,6 +22,7 @@ This repo uses one shared search experience across the global shell, dashboard r 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. 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 still clears the activation band. Do not use synthetic page padding to make the stricter gate pass. ## Change checklist diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index afd19e124d..43b0928366 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -90,7 +90,7 @@ import { import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { FavouritesGuestGate } from "@/components/clinical-dashboard/favourites-guest-gate"; import { useDashboardShellActions } from "@/components/clinical-dashboard/use-dashboard-shell-actions"; -import { readChromeCollapseBudget, useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll"; +import { readChromeCollapseMetrics, useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { answerReferencesDocument, @@ -2804,7 +2804,7 @@ export function ClinicalDashboard({ reportPhoneScrollHideRef.current({ offset: main.scrollTop, maxOffset: Math.max(0, main.scrollHeight - main.clientHeight), - collapseBudget: readChromeCollapseBudget(main), + ...readChromeCollapseMetrics(main), source: main, }); }); diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 4a14616795..8a4f45e6fe 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -33,7 +33,7 @@ import { resolveMobileComposerReserve, resolveShellVisibleMobileComposerReserve, } from "@/components/clinical-dashboard/mobile-composer-reserve"; -import { readChromeCollapseBudget, useScrollHideReporter } from "@/components/clinical-dashboard/use-hide-on-scroll"; +import { readChromeCollapseMetrics, 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 { useTheme } from "@/components/clinical-dashboard/use-theme"; @@ -544,7 +544,7 @@ function GlobalStandaloneSearchShellClient({ phoneScrollHide.reportScroll({ offset: target.scrollTop, maxOffset: Math.max(0, target.scrollHeight - target.clientHeight), - collapseBudget: readChromeCollapseBudget(target), + ...readChromeCollapseMetrics(target), source: target, }); } @@ -569,7 +569,7 @@ function GlobalStandaloneSearchShellClient({ maxOffset: Math.max(0, target.scrollHeight - target.clientHeight), // Collapsing chrome releases layout into nested scrollers too (their // flex height cap grows with the shell), so the same budget applies. - collapseBudget: readChromeCollapseBudget(main), + ...readChromeCollapseMetrics(main), source: target, }); }; diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 99ce5174ca..d89b911ad6 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -349,8 +349,19 @@ export function MasterSearchHeader({ disabled: !hideOnScroll || hideOnScroll.scrollHidden !== undefined, }); const scrollHidden = hideOnScroll?.scrollHidden !== undefined ? hideOnScroll.scrollHidden : internalScrollHidden; - const headerChromeHidden = - scrollHidden && !modeMenuOpen && !actionMenuOpen && !scopeOpen && !scopeSheetOpen && !headerChromeFocused; + // Header and composer share one scroll signal, so any active surface or + // focus inside either edge pins both edges. This preserves keyboard focus + // safety without letting the unfocused header disappear above a still- + // focused composer (or vice versa). + const sharedChromePinned = + modeMenuOpen || + actionMenuOpen || + commandDropdownOpen || + scopeOpen || + scopeSheetOpen || + headerChromeFocused || + composerChromeFocused; + const headerChromeHidden = scrollHidden && !sharedChromePinned; // Mode homes portal the composer into the hero slot. With "all" the hero owns // every width (the answer home keeps its in-flow pill on phones); "sm-up" // hero hosts hand phones the bottom dock instead. @@ -363,14 +374,7 @@ export function MasterSearchHeader({ // Compare addon chrome lives inside the phone dock; hide/reveal with it so // the search pill and Compare selected bar reclaim space together. const bottomComposerScrollHiddenActive = Boolean(hideOnScroll && phoneBottomSearchDockActive); - const bottomComposerHidden = - bottomComposerScrollHiddenActive && - scrollHidden && - !actionMenuOpen && - !commandDropdownOpen && - !scopeOpen && - !scopeSheetOpen && - !composerChromeFocused; + const bottomComposerHidden = bottomComposerScrollHiddenActive && scrollHidden && !sharedChromePinned; useEffect(() => { onBottomComposerHiddenChange?.(bottomComposerHidden); diff --git a/src/components/clinical-dashboard/mobile-composer-reserve.ts b/src/components/clinical-dashboard/mobile-composer-reserve.ts index 899f2f50b6..1bdab21eb1 100644 --- a/src/components/clinical-dashboard/mobile-composer-reserve.ts +++ b/src/components/clinical-dashboard/mobile-composer-reserve.ts @@ -14,7 +14,7 @@ * zero so content can paint all the way to the viewport edge once the dock is * invisible. The rem number is exported separately so the scroll-hide * collapse budget - * (use-hide-on-scroll's readChromeCollapseBudget) measures against the same + * (use-hide-on-scroll's readChromeCollapseMetrics) measures against the same * value; tests/mobile-composer-reserve.test.ts pins the pair together. */ export const mobileComposerHiddenReserveRem = 0; diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index 24033c964b..c496db2fb0 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -46,12 +46,18 @@ export interface ScrollMetrics { maxOffset?: number; /** * Layout px the chrome would release if it hid right now (see - * readChromeCollapseBudget). When provided together with maxOffset, hiding - * is refused unless enough runway remains below the offset to absorb the - * release. Omitted by consumers whose chrome does not change scroll - * geometry when hiding. + * readChromeCollapseMetrics). In-flow collapse requires enough runway below + * the current offset to absorb the release; reserve-only overlays require + * the resulting range to retain the activation band. Omitted by consumers + * whose chrome does not change scroll geometry when hiding. */ collapseBudget?: number; + /** + * A fixed-viewport overlay only removes tail clearance; it does not collapse + * an in-flow header or resize the scrollport. That path can safely hide when + * the post-collapse range still reaches the activation band. + */ + collapseKind?: "in-flow" | "reserve-only"; source?: EventTarget; } @@ -61,6 +67,7 @@ export function computeScrollHideUpdate(params: { lastOffset: number; maxOffset?: number; collapseBudget?: number; + collapseKind?: "in-flow" | "reserve-only"; sourceChanged?: boolean; currentlyHidden: boolean; direction?: ScrollDirection; @@ -76,6 +83,7 @@ export function computeScrollHideUpdate(params: { lastOffset, maxOffset, collapseBudget, + collapseKind, sourceChanged = false, currentlyHidden, direction = null, @@ -129,16 +137,27 @@ export function computeScrollHideUpdate(params: { // Only count travel beyond the activation band. This stops a single flick // from the top hiding the chrome the instant it clears the header height. const travelPastActivation = Math.min(nextDirectionTravel, offset - hideActivationOffset); - // Refuse to hide when the geometry the chrome would release exceeds the - // remaining runway (see collapseRunwaySlack above). Short pages then keep - // their chrome and scroll plainly; long pages simply never start a hide - // this close to the bottom edge. + // In-flow chrome must have enough remaining runway to absorb its release + // (see collapseRunwaySlack above). Reserve-only overlays use the separate + // post-collapse range test below. const runwayAfterCollapse = maxOffset === undefined || collapseBudget === undefined ? Number.POSITIVE_INFINITY : maxOffset - offset - collapseBudget; - hidden = - travelPastActivation >= hideIntentDistance && runwayAfterCollapse > revealIntentDistance + collapseRunwaySlack; + const postCollapseMaxOffset = + maxOffset === undefined || collapseBudget === undefined + ? Number.POSITIVE_INFINITY + : Math.max(0, maxOffset - collapseBudget); + // Reserve-only overlays keep the viewport geometry stable; only bottom + // tail clearance is removed. Requiring the resulting scroll range to retain + // the activation band prevents a collapse back toward the top on genuinely + // short pages without applying the stricter in-flow runway rule that made + // compact Answer results impossible to hide. + const collapseHasSafeRunway = + collapseKind === "reserve-only" + ? postCollapseMaxOffset >= hideActivationOffset + : runwayAfterCollapse > revealIntentDistance + collapseRunwaySlack; + hidden = travelPastActivation >= hideIntentDistance && collapseHasSafeRunway; } else if (currentlyHidden && nextDirection === "up" && nextDirectionTravel >= revealIntentDistance) { hidden = false; } @@ -161,9 +180,12 @@ export function computeScrollHideUpdate(params: { * `document-viewer-content` for DocumentViewer's own clearance (its hidden * `pb-3` equals the shared 0.75rem hidden reserve), falling back to the * scroller's own padding exactly like tests/playwright-scroll.ts. Call from - * inside a scroll handler, where layout is already flushed. + * inside a scroll handler, where layout is already flushed. The returned kind + * distinguishes in-flow collapse from a fixed overlay that only sheds reserve. */ -export function readChromeCollapseBudget(scroller: HTMLElement): number { +export function readChromeCollapseMetrics( + scroller: HTMLElement, +): Pick { const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); const headerRelease = collapse instanceof HTMLElement ? collapse.getBoundingClientRect().height : 0; const rootFontSize = Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16; @@ -177,7 +199,10 @@ export function readChromeCollapseBudget(scroller: HTMLElement): number { const viewerPad = scroller.querySelector('[data-testid="document-viewer-content"]'); const reserveRelease = reservePad || viewerPad ? padRelease(reservePad) + padRelease(viewerPad) : padRelease(scroller); - return headerRelease + reserveRelease; + return { + collapseBudget: headerRelease + reserveRelease, + collapseKind: collapse instanceof HTMLElement ? "in-flow" : reserveRelease > 0 ? "reserve-only" : undefined, + }; } function subscribeToPhoneMedia(onChange: () => void) { @@ -215,9 +240,15 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa const reportScroll = useCallback( (report: number | ScrollMetrics) => { - const { offset, maxOffset, collapseBudget, source } = + const { offset, maxOffset, collapseBudget, collapseKind, source } = typeof report === "number" - ? { offset: report, maxOffset: undefined, collapseBudget: undefined, source: undefined } + ? { + offset: report, + maxOffset: undefined, + collapseBudget: undefined, + collapseKind: undefined, + source: undefined, + } : report; if (!active || offset < 0) return; const lastOffset = lastOffsetRef.current; @@ -233,6 +264,7 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa lastOffset, maxOffset, collapseBudget, + collapseKind, sourceChanged, currentlyHidden: hiddenRef.current, direction: directionRef.current, @@ -329,7 +361,7 @@ export function useHideOnScroll({ return { offset: container.scrollTop, maxOffset: Math.max(0, container.scrollHeight - container.clientHeight), - collapseBudget: readChromeCollapseBudget(container), + ...readChromeCollapseMetrics(container), source: container, }; } diff --git a/tests/mobile-composer-reserve.test.ts b/tests/mobile-composer-reserve.test.ts index b7c6f58741..17acbb7f20 100644 --- a/tests/mobile-composer-reserve.test.ts +++ b/tests/mobile-composer-reserve.test.ts @@ -23,7 +23,7 @@ describe("mobile composer reserve contract", () => { it("collapses to zero hidden pad without Safari toolbar safe-area", () => { expect(mobileComposerHiddenReserve).toBe("0rem"); expect(mobileComposerHiddenReserveRem).toBe(0); - // The rem number feeds readChromeCollapseBudget's px math; it must stay + // The rem number feeds readChromeCollapseMetrics' px math; it must stay // equal to the CSS string above or the collapse budget silently drifts. expect(`${mobileComposerHiddenReserveRem}rem`).toBe(mobileComposerHiddenReserve); expect(resolveMobileComposerReserve(true, mobileComposerVisibleReserve.shellAnswer)).toBe( diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 1ec8925933..ea5f436a25 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2080,6 +2080,95 @@ test.describe("Clinical KB UI smoke coverage", () => { await expectNoPageHorizontalOverflow(page); }); + test("phone answer result keeps the edge dock and shared chrome synchronized on a short runway", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.setViewportSize({ width: 390, height: 844 }); + await mockDemoApi(page); + await gotoApp(page, "/?mode=answer&focus=1"); + await waitForDemoDashboardReady(page); + + const input = await fillVisibleQuestionInput(page, "lithium dosing"); + await visibleAnswerSubmitButton(page).click(); + await expect(page.getByTestId("plain-answer-response")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("answer-streaming")).toHaveCount(0); + + const main = page.locator("main#main-content"); + const header = page.locator("header.universal-header"); + const dock = page.locator("form.answer-footer-search-dock"); + await expect(dock).toBeVisible(); + // Keep the deterministic fixture in the measured user-reproduction band: + // the mocked answer is 64px shorter than the captured result, so model the + // missing content rather than injecting the 2000px runway used by older + // hide tests (which makes the collapse-budget defect impossible to see). + await main.evaluate((node) => { + const tail = document.createElement("div"); + tail.dataset.testid = "answer-regression-content-tail"; + tail.style.height = "64px"; + node.appendChild(tail); + }); + const edgeGeometry = await dock.evaluate((node) => { + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + return { + bottom: style.bottom, + left: style.left, + right: style.right, + width: rect.width, + viewportWidth: window.innerWidth, + rectBottom: rect.bottom, + viewportHeight: window.innerHeight, + }; + }); + expect(edgeGeometry.bottom).toBe("0px"); + expect(edgeGeometry.left).toBe("0px"); + expect(edgeGeometry.right).toBe("0px"); + expect(Math.abs(edgeGeometry.width - edgeGeometry.viewportWidth)).toBeLessThanOrEqual(1); + expect(Math.abs(edgeGeometry.rectBottom - edgeGeometry.viewportHeight)).toBeLessThanOrEqual(1); + + // Focused chrome is an accessibility pin. Force enough runway for the + // reporter to request a hide and prove that focus keeps both shared edges + // visible, rather than allowing only the header to disappear. + await expect(input).toBeFocused(); + await main.evaluate((node) => { + const spacer = document.createElement("div"); + spacer.dataset.testid = "answer-focus-scroll-spacer"; + spacer.style.height = "2000px"; + node.appendChild(spacer); + }); + for (const offset of [40, 80, 120, 160, 200]) { + await scrollPrimarySurface(page, offset); + } + await expect(header).not.toHaveAttribute("data-scroll-hidden", "true"); + await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); + + // Return to the real short-result geometry. With focus moved to the + // scrollport, a deliberate descent must hide both edges even though the + // only released layout is the dock reserve; no oversized synthetic runway + // remains. + await scrollPrimarySurface(page, 0); + await main.evaluate((node) => { + node.querySelector('[data-testid="answer-focus-scroll-spacer"]')?.remove(); + }); + await main.focus(); + await expect(input).not.toBeFocused(); + const visibleMaxOffset = await main.evaluate((node) => node.scrollHeight - node.clientHeight); + // Keep this in the measured regression band: enough content to scroll, but + // not enough for the strict in-flow budget (120px reserve + 28px runway) + // to permit hiding after the 96px intent threshold. + expect(visibleMaxOffset).toBeGreaterThan(190); + expect(visibleMaxOffset).toBeLessThan(250); + for (const offset of [40, 80, 120, 160, 200, visibleMaxOffset]) { + await scrollPrimarySurface(page, Math.min(offset, visibleMaxOffset)); + } + await expect(header).toHaveAttribute("data-scroll-hidden", "true"); + await expect(dock).toHaveAttribute("data-scroll-hidden", "true"); + await expect.poll(async () => readMobileComposerReservePx(main)).toBeLessThanOrEqual(1); + + await scrollPrimarySurface(page, 60); + await expect(header).not.toHaveAttribute("data-scroll-hidden", "true"); + await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); + }); + test("recent searches appear on the answer home and re-run on tap", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); const answerRequests: string[] = []; diff --git a/tests/use-hide-on-scroll.test.ts b/tests/use-hide-on-scroll.test.ts index c233bfe854..17c0b0659c 100644 --- a/tests/use-hide-on-scroll.test.ts +++ b/tests/use-hide-on-scroll.test.ts @@ -293,6 +293,40 @@ describe("computeScrollHideUpdate", () => { }); }); + it("allows a reserve-only overlay to hide when its post-collapse range retains the activation band", () => { + // Measured compact Answer result at 390x844: the visible result has 223px + // of range and releases a 120px dock reserve. The fixed viewport remains + // stable and the resulting 103px range still clears the 72px activation + // band, so the in-flow collapse gate must not pin both edges forever. + expect( + computeScrollHideUpdate({ + offset: 100, + lastOffset: 80, + maxOffset: 223, + collapseBudget: 120, + collapseKind: "reserve-only", + currentlyHidden: false, + direction: "down", + directionTravel: 80, + }).hidden, + ).toBe(true); + + // A genuinely short result would collapse below the hide threshold and + // still risks a top/bottom clamp cycle, so it remains visible. + expect( + computeScrollHideUpdate({ + offset: 100, + lastOffset: 80, + maxOffset: 180, + collapseBudget: 120, + collapseKind: "reserve-only", + currentlyHidden: false, + direction: "down", + directionTravel: 80, + }).hidden, + ).toBe(false); + }); + it("hides normally when ample runway remains below the collapse release", () => { expect( computeScrollHideUpdate({ From a09ecdb475d89428c5dc5324b241a006645a246e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 04:02:31 +0000 Subject: [PATCH 02/16] test(ui): require answer scroll hide without blur Co-authored-by: BigSimmo --- tests/ui-smoke.spec.ts | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index ea5f436a25..2afe4286c7 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2125,34 +2125,13 @@ test.describe("Clinical KB UI smoke coverage", () => { expect(Math.abs(edgeGeometry.width - edgeGeometry.viewportWidth)).toBeLessThanOrEqual(1); expect(Math.abs(edgeGeometry.rectBottom - edgeGeometry.viewportHeight)).toBeLessThanOrEqual(1); - // Focused chrome is an accessibility pin. Force enough runway for the - // reporter to request a hide and prove that focus keeps both shared edges - // visible, rather than allowing only the header to disappear. - await expect(input).toBeFocused(); - await main.evaluate((node) => { - const spacer = document.createElement("div"); - spacer.dataset.testid = "answer-focus-scroll-spacer"; - spacer.style.height = "2000px"; - node.appendChild(spacer); - }); - for (const offset of [40, 80, 120, 160, 200]) { - await scrollPrimarySurface(page, offset); - } - await expect(header).not.toHaveAttribute("data-scroll-hidden", "true"); - await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); - - // Return to the real short-result geometry. With focus moved to the - // scrollport, a deliberate descent must hide both edges even though the - // only released layout is the dock reserve; no oversized synthetic runway - // remains. - await scrollPrimarySurface(page, 0); - await main.evaluate((node) => { - node.querySelector('[data-testid="answer-focus-scroll-spacer"]')?.remove(); - }); - await main.focus(); + // Submitting from the auto-focused home composer must not carry stale focus + // into the newly docked follow-up input. A focused dock is intentionally + // pinned for keyboard safety, so retaining focus here permanently disables + // the ordinary touch-scroll hide path. await expect(input).not.toBeFocused(); const visibleMaxOffset = await main.evaluate((node) => node.scrollHeight - node.clientHeight); - // Keep this in the measured regression band: enough content to scroll, but + // This is the measured reproduction band: enough content to scroll, but // not enough for the strict in-flow budget (120px reserve + 28px runway) // to permit hiding after the 96px intent threshold. expect(visibleMaxOffset).toBeGreaterThan(190); From 48a0be6b8218df8618378be62da1649d8f7f640f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 04:04:22 +0000 Subject: [PATCH 03/16] fix(ui): release stale answer composer focus Co-authored-by: BigSimmo --- src/components/ClinicalDashboard.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 43b0928366..e714c4e427 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -360,6 +360,10 @@ export function ClinicalDashboard({ const [modeSearchSubmitted, setModeSearchSubmitted] = useState(() => Boolean(autoRunSearch && initialQuery.trim() && initialSearchMode !== "tools"), ); + // `focus=1` should focus the home composer, not the replacement follow-up + // dock after an Answer submission. Carrying that focus across the portal + // swap pins both scroll-hide edges indefinitely. + const shouldAutoFocusComposer = focusSearch && !(searchMode === "answer" && modeSearchSubmitted); const [answer, setAnswer] = useState(null); const [sources, setSources] = useState([]); // Answer-mode conversation thread. `priorAnswerTurns` holds completed @@ -1495,11 +1499,17 @@ export function ClinicalDashboard({ }, []); useEffect(() => { - if (!focusSearch) return undefined; + if (!shouldAutoFocusComposer) { + // Release only focus inherited from the submitted home composer. This + // effect runs on the home -> result transition; later user-initiated + // focus does not change its dependencies and remains safely pinned. + if (document.activeElement === composerInputRef.current) composerInputRef.current?.blur(); + return undefined; + } focusComposerInput(); const timeout = window.setTimeout(focusComposerInput, 500); return () => window.clearTimeout(timeout); - }, [focusSearch]); + }, [shouldAutoFocusComposer]); // Abort any in-flight answer/library search if the dashboard unmounts. useEffect(() => { @@ -3361,7 +3371,7 @@ export function ClinicalDashboard({ }} queryModeOptions={clinicalQueryModeOptions} queryInputRef={composerInputRef} - queryInputAutoFocus={focusSearch} + queryInputAutoFocus={shouldAutoFocusComposer} recentQueries={recentQueries} commandScopes={commandScopes} onCommandScopesChange={setCommandScopes} From dd2d8c3ecd6a04af369163898f77c6eac198dcf7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 04:19:16 +0000 Subject: [PATCH 04/16] fix(ui): bind composer autofocus retries Co-authored-by: BigSimmo --- src/components/ClinicalDashboard.tsx | 23 ++++++++++++++++------- tests/ui-smoke.spec.ts | 3 +++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index e714c4e427..fa00b1f73e 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1506,8 +1506,8 @@ export function ClinicalDashboard({ if (document.activeElement === composerInputRef.current) composerInputRef.current?.blur(); return undefined; } - focusComposerInput(); - const timeout = window.setTimeout(focusComposerInput, 500); + focusComposerInput(true); + const timeout = window.setTimeout(() => focusComposerInput(true), 500); return () => window.clearTimeout(timeout); }, [shouldAutoFocusComposer]); @@ -1546,7 +1546,7 @@ export function ClinicalDashboard({ setLoading(false); setError(null); setAnswerProgress(null); - if (shouldFocusComposer) focusComposerInput(); + if (shouldFocusComposer) focusComposerInput(true); }); return () => window.cancelAnimationFrame(frame); }, [searchParams, clearDifferentialModeResultState]); @@ -1566,7 +1566,7 @@ export function ClinicalDashboard({ // run=1 URLs name the latest answered question; the composer stays empty // while an answer thread is active (including after localStorage restore). if (searchText && params.get("run") !== "1") setQuery(searchText); - if (shouldFocusComposer) focusComposerInput(); + if (shouldFocusComposer) focusComposerInput(true); }); return () => window.cancelAnimationFrame(frame); }, [clearDifferentialModeResultState]); @@ -2621,10 +2621,19 @@ export function ClinicalDashboard({ router.push(appModeHomeHref(mode, { queryMode, scopeFilters })); } - function focusComposerInput() { + function focusComposerInput(retainTarget = false) { + // Bind auto-focus retries to the home node so its replacement never inherits focus. + const requestedInput = retainTarget ? composerInputRef.current : null; + const resolveInput = () => (retainTarget ? requestedInput : composerInputRef.current); window.requestAnimationFrame(() => { - composerInputRef.current?.focus({ preventScroll: true }); - window.setTimeout(() => composerInputRef.current?.focus({ preventScroll: true }), 150); + const input = resolveInput(); + if (!input?.isConnected || composerInputRef.current !== input) return; + input.focus({ preventScroll: true }); + window.setTimeout(() => { + const retryInput = resolveInput(); + if (retryInput?.isConnected && composerInputRef.current === retryInput) + retryInput.focus({ preventScroll: true }); + }, 150); }); } diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 2afe4286c7..f266099641 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2146,6 +2146,9 @@ test.describe("Clinical KB UI smoke coverage", () => { await scrollPrimarySurface(page, 60); await expect(header).not.toHaveAttribute("data-scroll-hidden", "true"); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); + + await input.click(); + await expect(input).toBeFocused(); }); test("recent searches appear on the answer home and re-run on tap", async ({ page }) => { From ae77f8c3afa399d8bdbdbdee2b579933aa6ebc49 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 04:22:11 +0000 Subject: [PATCH 05/16] chore(ui): stay within dashboard line budget Co-authored-by: BigSimmo --- src/components/ClinicalDashboard.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 37a35dcacd..f44ff05829 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -360,9 +360,6 @@ export function ClinicalDashboard({ const [modeSearchSubmitted, setModeSearchSubmitted] = useState(() => Boolean(autoRunSearch && initialQuery.trim() && initialSearchMode !== "tools"), ); - // `focus=1` should focus the home composer, not the replacement follow-up - // dock after an Answer submission. Carrying that focus across the portal - // swap pins both scroll-hide edges indefinitely. const shouldAutoFocusComposer = focusSearch && !(searchMode === "answer" && modeSearchSubmitted); const [answer, setAnswer] = useState(null); const [sources, setSources] = useState([]); @@ -1500,9 +1497,6 @@ export function ClinicalDashboard({ useEffect(() => { if (!shouldAutoFocusComposer) { - // Release only focus inherited from the submitted home composer. This - // effect runs on the home -> result transition; later user-initiated - // focus does not change its dependencies and remains safely pinned. if (document.activeElement === composerInputRef.current) composerInputRef.current?.blur(); return undefined; } @@ -2622,7 +2616,6 @@ export function ClinicalDashboard({ } function focusComposerInput(retainTarget = false) { - // Bind auto-focus retries to the home node so its replacement never inherits focus. const requestedInput = retainTarget ? composerInputRef.current : null; const resolveInput = () => (retainTarget ? requestedInput : composerInputRef.current); window.requestAnimationFrame(() => { From 68d9f21b5a3c9438609e6afbcd53482f7d8670c4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 12:00:07 +0000 Subject: [PATCH 06/16] fix(ui): hide chrome on compact answer results Co-authored-by: BigSimmo --- docs/search-chrome-behaviour.md | 2 +- .../clinical-dashboard/use-hide-on-scroll.ts | 25 ++++---- tests/ui-smoke.spec.ts | 36 +++++------ tests/use-hide-on-scroll.test.ts | 59 ++++++++++++++----- 4 files changed, 76 insertions(+), 46 deletions(-) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index c1200edea1..84ab770b23 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -22,7 +22,7 @@ This repo uses one shared search experience across the global shell, dashboard r 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. 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 still clears the activation band. Do not use synthetic page padding to make the stricter gate pass. +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. Do not use synthetic page padding to make the stricter gate pass. ## Change checklist diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index c496db2fb0..46b5ffe39e 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -48,14 +48,14 @@ export interface ScrollMetrics { * Layout px the chrome would release if it hid right now (see * readChromeCollapseMetrics). In-flow collapse requires enough runway below * the current offset to absorb the release; reserve-only overlays require - * the resulting range to retain the activation band. Omitted by consumers - * whose chrome does not change scroll geometry when hiding. + * the resulting range to retain top reveal plus deliberate hide intent. + * Omitted by consumers whose chrome does not change scroll geometry. */ collapseBudget?: number; /** * A fixed-viewport overlay only removes tail clearance; it does not collapse * an in-flow header or resize the scrollport. That path can safely hide when - * the post-collapse range still reaches the activation band. + * the post-collapse range retains the top reveal band plus deliberate intent. */ collapseKind?: "in-flow" | "reserve-only"; source?: EventTarget; @@ -133,10 +133,11 @@ export function computeScrollHideUpdate(params: { const nextDirectionTravel = nextDirection === direction ? directionTravel + Math.abs(delta) : Math.abs(delta); let hidden = currentlyHidden; - if (!currentlyHidden && nextDirection === "down" && offset > hideActivationOffset) { - // Only count travel beyond the activation band. This stops a single flick - // from the top hiding the chrome the instant it clears the header height. - const travelPastActivation = Math.min(nextDirectionTravel, offset - hideActivationOffset); + const effectiveHideActivationOffset = collapseKind === "reserve-only" ? topRevealOffset : hideActivationOffset; + if (!currentlyHidden && nextDirection === "down" && offset > effectiveHideActivationOffset) { + // In-flow chrome waits beyond its header-height band; fixed overlays begin + // counting deliberate intent after the small top reveal band. + const travelPastActivation = Math.min(nextDirectionTravel, offset - effectiveHideActivationOffset); // In-flow chrome must have enough remaining runway to absorb its release // (see collapseRunwaySlack above). Reserve-only overlays use the separate // post-collapse range test below. @@ -148,14 +149,12 @@ export function computeScrollHideUpdate(params: { maxOffset === undefined || collapseBudget === undefined ? Number.POSITIVE_INFINITY : Math.max(0, maxOffset - collapseBudget); - // Reserve-only overlays keep the viewport geometry stable; only bottom - // tail clearance is removed. Requiring the resulting scroll range to retain - // the activation band prevents a collapse back toward the top on genuinely - // short pages without applying the stricter in-flow runway rule that made - // compact Answer results impossible to hide. + // Reserve-only overlays keep the viewport geometry stable; requiring their + // resulting range to retain top-reveal + hide-intent distance prevents a + // material clamp while allowing genuinely compact results to hide. const collapseHasSafeRunway = collapseKind === "reserve-only" - ? postCollapseMaxOffset >= hideActivationOffset + ? postCollapseMaxOffset >= effectiveHideActivationOffset + hideIntentDistance : runwayAfterCollapse > revealIntentDistance + collapseRunwaySlack; hidden = travelPastActivation >= hideIntentDistance && collapseHasSafeRunway; } else if (currentlyHidden && nextDirection === "up" && nextDirectionTravel >= revealIntentDistance) { diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 90f200445c..749f8dcbef 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2136,16 +2136,6 @@ test.describe("Clinical KB UI smoke coverage", () => { const header = page.locator("header.universal-header"); const dock = page.locator("form.answer-footer-search-dock"); await expect(dock).toBeVisible(); - // Keep the deterministic fixture in the measured user-reproduction band: - // the mocked answer is 64px shorter than the captured result, so model the - // missing content rather than injecting the 2000px runway used by older - // hide tests (which makes the collapse-budget defect impossible to see). - await main.evaluate((node) => { - const tail = document.createElement("div"); - tail.dataset.testid = "answer-regression-content-tail"; - tail.style.height = "64px"; - node.appendChild(tail); - }); const edgeGeometry = await dock.evaluate((node) => { const rect = node.getBoundingClientRect(); const style = window.getComputedStyle(node); @@ -2170,12 +2160,24 @@ test.describe("Clinical KB UI smoke coverage", () => { // pinned for keyboard safety, so retaining focus here permanently disables // the ordinary touch-scroll hide path. await expect(input).not.toBeFocused(); - const visibleMaxOffset = await main.evaluate((node) => node.scrollHeight - node.clientHeight); - // This is the measured reproduction band: enough content to scroll, but - // not enough for the strict in-flow budget (120px reserve + 28px runway) - // to permit hiding after the 96px intent threshold. - expect(visibleMaxOffset).toBeGreaterThan(190); - expect(visibleMaxOffset).toBeLessThan(250); + const geometry = await main.evaluate((node) => { + const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); + const maxOffset = node.scrollHeight - node.clientHeight; + const collapseBudget = + (collapse?.getBoundingClientRect().height ?? 0) + + Number.parseFloat(window.getComputedStyle(node).paddingBottom); + return { maxOffset, collapseBudget, postCollapseMaxOffset: Math.max(0, maxOffset - collapseBudget) }; + }); + const visibleMaxOffset = geometry.maxOffset; + // Pin the unmodified short-result geometry. Its 39px post-collapse range + // clears top-reveal + hide-intent distance (32px), but not the 72px in-flow + // activation band; synthetic tail content would hide this distinction. + expect(geometry.maxOffset).toBeGreaterThan(140); + expect(geometry.maxOffset).toBeLessThan(180); + expect(geometry.collapseBudget).toBeGreaterThan(112); + expect(geometry.collapseBudget).toBeLessThan(128); + expect(geometry.postCollapseMaxOffset).toBeGreaterThanOrEqual(32); + expect(geometry.postCollapseMaxOffset).toBeLessThan(48); for (const offset of [40, 80, 120, 160, 200, visibleMaxOffset]) { await scrollPrimarySurface(page, Math.min(offset, visibleMaxOffset)); } @@ -2183,7 +2185,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(dock).toHaveAttribute("data-scroll-hidden", "true"); await expect.poll(async () => readMobileComposerReservePx(main)).toBeLessThanOrEqual(1); - await scrollPrimarySurface(page, 60); + await scrollPrimarySurface(page, 20); await expect(header).not.toHaveAttribute("data-scroll-hidden", "true"); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); diff --git a/tests/use-hide-on-scroll.test.ts b/tests/use-hide-on-scroll.test.ts index 17c0b0659c..4b77b1b5db 100644 --- a/tests/use-hide-on-scroll.test.ts +++ b/tests/use-hide-on-scroll.test.ts @@ -293,36 +293,65 @@ describe("computeScrollHideUpdate", () => { }); }); - it("allows a reserve-only overlay to hide when its post-collapse range retains the activation band", () => { - // Measured compact Answer result at 390x844: the visible result has 223px - // of range and releases a 120px dock reserve. The fixed viewport remains - // stable and the resulting 103px range still clears the 72px activation - // band, so the in-flow collapse gate must not pin both edges forever. + it("allows a reserve-only overlay to hide when its post-collapse range retains deliberate hide intent", () => { + // Measured compact Answer result at 390x844 without synthetic content: + // 159px visible range - 120px reserve = 39px after hiding. A fixed overlay + // can hide after the 8px top band + 24px intent without collapsing in-flow + // header geometry or clamping materially. + const hidden = computeScrollHideUpdate({ + offset: 40, + lastOffset: 0, + maxOffset: 159, + collapseBudget: 120, + collapseKind: "reserve-only", + currentlyHidden: false, + }); + expect(hidden.hidden).toBe(true); + + const clamped = computeScrollHideUpdate({ + offset: 39, + lastOffset: hidden.lastOffset, + maxOffset: 39, + currentlyHidden: hidden.hidden, + direction: hidden.direction, + directionTravel: hidden.directionTravel, + }); + expect(clamped.hidden).toBe(true); + expect( + computeScrollHideUpdate({ + offset: 20, + lastOffset: clamped.lastOffset, + maxOffset: 39, + currentlyHidden: clamped.hidden, + direction: clamped.direction, + directionTravel: clamped.directionTravel, + }).hidden, + ).toBe(false); + + // The same geometry remains protected when an in-flow header participates. expect( computeScrollHideUpdate({ offset: 100, lastOffset: 80, - maxOffset: 223, + maxOffset: 159, collapseBudget: 120, - collapseKind: "reserve-only", + collapseKind: "in-flow", currentlyHidden: false, direction: "down", directionTravel: 80, }).hidden, - ).toBe(true); + ).toBe(false); - // A genuinely short result would collapse below the hide threshold and - // still risks a top/bottom clamp cycle, so it remains visible. + // A genuinely short reserve-only result would collapse below the deliberate + // hide threshold and still risks a top/bottom clamp cycle, so it stays visible. expect( computeScrollHideUpdate({ - offset: 100, - lastOffset: 80, - maxOffset: 180, + offset: 40, + lastOffset: 0, + maxOffset: 145, collapseBudget: 120, collapseKind: "reserve-only", currentlyHidden: false, - direction: "down", - directionTravel: 80, }).hidden, ).toBe(false); }); From 3b5ef43f1825dd8cf11dd767069569ba1c701c45 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 12:52:00 +0000 Subject: [PATCH 07/16] fix(ui): hold chrome through fractional scroll clamp Co-authored-by: BigSimmo --- .../clinical-dashboard/use-hide-on-scroll.ts | 10 ++++--- tests/ui-smoke.spec.ts | 26 ++++++++++++++++--- tests/use-hide-on-scroll.test.ts | 24 +++++++++++++++++ 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index 46b5ffe39e..6ba3c2caca 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -25,10 +25,12 @@ const minimumDelta = 4; // chrome at a direction change. const hideIntentDistance = 24; const revealIntentDistance = 12; -// How close to the bottom edge (px) counts as "pinned to the bottom". When the -// offset is this near the maximum, an upward reading is the viewport growing -// under a collapsing header rather than a real scroll, so it must not reveal. -const bottomClampTolerance = 1; +// How close to the bottom edge (CSS px) counts as "pinned to the bottom". +// scrollHeight/clientHeight expose integer maxima while composited scrolling +// can report fractional scrollTop values just over 1px below that edge during +// a reserve transition. Keep this below minimumDelta so a real upward move is +// still required to reveal. +const bottomClampTolerance = 2; // Hiding the chrome releases its layout space back to the scroller (header // grid collapse + dock reserve-pad shrink), shrinking maxOffset by the same // amount. When the runway left below the current offset is smaller than that diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 749f8dcbef..e748d1544a 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2168,7 +2168,6 @@ test.describe("Clinical KB UI smoke coverage", () => { Number.parseFloat(window.getComputedStyle(node).paddingBottom); return { maxOffset, collapseBudget, postCollapseMaxOffset: Math.max(0, maxOffset - collapseBudget) }; }); - const visibleMaxOffset = geometry.maxOffset; // Pin the unmodified short-result geometry. Its 39px post-collapse range // clears top-reveal + hide-intent distance (32px), but not the 72px in-flow // activation band; synthetic tail content would hide this distinction. @@ -2178,11 +2177,30 @@ test.describe("Clinical KB UI smoke coverage", () => { expect(geometry.collapseBudget).toBeLessThan(128); expect(geometry.postCollapseMaxOffset).toBeGreaterThanOrEqual(32); expect(geometry.postCollapseMaxOffset).toBeLessThan(48); - for (const offset of [40, 80, 120, 160, 200, visibleMaxOffset]) { - await scrollPrimarySurface(page, Math.min(offset, visibleMaxOffset)); - } + await main.focus(); + await page.keyboard.press("PageDown"); + await expect(header).toHaveAttribute("data-scroll-hidden", "true"); + await expect(dock).toHaveAttribute("data-scroll-hidden", "true"); + // The reserve and both chrome edges animate for 240ms. The hidden state + // must survive the browser clamping scrollTop against the shrinking range, + // and the actual painted elements must finish outside the viewport. + await page.waitForTimeout(320); await expect(header).toHaveAttribute("data-scroll-hidden", "true"); await expect(dock).toHaveAttribute("data-scroll-hidden", "true"); + const settledHiddenGeometry = await page.evaluate(() => { + const headerNode = document.querySelector("header.universal-header"); + const dockNode = document.querySelector("form.answer-footer-search-dock"); + if (!headerNode || !dockNode) throw new Error("Expected shared phone chrome"); + const headerRect = headerNode.getBoundingClientRect(); + const dockRect = dockNode.getBoundingClientRect(); + return { + headerBottom: headerRect.bottom, + dockTop: dockRect.top, + viewportHeight: window.innerHeight, + }; + }); + expect(settledHiddenGeometry.headerBottom).toBeLessThanOrEqual(1); + expect(settledHiddenGeometry.dockTop).toBeGreaterThanOrEqual(settledHiddenGeometry.viewportHeight - 1); await expect.poll(async () => readMobileComposerReservePx(main)).toBeLessThanOrEqual(1); await scrollPrimarySurface(page, 20); diff --git a/tests/use-hide-on-scroll.test.ts b/tests/use-hide-on-scroll.test.ts index 4b77b1b5db..9a4cb5c8e5 100644 --- a/tests/use-hide-on-scroll.test.ts +++ b/tests/use-hide-on-scroll.test.ts @@ -356,6 +356,30 @@ describe("computeScrollHideUpdate", () => { ).toBe(false); }); + it("holds a reserve-only overlay through the captured fractional bottom clamp", () => { + // Real PageDown frame from the compact Answer result at 390x844. The + // animated reserve shrink moved the browser's maximum from 160px to 127px; + // compositor rounding left scrollTop 1.03px below that integer maximum. + // This is layout feedback near the new bottom, not upward user intent. + expect( + computeScrollHideUpdate({ + offset: 125.9683, + lastOffset: 160.5079, + maxOffset: 127, + collapseBudget: 23.3347, + collapseKind: "reserve-only", + currentlyHidden: true, + direction: "down", + directionTravel: 160.5079, + }), + ).toEqual({ + hidden: true, + lastOffset: 125.9683, + direction: null, + directionTravel: 0, + }); + }); + it("hides normally when ample runway remains below the collapse release", () => { expect( computeScrollHideUpdate({ From 080848107a8ab8ad916c0d7d334c22fcc70a168d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:04:13 +0000 Subject: [PATCH 08/16] docs(review): record mobile chrome Bugbot pass 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 250735a6fb..cf2ddf1f91 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -872,3 +872,4 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-25 | open-pr-babysit-continuation-20260725 | multipass | Babysit continuation after 14 merges: #1177 landed; #1174/#1178/#1153 in progress; drafts #1187/#1192 skipped; large cluster #1162/#1185/#1186/#1188/#1190 content-conflicted (skip). | merge-tree inventory; no provider-backed checks. | | 2026-07-25 | audit-remediation (PR #1153) | 5a731df5c25fed9b07fd2321a0ad4b6519471f4b | PR babysit: CodeRabbit thread fixes + merge | Before: MERGEABLE/BLOCKED on required_review_thread_resolution + pending CI; 6 CodeRabbit threads. After: fixed sync-skills pad/YAML escape, PDF temp cleanup, squash-aware rollback wording; dispositioned ledger mid-table + retained false-positive; approved CI; merged to main `191b17d2f` (merge commit); branch deleted; tip is ancestor of main. | Hosted CI green on tip; no provider-backed checks. | | 2026-07-25 | cursor/local-presence-054-7cf3 (PR #1178) | 9135891bfd194394549cb480a7ec86de12b23ee7 | PR babysit: local-presence + /tools + CI/UI fixes + squash merge | Before: flaky Safety audit on package.json scripts, Production UI Sources autofocus flake, CodeRabbit short-env duplicate thread. After: ci-change-scope lockfile-only; strip stale short env keys; sheet open-focus retries + skip focus=1 reclaim under modal; squash-merged `d08ec2e8e`; branch deleted; key-file content-diff empty. | Hosted PR required SUCCESS (Production UI green on tip); focused local-presence vitest; no provider-backed checks. | +| 2026-07-25 | cursor/fix-mobile-composer-edge-scroll-5b1d (PR #1192) | 3b5ef43f1825dd8cf11dd767069569ba1c701c45 | Bugbot branch review: mobile Answer edge dock, synchronized hide/reveal, focus safety, reserve-collapse and fractional-clamp safeguards | No bugs found. Highest residual risk is physical iOS Safari toolbar/visual-viewport behavior beyond Chromium emulation. | `npm run verify:cheap` (3,357 passed); `npm run verify:ui` (272 passed); focused clamp/reserve Vitest (28 passed); focused production Chromium regression passed; clean headed-phone video proof; no provider-backed checks run. | From 164139c96cb1ff96e695f1a70cb8c8cf42484623 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:06:52 +0000 Subject: [PATCH 09/16] test(ui): distinguish range clamps from upward intent Co-authored-by: BigSimmo --- tests/use-hide-on-scroll.test.ts | 33 +++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/use-hide-on-scroll.test.ts b/tests/use-hide-on-scroll.test.ts index 9a4cb5c8e5..9363fe36ce 100644 --- a/tests/use-hide-on-scroll.test.ts +++ b/tests/use-hide-on-scroll.test.ts @@ -356,7 +356,7 @@ describe("computeScrollHideUpdate", () => { ).toBe(false); }); - it("holds a reserve-only overlay through the captured fractional bottom clamp", () => { + it("uses a shrinking scroll range, not pixel tolerance, to identify a reserve-collapse clamp", () => { // Real PageDown frame from the compact Answer result at 390x844. The // animated reserve shrink moved the browser's maximum from 160px to 127px; // compositor rounding left scrollTop 1.03px below that integer maximum. @@ -366,6 +366,7 @@ describe("computeScrollHideUpdate", () => { offset: 125.9683, lastOffset: 160.5079, maxOffset: 127, + previousMaxOffset: 160, collapseBudget: 23.3347, collapseKind: "reserve-only", currentlyHidden: true, @@ -378,6 +379,36 @@ describe("computeScrollHideUpdate", () => { direction: null, directionTravel: 0, }); + + // The same fractional positions are a genuine upward gesture once the + // scroll range is stable; no bottom-edge tolerance may suppress it. + expect( + computeScrollHideUpdate({ + offset: 125.9683, + lastOffset: 160.5079, + maxOffset: 127, + previousMaxOffset: 127, + collapseKind: "reserve-only", + currentlyHidden: true, + direction: "down", + directionTravel: 160.5079, + }).hidden, + ).toBe(false); + + // Range shrink alone is insufficient: if the old position still fits + // inside the new range, the browser did not have to clamp it. + expect( + computeScrollHideUpdate({ + offset: 100, + lastOffset: 120, + maxOffset: 127, + previousMaxOffset: 160, + collapseKind: "reserve-only", + currentlyHidden: true, + direction: "down", + directionTravel: 120, + }).hidden, + ).toBe(false); }); it("hides normally when ample runway remains below the collapse release", () => { From 4f9fbf388191507283efc82ac59996dedec7223e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:07:53 +0000 Subject: [PATCH 10/16] fix(ui): distinguish layout clamps from upward scroll Co-authored-by: BigSimmo --- .../clinical-dashboard/use-hide-on-scroll.ts | 64 ++++++++++++------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index 6ba3c2caca..4d5dd0086f 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -25,12 +25,10 @@ const minimumDelta = 4; // chrome at a direction change. const hideIntentDistance = 24; const revealIntentDistance = 12; -// How close to the bottom edge (CSS px) counts as "pinned to the bottom". -// scrollHeight/clientHeight expose integer maxima while composited scrolling -// can report fractional scrollTop values just over 1px below that edge during -// a reserve transition. Keep this below minimumDelta so a real upward move is -// still required to reveal. -const bottomClampTolerance = 2; +// How close to the stable bottom edge (px) counts as "pinned to the bottom". +// Keep this strict for iOS rubber-band readings; animated layout clamps are +// identified from the changing scroll range instead of pixel proximity. +const bottomClampTolerance = 1; // Hiding the chrome releases its layout space back to the scroller (header // grid collapse + dock reserve-pad shrink), shrinking maxOffset by the same // amount. When the runway left below the current offset is smaller than that @@ -68,6 +66,7 @@ export function computeScrollHideUpdate(params: { offset: number; lastOffset: number; maxOffset?: number; + previousMaxOffset?: number; collapseBudget?: number; collapseKind?: "in-flow" | "reserve-only"; sourceChanged?: boolean; @@ -84,6 +83,7 @@ export function computeScrollHideUpdate(params: { offset, lastOffset, maxOffset, + previousMaxOffset, collapseBudget, collapseKind, sourceChanged = false, @@ -102,20 +102,23 @@ export function computeScrollHideUpdate(params: { return { hidden: false, lastOffset: offset, direction: null, directionTravel: 0 }; } - // Collapsing in-flow chrome grows the scroll viewport: as the header hands its - // height back to the content, the browser clamps scrollTop to the new, smaller - // maximum and emits an apparent upward scroll even though the user is moving - // down or holding at the bottom. A collapse animates over several frames, so - // this clamp repeats frame after frame; if any frame's phantom "up" reveals - // the chrome, the viewport shrinks again and a hide/reveal scroll-bounce - // begins. While the chrome is hidden and the offset stays pinned to the bottom - // edge, treat every upward reading as layout feedback — hold the hidden state - // and rebase intent so only a genuine upward scroll (one that pulls the offset - // clear of the bottom) can reveal. This intentionally does not depend on the - // previous offset's relationship to the maximum, which the browser's per-frame - // clamping makes unreliable during the collapse. - // - // The bottom test is deliberately one-sided (`offset >= maxOffset - tol`, not + // When hidden chrome releases layout, the scroll range shrinks and a previous + // offset beyond the new maximum becomes impossible. The browser clamps both + // values downward; that apparent upward movement is geometry feedback, not + // reveal intent. Hold hidden and rebase until the range stabilizes. + if ( + currentlyHidden && + maxOffset !== undefined && + previousMaxOffset !== undefined && + maxOffset < previousMaxOffset && + offset < lastOffset && + lastOffset > maxOffset + ) { + return { hidden: true, lastOffset: offset, direction: null, directionTravel: 0 }; + } + + // The stable-bottom test is deliberately one-sided + // (`offset >= maxOffset - tol`, not // `|offset - maxOffset| <= tol`): iOS rubber-band overscroll at the bottom can // report a scrollTop *past* the maximum, and while the content springs back // the reading moves up. That is still the bottom edge, not a scroll away from @@ -233,6 +236,7 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa const [hidden, setHidden] = useState(false); const hiddenRef = useRef(false); const lastOffsetRef = useRef(0); + const lastMaxOffsetRef = useRef(undefined); const directionRef = useRef(null); const directionTravelRef = useRef(0); const scrollSourceRef = useRef(null); @@ -251,19 +255,33 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa source: undefined, } : report; - if (!active || offset < 0) return; + if (!active) return; const lastOffset = lastOffsetRef.current; const delta = offset - lastOffset; const sourceChanged = source !== undefined && hasScrollSourceRef.current && scrollSourceRef.current !== source; + const previousMaxOffset = sourceChanged ? undefined : lastMaxOffsetRef.current; + const comparableRangeChanged = + previousMaxOffset !== undefined && maxOffset !== undefined && previousMaxOffset !== maxOffset; if (source !== undefined) { scrollSourceRef.current = source; hasScrollSourceRef.current = true; } - if (!sourceChanged && Math.abs(delta) < minimumDelta && offset > topRevealOffset) return; + // Baseline each metrics report, even when movement itself is too small to + // evaluate. Undefined explicitly clears stale geometry for numeric reports. + lastMaxOffsetRef.current = maxOffset; + if (offset < 0) return; + if ( + !sourceChanged && + !comparableRangeChanged && + Math.abs(delta) < minimumDelta && + offset > topRevealOffset + ) + return; const update = computeScrollHideUpdate({ offset, lastOffset, maxOffset, + previousMaxOffset, collapseBudget, collapseKind, sourceChanged, @@ -284,6 +302,7 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa if (active) return undefined; hiddenRef.current = false; lastOffsetRef.current = 0; + lastMaxOffsetRef.current = undefined; directionRef.current = null; directionTravelRef.current = 0; scrollSourceRef.current = null; @@ -301,6 +320,7 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa useEffect(() => { hiddenRef.current = false; lastOffsetRef.current = 0; + lastMaxOffsetRef.current = undefined; directionRef.current = null; directionTravelRef.current = 0; scrollSourceRef.current = null; From 23bbb975587229e20b5bb7ac583fd36915b31c49 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:13:21 +0000 Subject: [PATCH 11/16] refactor(ui): compact bound composer focus retry Co-authored-by: BigSimmo --- src/components/ClinicalDashboard.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 11b2f31738..afa1bfd1de 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -2617,16 +2617,13 @@ export function ClinicalDashboard({ function focusComposerInput(retainTarget = false) { const requestedInput = retainTarget ? composerInputRef.current : null; - const resolveInput = () => (retainTarget ? requestedInput : composerInputRef.current); + const focusBoundInput = () => { + const input = retainTarget ? requestedInput : composerInputRef.current; + if (input?.isConnected && composerInputRef.current === input) input.focus({ preventScroll: true }); + }; window.requestAnimationFrame(() => { - const input = resolveInput(); - if (!input?.isConnected || composerInputRef.current !== input) return; - input.focus({ preventScroll: true }); - window.setTimeout(() => { - const retryInput = resolveInput(); - if (retryInput?.isConnected && composerInputRef.current === retryInput) - retryInput.focus({ preventScroll: true }); - }, 150); + focusBoundInput(); + window.setTimeout(focusBoundInput, 150); }); } From ff3c2221ce39531f1f213e0a04a851badc22eaf7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:17:12 +0000 Subject: [PATCH 12/16] docs(ui): codify geometry-based clamp handling Co-authored-by: BigSimmo --- docs/search-chrome-behaviour.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 84ab770b23..fa64e35bad 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -23,6 +23,7 @@ This repo uses one shared search experience across the global shell, dashboard r 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. 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. ## Change checklist From 69dc0dbfb46586f54f5934199d4a65b9f6a0aba8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:26:06 +0000 Subject: [PATCH 13/16] style: format use-hide-on-scroll.ts to satisfy prettier check Co-authored-by: BigSimmo --- src/components/clinical-dashboard/use-hide-on-scroll.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index 4d5dd0086f..692beeb584 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -270,12 +270,7 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa // evaluate. Undefined explicitly clears stale geometry for numeric reports. lastMaxOffsetRef.current = maxOffset; if (offset < 0) return; - if ( - !sourceChanged && - !comparableRangeChanged && - Math.abs(delta) < minimumDelta && - offset > topRevealOffset - ) + if (!sourceChanged && !comparableRangeChanged && Math.abs(delta) < minimumDelta && offset > topRevealOffset) return; const update = computeScrollHideUpdate({ offset, From c08ed37c5e8c7b62cf7d547089afff7a63c9122c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:32:39 +0000 Subject: [PATCH 14/16] docs(review): record current PR Bugbot pass 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 cf2ddf1f91..060e9ba4c3 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -873,3 +873,4 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-25 | audit-remediation (PR #1153) | 5a731df5c25fed9b07fd2321a0ad4b6519471f4b | PR babysit: CodeRabbit thread fixes + merge | Before: MERGEABLE/BLOCKED on required_review_thread_resolution + pending CI; 6 CodeRabbit threads. After: fixed sync-skills pad/YAML escape, PDF temp cleanup, squash-aware rollback wording; dispositioned ledger mid-table + retained false-positive; approved CI; merged to main `191b17d2f` (merge commit); branch deleted; tip is ancestor of main. | Hosted CI green on tip; no provider-backed checks. | | 2026-07-25 | cursor/local-presence-054-7cf3 (PR #1178) | 9135891bfd194394549cb480a7ec86de12b23ee7 | PR babysit: local-presence + /tools + CI/UI fixes + squash merge | Before: flaky Safety audit on package.json scripts, Production UI Sources autofocus flake, CodeRabbit short-env duplicate thread. After: ci-change-scope lockfile-only; strip stale short env keys; sheet open-focus retries + skip focus=1 reclaim under modal; squash-merged `d08ec2e8e`; branch deleted; key-file content-diff empty. | Hosted PR required SUCCESS (Production UI green on tip); focused local-presence vitest; no provider-backed checks. | | 2026-07-25 | cursor/fix-mobile-composer-edge-scroll-5b1d (PR #1192) | 3b5ef43f1825dd8cf11dd767069569ba1c701c45 | Bugbot branch review: mobile Answer edge dock, synchronized hide/reveal, focus safety, reserve-collapse and fractional-clamp safeguards | No bugs found. Highest residual risk is physical iOS Safari toolbar/visual-viewport behavior beyond Chromium emulation. | `npm run verify:cheap` (3,357 passed); `npm run verify:ui` (272 passed); focused clamp/reserve Vitest (28 passed); focused production Chromium regression passed; clean headed-phone video proof; no provider-backed checks run. | +| 2026-07-25 | cursor/fix-mobile-composer-edge-scroll-5b1d (PR #1192) | 69dc0dbfb46586f54f5934199d4a65b9f6a0aba8 | User-requested Bugbot review of current PR head after geometry-aware clamp handling and CI formatting fix | No bugs found. | Bugbot branch review; prior focused unit/Chromium/manual proofs retained; no provider-backed checks run. | From 80081aa87b9de3310e0453cf82d5190c33f87400 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 13:54:13 +0000 Subject: [PATCH 15/16] docs(review): record document scroll behavior findings 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 060e9ba4c3..b42616b809 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -874,3 +874,4 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-25 | cursor/local-presence-054-7cf3 (PR #1178) | 9135891bfd194394549cb480a7ec86de12b23ee7 | PR babysit: local-presence + /tools + CI/UI fixes + squash merge | Before: flaky Safety audit on package.json scripts, Production UI Sources autofocus flake, CodeRabbit short-env duplicate thread. After: ci-change-scope lockfile-only; strip stale short env keys; sheet open-focus retries + skip focus=1 reclaim under modal; squash-merged `d08ec2e8e`; branch deleted; key-file content-diff empty. | Hosted PR required SUCCESS (Production UI green on tip); focused local-presence vitest; no provider-backed checks. | | 2026-07-25 | cursor/fix-mobile-composer-edge-scroll-5b1d (PR #1192) | 3b5ef43f1825dd8cf11dd767069569ba1c701c45 | Bugbot branch review: mobile Answer edge dock, synchronized hide/reveal, focus safety, reserve-collapse and fractional-clamp safeguards | No bugs found. Highest residual risk is physical iOS Safari toolbar/visual-viewport behavior beyond Chromium emulation. | `npm run verify:cheap` (3,357 passed); `npm run verify:ui` (272 passed); focused clamp/reserve Vitest (28 passed); focused production Chromium regression passed; clean headed-phone video proof; no provider-backed checks run. | | 2026-07-25 | cursor/fix-mobile-composer-edge-scroll-5b1d (PR #1192) | 69dc0dbfb46586f54f5934199d4a65b9f6a0aba8 | User-requested Bugbot review of current PR head after geometry-aware clamp handling and CI formatting fix | No bugs found. | Bugbot branch review; prior focused unit/Chromium/manual proofs retained; no provider-backed checks run. | +| 2026-07-25 | cursor/fix-mobile-composer-edge-scroll-5b1d (PR #1192) | c08ed37c5e8c7b62cf7d547089afff7a63c9122c | Live local document-detail scroll/ownership review at 390x844, 768x1024, and 1440x900 | FINDINGS: P2 canonical phone detail renders shared mobile header plus DocumentViewer header; P2 expanded desktop sticky rail scrolls its section navigation off-screen; P3 390px in-flow section nav fully hides Images with no overflow cue. Composer focus pinning, actions sheet, endpoint clearance, safe-area gap, and single composer/content reserve ownership otherwise held. | `npm run workflow:design-sweep -- --write-evidence`; `npm run ensure` + `/api/local-project-id` identity; live local Chromium natural down/up, anchors, focus, sheet, endpoint and geometry probes; focused document-viewer Playwright 3/3; reduced-motion + forced-colors visibility at 390/1440; no OpenAI/Supabase/GitHub/hosted CI/provider calls. | From ac14e66e56e4ad7f60cdc1c860ea135928b45816 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:44:04 +0800 Subject: [PATCH 16/16] fix: resolve header scroll hide behavior --- .../master-search-header.tsx | 92 +++++++++++++++---- .../clinical-dashboard/use-hide-on-scroll.ts | 77 +++++++++++++--- 2 files changed, 137 insertions(+), 32 deletions(-) diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index b16bc0e923..6414bf74d0 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -266,6 +266,20 @@ export function MasterSearchHeader({ * matching top padding on its scroll container. */ allBreakpoints?: boolean; + /** + * Collapse-only: how the chrome hides above the phone breakpoint. Omitting + * it keeps the hide/reveal phone-only. + * + * "collapse" releases the header's layout row at every width — for hosts + * whose scrollport is an internal element at every width (ClinicalDashboard's + * `
`), where the released strip goes straight to the content. + * + * "sticky" pins the chrome to the viewport top and slides it away instead — + * for hosts that hand scrolling back to the document above the phone + * breakpoint (GlobalSearchShell), where releasing flow space would jump the + * page under the reader. + */ + wide?: "collapse" | "sticky"; /** Parent-owned hidden state for hosts that report scroll via React `onScroll`. */ scrollHidden?: boolean; }; @@ -1680,6 +1694,12 @@ export function MasterSearchHeader({ // flow (absolute over the scrolling
, which reserves matching top // padding) so content frosts under the glass bar at every width. const overlayAllBreakpoints = hideStrategy === "overlay" && Boolean(hideOnScroll?.allBreakpoints); + const wideCollapseBehaviour = hideStrategy === "collapse" ? hideOnScroll?.wide : undefined; + // Collapse hosts whose scrollport is internal at every width release the + // header row at every width too; hosts that hand scrolling back to the + // document above the phone breakpoint stick and translate there instead. + const collapsesAtEveryWidth = wideCollapseBehaviour === "collapse"; + const sticksAbovePhones = wideCollapseBehaviour === "sticky"; const chromeFocusProps = hideOnScroll ? { onFocusCapture: () => setHeaderChromeFocused(true), @@ -1709,13 +1729,19 @@ export function MasterSearchHeader({ "edge-glass-header universal-header z-30 py-2 pt-[max(0.5rem,env(safe-area-inset-top))] text-[color:var(--text)]", // Collapse hosts keep the header above an internally scrolling
, so // sticky is unnecessary on phones and fights the 0fr grid collapse by - // pinning the bar inside the viewport. All-breakpoints overlay hosts take - // the header out of flow entirely (absolute over the padded
) — - // sticky would be inert there because the scroll container is
, not - // an ancestor of the header. Legacy overlay hosts keep sticky (they ride - // document scroll) and can translate away with zero layout shift. + // pinning the bar inside the viewport. Above phones the pinning belongs + // to the collapse wrapper instead: this
sits inside two + // header-height boxes, which leaves a sticky rule here zero travel — the + // bar simply scrolled off with the page and only came back at scroll + // top. All-breakpoints overlay hosts take the header out of flow + // entirely (absolute over the padded
) — sticky would be inert + // there because the scroll container is
, not an ancestor of the + // header. Legacy overlay hosts keep sticky (they ride document scroll) + // and can translate away with zero layout shift. hideStrategy === "collapse" - ? "max-sm:relative sm:sticky sm:top-0" + ? sticksAbovePhones + ? "relative" + : "max-sm:relative sm:sticky sm:top-0" : overlayAllBreakpoints ? "absolute inset-x-0 top-0" : "sticky top-0", @@ -1937,32 +1963,62 @@ export function MasterSearchHeader({ ); if (hideStrategy === "collapse") { - // Collapse hide-on-scroll (phones): the host renders the header above an - // internally scrolling element, so hiding must also release the header's - // layout space. A 1fr -> 0fr grid row animates the collapse without any - // height measurement; the bottom-anchored inner track makes the chrome - // slide up out of the viewport top. Fixed-position composers (answer - // footer, mobile bottom search) escape the wrapper naturally because it - // never carries a transform, and everything is inert from sm up. + // Collapse hide-on-scroll: the host renders the header above an internally + // scrolling element, so hiding must also release the header's layout space. + // A 1fr -> 0fr grid row animates the collapse without any height + // measurement; the bottom-anchored inner track makes the chrome slide up + // out of the viewport top. Fixed-position composers (answer footer, mobile + // bottom search) escape the wrapper naturally because it carries no + // transform in this mode. + // + // Above the phone breakpoint a `wide: "sticky"` host scrolls the document + // instead, so this wrapper — not the
inside it, which has no + // sticky travel within its header-height parents — pins to the viewport top + // and translates away. Releasing the row there would pull the whole page up + // by the header height mid-scroll; translating leaves the geometry alone. return (
{headerAndComposer} diff --git a/src/components/clinical-dashboard/use-hide-on-scroll.ts b/src/components/clinical-dashboard/use-hide-on-scroll.ts index 692beeb584..fae24f098b 100644 --- a/src/components/clinical-dashboard/use-hide-on-scroll.ts +++ b/src/components/clinical-dashboard/use-hide-on-scroll.ts @@ -7,7 +7,8 @@ import { mobileComposerHiddenReserveRem } from "@/components/clinical-dashboard/ // Matches phoneSearchLayoutMediaQuery in master-search-header.tsx — the repo's // phone/tablet seam. Hide-on-scroll runs below the sm breakpoint unless the -// host opts into all breakpoints (the ClinicalDashboard glass-header overlay). +// host opts into all breakpoints (both app shells do this for the header; the +// bottom search dock stays phone-only). const phoneMediaQuery = "(max-width: 639px)"; // Scroll offset (px) that must be passed before the chrome may hide; the @@ -191,7 +192,14 @@ export function readChromeCollapseMetrics( scroller: HTMLElement, ): Pick { const collapse = document.querySelector('[data-testid="universal-header-collapse"]'); - const headerRelease = collapse instanceof HTMLElement ? collapse.getBoundingClientRect().height : 0; + // The 1fr -> 0fr grid IS the collapse mechanism, so the wrapper only hands + // layout back while it is a grid at the current width. Where it sticks and + // translates instead (GlobalSearchShell above the phone breakpoint, which + // hands scrolling back to the document), hiding costs the scroller nothing. + const headerRelease = + collapse instanceof HTMLElement && window.getComputedStyle(collapse).display === "grid" + ? collapse.getBoundingClientRect().height + : 0; const rootFontSize = Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16; const hiddenPadPx = mobileComposerHiddenReserveRem * rootFontSize; const padRelease = (element: Element | null): number => { @@ -221,7 +229,7 @@ function readPhoneMedia() { const usePhoneMediaStore = createBrowserStore(subscribeToPhoneMedia, readPhoneMedia, false); -function usePhoneScrollHideActive(disabled = false, allowAllBreakpoints = false) { +function useScrollHideActive(disabled = false, allowAllBreakpoints = false) { const isPhone = usePhoneMediaStore(); return (allowAllBreakpoints || isPhone) && !disabled; } @@ -230,9 +238,11 @@ function usePhoneScrollHideActive(disabled = false, allowAllBreakpoints = false) * Imperative scroll-offset reporter for hosts that already own a React `onScroll` * handler on the scrolling element (for example ClinicalDashboard `
`). * Pass `allowAllBreakpoints` when the consumer hides chrome at every width - * (the all-breakpoints glass-header overlay) instead of phones only. + * (both app shells do this for the header) instead of phones only, and + * `resetKey` when the host changes the scroll geometry under the reporter + * without remounting. */ -export function useScrollHideReporter(disabled = false, allowAllBreakpoints = false) { +export function useScrollHideReporter(disabled = false, allowAllBreakpoints = false, resetKey?: unknown) { const [hidden, setHidden] = useState(false); const hiddenRef = useRef(false); const lastOffsetRef = useRef(0); @@ -241,7 +251,7 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa const directionTravelRef = useRef(0); const scrollSourceRef = useRef(null); const hasScrollSourceRef = useRef(false); - const active = usePhoneScrollHideActive(disabled, allowAllBreakpoints); + const active = useScrollHideActive(disabled, allowAllBreakpoints); const reportScroll = useCallback( (report: number | ScrollMetrics) => { @@ -306,12 +316,11 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa return () => window.cancelAnimationFrame(frame); }, [active]); - // The gate widening/narrowing (e.g. ClinicalDashboard toggling answer mode) - // changes the scroll geometry underneath us (
gains/loses its header - // reserve), so a carried-over hidden flag or last offset would produce one - // spurious hide/reveal on the first post-switch scroll. Reset on the change - // itself — `active` can stay true across it on phones, so the effect above - // never fires there. + // A geometry switch under the reporter (e.g. ClinicalDashboard toggling answer + // mode, where
gains/loses its header reserve) would otherwise carry a + // stale hidden flag or last offset into the first post-switch scroll and + // produce one spurious hide/reveal. Reset on the change itself — `active` can + // stay true across it, so the effect above never fires there. useEffect(() => { hiddenRef.current = false; lastOffsetRef.current = 0; @@ -322,11 +331,51 @@ export function useScrollHideReporter(disabled = false, allowAllBreakpoints = fa hasScrollSourceRef.current = false; const frame = window.requestAnimationFrame(() => setHidden(false)); return () => window.cancelAnimationFrame(frame); - }, [allowAllBreakpoints]); + }, [allowAllBreakpoints, resetKey]); return { hidden: active && hidden, reportScroll }; } +/** + * Feeds document scroll into a {@link useScrollHideReporter} for hosts whose + * page scrolls the document above the phone breakpoint — GlobalSearchShell, + * where `#main-content` is the scrollport only on phones and its `onScroll` + * therefore never fires on tablet/desktop. Self-gating: while the document + * cannot scroll (the phone shell is `fixed inset-0`) no scroll event arrives, + * so the internal scroller stays the single source at that width. + */ +export function useDocumentScrollHideReporter(reportScroll: (metrics: ScrollMetrics) => void) { + useEffect(() => { + let frame = 0; + + const evaluate = () => { + frame = 0; + const scrollingElement = document.scrollingElement ?? document.documentElement; + const maxOffset = Math.max(0, scrollingElement.scrollHeight - window.innerHeight); + if (maxOffset <= 0) return; + reportScroll({ + offset: window.scrollY, + maxOffset, + // Chrome that sticks to the viewport and translates away releases no + // document layout, so there is no runway to protect here. + collapseBudget: 0, + source: window, + }); + }; + + const onScroll = () => { + if (frame) return; + frame = window.requestAnimationFrame(evaluate); + }; + + window.addEventListener("scroll", onScroll, { passive: true }); + return () => { + window.removeEventListener("scroll", onScroll); + if (frame) window.cancelAnimationFrame(frame); + }; + }, [reportScroll]); +} + interface UseHideOnScrollOptions { /** * Element that owns the scrolling. When omitted the window/document scroll @@ -355,7 +404,7 @@ export function useHideOnScroll({ resetKey, }: UseHideOnScrollOptions): boolean { const { hidden, reportScroll } = useScrollHideReporter(disabled); - const active = usePhoneScrollHideActive(disabled); + const active = useScrollHideActive(disabled); useEffect(() => { reportScroll(0);