diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 250735a6fb..b42616b809 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -872,3 +872,6 @@ 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. | +| 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. | diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 54835a095b..fa64e35bad 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -22,6 +22,8 @@ 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 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 diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index ca493afe7c..afa1bfd1de 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -89,7 +89,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, @@ -354,6 +354,7 @@ export function ClinicalDashboard({ const [modeSearchSubmitted, setModeSearchSubmitted] = useState(() => Boolean(autoRunSearch && initialQuery.trim() && initialSearchMode !== "tools"), ); + const shouldAutoFocusComposer = focusSearch && !(searchMode === "answer" && modeSearchSubmitted); const [answer, setAnswer] = useState(null); const [sources, setSources] = useState([]); // Answer-mode conversation thread. `priorAnswerTurns` holds completed @@ -1495,11 +1496,14 @@ export function ClinicalDashboard({ }, []); useEffect(() => { - if (!focusSearch) return undefined; - focusComposerInput(); - const timeout = window.setTimeout(focusComposerInput, 500); + if (!shouldAutoFocusComposer) { + if (document.activeElement === composerInputRef.current) composerInputRef.current?.blur(); + return undefined; + } + focusComposerInput(true); + const timeout = window.setTimeout(() => focusComposerInput(true), 500); return () => window.clearTimeout(timeout); - }, [focusSearch]); + }, [shouldAutoFocusComposer]); // Abort any in-flight answer/library search if the dashboard unmounts. useEffect(() => { @@ -1536,7 +1540,7 @@ export function ClinicalDashboard({ setLoading(false); setError(null); setAnswerProgress(null); - if (shouldFocusComposer) focusComposerInput(); + if (shouldFocusComposer) focusComposerInput(true); }); return () => window.cancelAnimationFrame(frame); }, [searchParams, clearDifferentialModeResultState]); @@ -1556,7 +1560,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]); @@ -2611,10 +2615,15 @@ export function ClinicalDashboard({ router.push(appModeHomeHref(mode, { queryMode, scopeFilters })); } - function focusComposerInput() { + function focusComposerInput(retainTarget = false) { + const requestedInput = retainTarget ? composerInputRef.current : null; + const focusBoundInput = () => { + const input = retainTarget ? requestedInput : composerInputRef.current; + if (input?.isConnected && composerInputRef.current === input) input.focus({ preventScroll: true }); + }; window.requestAnimationFrame(() => { - composerInputRef.current?.focus({ preventScroll: true }); - window.setTimeout(() => composerInputRef.current?.focus({ preventScroll: true }), 150); + focusBoundInput(); + window.setTimeout(focusBoundInput, 150); }); } @@ -2804,7 +2813,7 @@ export function ClinicalDashboard({ reportPhoneScrollHideRef.current({ offset: main.scrollTop, maxOffset: Math.max(0, main.scrollHeight - main.clientHeight), - collapseBudget: readChromeCollapseBudget(main), + ...readChromeCollapseMetrics(main), source: main, }); }); @@ -3366,7 +3375,7 @@ export function ClinicalDashboard({ }} queryModeOptions={clinicalQueryModeOptions} queryInputRef={composerInputRef} - queryInputAutoFocus={focusSearch} + queryInputAutoFocus={shouldAutoFocusComposer} recentQueries={recentQueries} commandScopes={commandScopes} onCommandScopesChange={setCommandScopes} diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 991c061720..f1a2cc5f34 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -31,7 +31,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 { @@ -506,7 +506,7 @@ function GlobalStandaloneSearchShellClient({ phoneScrollHide.reportScroll({ offset: target.scrollTop, maxOffset: Math.max(0, target.scrollHeight - target.clientHeight), - collapseBudget: readChromeCollapseBudget(target), + ...readChromeCollapseMetrics(target), source: target, }); } @@ -531,7 +531,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 9b631d10f5..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; }; @@ -350,8 +364,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. @@ -364,14 +389,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); @@ -1676,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), @@ -1705,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", @@ -1933,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/mobile-composer-reserve.ts b/src/components/clinical-dashboard/mobile-composer-reserve.ts index d853669f9e..4e507d7d42 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..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 @@ -25,9 +26,9 @@ 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. +// 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 @@ -46,12 +47,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 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 retains the top reveal band plus deliberate intent. + */ + collapseKind?: "in-flow" | "reserve-only"; source?: EventTarget; } @@ -60,7 +67,9 @@ export function computeScrollHideUpdate(params: { offset: number; lastOffset: number; maxOffset?: number; + previousMaxOffset?: number; collapseBudget?: number; + collapseKind?: "in-flow" | "reserve-only"; sourceChanged?: boolean; currentlyHidden: boolean; direction?: ScrollDirection; @@ -75,7 +84,9 @@ export function computeScrollHideUpdate(params: { offset, lastOffset, maxOffset, + previousMaxOffset, collapseBudget, + collapseKind, sourceChanged = false, currentlyHidden, direction = null, @@ -92,20 +103,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 @@ -125,20 +139,30 @@ 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); - // 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. + 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. 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; 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 >= effectiveHideActivationOffset + hideIntentDistance + : runwayAfterCollapse > revealIntentDistance + collapseRunwaySlack; + hidden = travelPastActivation >= hideIntentDistance && collapseHasSafeRunway; } else if (currentlyHidden && nextDirection === "up" && nextDirectionTravel >= revealIntentDistance) { hidden = false; } @@ -161,11 +185,21 @@ 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; + // 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 => { @@ -177,7 +211,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) { @@ -192,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; } @@ -201,38 +238,57 @@ 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); + const lastMaxOffsetRef = useRef(undefined); const directionRef = useRef(null); 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) => { - 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; + 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, currentlyHidden: hiddenRef.current, direction: directionRef.current, @@ -251,6 +307,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; @@ -259,26 +316,66 @@ 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; + lastMaxOffsetRef.current = undefined; directionRef.current = null; directionTravelRef.current = 0; scrollSourceRef.current = null; 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 @@ -307,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); @@ -329,7 +426,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 79e5b33ffc..f039a05dee 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 9e554a2cbb..e748d1544a 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2120,6 +2120,97 @@ 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(); + 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); + + // 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 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) }; + }); + // 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); + 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); + 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 }) => { 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..9363fe36ce 100644 --- a/tests/use-hide-on-scroll.test.ts +++ b/tests/use-hide-on-scroll.test.ts @@ -293,6 +293,124 @@ describe("computeScrollHideUpdate", () => { }); }); + 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: 159, + collapseBudget: 120, + collapseKind: "in-flow", + currentlyHidden: false, + direction: "down", + directionTravel: 80, + }).hidden, + ).toBe(false); + + // 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: 40, + lastOffset: 0, + maxOffset: 145, + collapseBudget: 120, + collapseKind: "reserve-only", + currentlyHidden: false, + }).hidden, + ).toBe(false); + }); + + 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. + // This is layout feedback near the new bottom, not upward user intent. + expect( + computeScrollHideUpdate({ + offset: 125.9683, + lastOffset: 160.5079, + maxOffset: 127, + previousMaxOffset: 160, + collapseBudget: 23.3347, + collapseKind: "reserve-only", + currentlyHidden: true, + direction: "down", + directionTravel: 160.5079, + }), + ).toEqual({ + hidden: true, + lastOffset: 125.9683, + 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", () => { expect( computeScrollHideUpdate({