diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 763cee04..3ac0425c 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1101,3 +1101,7 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-26 | PR #1254 / `apply-audit-remediation-fixes` | head `b5aa92f0febbd0425c5f3d809356946a7d2371f5` / squash `4d54abfa4d467b16e5e2f0fe3f16b8b3b52bc967` | prlanded after squash merge | LANDED. Provenance/governance UI tokens, citation telemetry, threshold inclusivity (`lt`/`lte`/`gt`/`gte`), locality audit kept out of offline pr-local. Supersedes earlier DO NOT MERGE babysit rows after thread resolve + green CI. Remote branch deleted by merge. | Hosted PR policy/PR required/Static/Unit/Build/Production UI/Migration SUCCESS; Codex P1 thread resolved; no provider-backed evals. | | 2026-07-26 | PR #1259 / `codex/phone-header-hidden-edge` | head `0025d17a2c8c727d4e6cf44ae368f6448a84eeba` / squash `ecf54db378c8081b36c8fd62eaadbd716a1750c8` | prlanded after squash merge | LANDED. Edge-to-edge phone header collapse + shared hide/reveal + portaled addon focus lifecycle. Supersedes earlier blocked babysit rows. Remote branch deleted by merge. | Hosted PR required/Production UI/Unit/Build/Static SUCCESS; focused phone-scroll/header contracts PASS; no provider-backed checks. | | 2026-07-26 | open-PR babysit sweep (Cursor) | `origin/main` @ `ecf54db378c8081b36c8fd62eaadbd716a1750c8` | triage + close + sequential land | Queue cleared. Closed #1255/#1253 as unsafe/superseded. Squash-merged useful #1257, #1212, #1241, #1248, #1254, and late-arriving #1259 after CI green + thread triage. Open PR count 0 at closeout. | Per-PR hosted required CI green at merge; merge-tree used to classify GitHub CONFLICTING as staleness vs real conflicts; no provider-backed checks. | + +| 2026-07-26 | `codex/phone-footer-glass` | `5ad4f8b28` | Final phone footer glass and scroll-stability review | APPROVE. Replaced the opaque phone footer/safe-area slab with localized translucent glass across shared docks and page-owned calculator/document composers; hidden chrome releases paint, pointer ownership, and reserve. Final review found and fixed calculator reserve under-budgeting plus Chromium scroll anchoring feedback, with insufficient-runway collapse refusal and sufficient-runway frame-monotonic hide/reveal. No P0-P3 findings remain. Highest residual risk is physical iOS/WebKit safe-area and momentum compositing beyond simulated Chromium. | `verify:cheap` PASS (393 files; 3518 passed / 2 skipped); focused Services/Calculators and calculator transition Chromium PASS; `verify:ui` production build 312/313 with the sole unchanged Answer short-runway geometry outlier immediately passing exact rerun 1/1; final diff review APPROVE; no provider-backed checks. | + +| 2026-07-26 | `codex/phone-footer-glass` | `7e4fd1a23` | Review-follow-up and CI hydration-race review | APPROVE. Scoped the expanded collapse runway only to combined in-flow header plus reserve owners, cleared the calculator dock focus latch after sheet teardown, and made mode-home UI assertions wait for one settled owner during production hydration. Both automated review threads were addressed and resolved. No P0-P3 findings remain; physical iOS/WebKit compositing remains the only material unverified surface. | `verify:cheap` PASS; focused scroll-hide/static contracts 25/25 PASS; focused calculator teardown/geometry Chromium 3/3 PASS; affected mode-home production Chromium 5/5 PASS; no provider-backed checks. | diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md index 9509524e..d1452b17 100644 --- a/docs/search-chrome-behaviour.md +++ b/docs/search-chrome-behaviour.md @@ -19,20 +19,21 @@ This repo uses one shared search experience across the global shell, dashboard r 2. Keep the CSS token `--phone-dock-hidden-pad` aligned with `mobileComposerHiddenReserve`. 3. A visible fixed phone dock may include `var(--safe-area-bottom)` so the pill clears the home indicator. 4. A hidden phone dock must release the content-facing reserve to `0rem`; do not use `env(safe-area-inset-bottom)` or `var(--safe-area-bottom)` for hidden content padding. -5. Edge-to-edge phone dock mode is `left: 0; right: 0; bottom: 0; width: 100%`; inset the pill with padding, not with a non-zero bottom offset. +5. Edge-to-edge phone dock mode is `left: 0; right: 0; bottom: 0; width: 100%`; inset the pill with padding, not with a non-zero bottom offset. Keep the dock form transparent and use its absolute `.answer-footer-search-backdrop` child for a localized translucent gradient/blur through the safe area; it must move and fade with the dock, never become a viewport-fixed or opaque slab. 6. Header and footer chrome that share the same scroll signal should hide/reveal symmetrically for the surfaces that actually hide: when the phone top bar is hidden, `chrome-safe-area-top` and the controls both release to `0rem` so underlying content paints to the physical viewport edge. The visible phone header still owns `var(--safe-area-top)`; tablet/desktop sticky chrome keeps its pinned inset. Top-bar hide/reveal is cross-breakpoint; the search field stays pinned on tablets, while desktop search belongs to page flow and scrolls away naturally; the bottom search dock is phone-only. Hidden bottom dock reserve stays `0rem` (invariant 4). Read "Scroll hide/reveal" below before changing either. 7. Do not add page-local dock-sized `pb-[calc(...safe-area...)]` under a shell-owned dock. Put clearance in the shared reserve or the page-owned composer, never both. 8. `GlobalSearchShell` uses an inner `mobile-composer-reserve-pad` so phone padding contributes to scroll height; do not move phone shell clearance back to scrollport padding without a browser proof. -9. Keep collapse-budget policy geometry-aware: an in-flow collapsing phone header needs enough remaining runway to absorb controls + released top safe-area + dock clearance, while a fixed overlay that only releases bottom reserve may hide when its post-collapse range retains the top reveal band plus deliberate hide intent _and_ the current offset already fits that post-collapse range (no material near-bottom clamp). Do not use synthetic page padding to make the stricter gate pass. -10. Detect reserve-transition clamps from geometry, not a wider pixel tolerance: if the scroll range shrinks and the previous offset no longer fits inside the new maximum, rebase that frame as layout feedback. Once the range stabilizes, the same upward movement must reveal normally. -11. Standalone mode-home detection (`isStandaloneModeHomePath`) is pathname-only. Do not gate hero vs dock on a React `searchMode` that can update before the router pathname lands — that one-frame mismatch animates reserve padding and reads as a choppy screen resize. -12. Phone `#main-content` / reserve-pad `padding-bottom` transitions apply while `data-reserve-transitioning="true"` (scroll-hide and reveal). Mode and route reserve flips clear that marker immediately and must snap. -13. Shared shell must reset phone scroll offset and scroll-hide state on `pathname` change so mode homes do not inherit a mid-page offset or collapsed header. -14. Hero composer portal: keep the default composer mounted until the portal host is actually attached; do not hide on `slotId` alone (mode-home remounts otherwise flash a null gap). -15. Leaving the dashboard shell for a namespaced mode (`selectSearchMode` / `crossModeSearch`) must navigate without rewriting dashboard chrome first. -16. Do not wrap mode-home `{children}` in `ClientHydrationBoundary` — that blanks RSC HTML until JS mounts. Keep hydration guards on the specific leaf that mismatches. Do not call `useSearchParams()` in an ancestor Suspense that also renders route `{children}`: that nests the page segment inside the shell’s incomplete streaming boundary and can leave a persistent hidden `S:` clone (duplicate page-root `data-testid`s). Gate always-standalone pathnames with `isAlwaysStandaloneShellPath`, and bridge search params beside the shell body via `ShellSearchParamsBridge`. -17. Standalone mode-home `loading.tsx` files must render `ModeHomeRouteLoading` (phone top-aligned). Do not reuse unrelated results/medication skeletons. -18. `ClinicalDashboard` must stay out of the shared shell’s static import graph (dynamic import) so namespaced mode routes do not parse the dashboard module. +9. Page-owned fixed phone composers follow the same release contract: calculators use the shared footer backdrop; DocumentViewer keeps its floating pill but synchronizes transform, opacity, pointer release, and its own zero-reserve content padding. In-flow hero composers remain free of fixed-footer glass. +10. Keep collapse-budget policy geometry-aware: an in-flow collapsing phone header needs enough remaining runway to absorb controls + released top safe-area + dock clearance, while a fixed overlay that only releases bottom reserve may hide when its post-collapse range retains the top reveal band plus deliberate hide intent _and_ the current offset already fits that post-collapse range (no material near-bottom clamp). Do not use synthetic page padding to make the stricter gate pass. +11. 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. +12. Standalone mode-home detection (`isStandaloneModeHomePath`) is pathname-only. Do not gate hero vs dock on a React `searchMode` that can update before the router pathname lands — that one-frame mismatch animates reserve padding and reads as a choppy screen resize. +13. Phone `#main-content` / reserve-pad `padding-bottom` transitions apply while `data-reserve-transitioning="true"` (scroll-hide and reveal). Mode and route reserve flips clear that marker immediately and must snap. +14. Shared shell must reset phone scroll offset and scroll-hide state on `pathname` change so mode homes do not inherit a mid-page offset or collapsed header. +15. Hero composer portal: keep the default composer mounted until the portal host is actually attached; do not hide on `slotId` alone (mode-home remounts otherwise flash a null gap). +16. Leaving the dashboard shell for a namespaced mode (`selectSearchMode` / `crossModeSearch`) must navigate without rewriting dashboard chrome first. +17. Do not wrap mode-home `{children}` in `ClientHydrationBoundary` — that blanks RSC HTML until JS mounts. Keep hydration guards on the specific leaf that mismatches. Do not call `useSearchParams()` in an ancestor Suspense that also renders route `{children}`: that nests the page segment inside the shell’s incomplete streaming boundary and can leave a persistent hidden `S:` clone (duplicate page-root `data-testid`s). Gate always-standalone pathnames with `isAlwaysStandaloneShellPath`, and bridge search params beside the shell body via `ShellSearchParamsBridge`. +18. Standalone mode-home `loading.tsx` files must render `ModeHomeRouteLoading` (phone top-aligned). Do not reuse unrelated results/medication skeletons. +19. `ClinicalDashboard` must stay out of the shared shell’s static import graph (dynamic import) so namespaced mode routes do not parse the dashboard module. ## Scroll hide/reveal diff --git a/src/app/globals.css b/src/app/globals.css index e8a35e92..127e9da5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -863,22 +863,22 @@ summary::-webkit-details-marker { } .answer-footer-search-dock .answer-footer-search-backdrop { - position: fixed; + position: absolute; inset-inline: 0; bottom: 0; top: auto; z-index: 0; height: var(--footer-scrim-height); overflow: hidden; - /* Bottom of the scrim must reach near-opaque so the safe-area band under the - pill reads as continuous dock chrome (edge-to-edge), not a white strip. */ + /* The safe-area tint stays visibly translucent: this is localized glass + around the pill, never a second opaque footer surface. */ background: linear-gradient( 180deg, transparent 0%, color-mix(in srgb, var(--background) 12%, transparent) 32%, color-mix(in srgb, var(--background) 48%, transparent) 62%, - color-mix(in srgb, var(--background) 82%, transparent) 84%, - var(--background) 100% + color-mix(in srgb, var(--background) 64%, transparent) 84%, + color-mix(in srgb, var(--background) 72%, transparent) 100% ); backdrop-filter: blur(2px) saturate(130%); mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 55%) 28%, black 52%, black 100%); @@ -925,7 +925,7 @@ summary::-webkit-details-marker { transparent 0%, color-mix(in srgb, var(--background) 45%, transparent) 36%, color-mix(in srgb, var(--background) 82%, transparent) 68%, - var(--background) 100% + color-mix(in srgb, var(--background) 88%, transparent) 100% ); } @@ -970,7 +970,7 @@ summary::-webkit-details-marker { transparent 0%, color-mix(in srgb, var(--background) 50%, transparent) 36%, color-mix(in srgb, var(--background) 86%, transparent) 72%, - var(--background) 100% + color-mix(in srgb, var(--background) 90%, transparent) 100% ); } @@ -1831,9 +1831,8 @@ summary::-webkit-details-marker { 0 8px 22px rgb(16 24 40 / 8%); } - /* Edge-to-edge phone dock: one full-width footer surface flush to the - viewport. The pill is the only translucent layer; the dock itself paints - the safe-area/home-indicator region. Never add a non-zero bottom offset. */ + /* Edge-to-edge phone dock: the form remains transparent while its localized + child backdrop tints the pill and safe area. Never add a bottom offset. */ .answer-footer-search-dock.answer-footer-search-edge, .answer-footer-search-dock.dashboard-composer-edge.answer-footer-search-edge, .answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge { @@ -1846,7 +1845,7 @@ summary::-webkit-details-marker { padding-inline: max(0.75rem, var(--safe-area-left)) max(0.75rem, var(--safe-area-right)); padding-top: 0.5rem; padding-bottom: max(0.5rem, var(--safe-area-bottom)); - background: var(--surface); + background: transparent; } .answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge.document-mobile-search-compact, @@ -1864,6 +1863,7 @@ summary::-webkit-details-marker { .answer-footer-search-dock.document-mobile-search-edge.answer-footer-search-edge[data-scroll-hidden="true"], .answer-footer-search-dock.dashboard-composer-edge.answer-footer-search-edge[data-scroll-hidden="true"] { transform: translateY(calc(100% + var(--safe-area-bottom))); + opacity: 0; pointer-events: none; } @@ -1886,11 +1886,13 @@ summary::-webkit-details-marker { that gap. translateY-only: this form centres with mx-auto, not translateX. */ .document-viewer-composer[data-scroll-hidden="true"] { transform: translateY(calc(100% + max(0.75rem, var(--safe-area-bottom)))); + opacity: 0; pointer-events: none; } .answer-footer-search-dock .answer-footer-search-backdrop { - display: none; + display: block; + position: absolute; } .answer-footer-search-dock .answer-footer-search-pill { @@ -2681,12 +2683,23 @@ html[data-motion="reduced"] .source-capsule-hit[aria-expanded="true"]:hover .sou will-change: padding-bottom; transition: padding-bottom 240ms var(--ease-out-soft); } + /* A page-owned reserve changes scroll height below the current viewport. + Suspend browser anchoring only for that transition so Chrome cannot turn + the intended padding release into a synthetic upward scroll/reveal loop. */ + #main-content:has([data-reserve-owner][data-reserve-transitioning="true"]) { + overflow-anchor: none; + } + [data-reserve-owner][data-reserve-transitioning="true"] { + will-change: padding-bottom; + transition: padding-bottom 240ms var(--ease-out-soft); + } } @media (max-width: 639px) and (prefers-reduced-motion: reduce) { #main-content[data-reserve-transitioning="true"], #main-content[data-reserve-transitioning="true"] [data-testid="mobile-composer-reserve-pad"], - [data-testid="document-viewer-content"][data-reserve-transitioning="true"] { + [data-testid="document-viewer-content"][data-reserve-transitioning="true"], + [data-reserve-owner][data-reserve-transitioning="true"] { transition: none; } } diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 81ac9156..ac745360 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1654,7 +1654,7 @@ export function DocumentViewer({ }} className={cn( glassOverlaySurface, - "document-viewer-composer floating-composer-edge dashboard-composer-edge fixed z-40 mx-auto flex min-h-[56px] max-w-3xl items-center gap-2 rounded-full bg-[color:var(--surface-lux)] px-2 shadow-[var(--shadow-lux)] max-sm:transition-transform motion-reduce:transition-none", + "document-viewer-composer floating-composer-edge dashboard-composer-edge fixed z-40 mx-auto flex min-h-[56px] max-w-3xl items-center gap-2 rounded-full bg-[color:var(--surface-lux)] px-2 shadow-[var(--shadow-lux)] max-sm:transition-[transform,opacity] motion-reduce:transition-none", composerScrollHidden ? "max-sm:duration-[240ms] max-sm:ease-[cubic-bezier(0.4,0,0.2,1)]" : "max-sm:duration-200 max-sm:ease-[cubic-bezier(0.22,1,0.36,1)]", diff --git a/src/components/calculators/search-page.tsx b/src/components/calculators/search-page.tsx index ace33c4f..0e8202aa 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -20,7 +20,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout"; -import { useHideOnScroll } from "@/components/clinical-dashboard/use-hide-on-scroll"; +import { useHideOnScroll, useReserveTransitionMarker } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { PrivacyInputNotice } from "@/components/privacy-input-notice"; import { chatComposerInput, chatComposerShellBase, chatSendButton, cn, eyebrowText } from "@/components/ui-primitives"; @@ -518,6 +518,20 @@ export function CalculatorsSearchPage() { // focused input off-screen or mark it aria-hidden while still tabbable. const [dockFocused, setDockFocused] = useState(false); const dockHidden = footerHidden && !dockFocused; + const reserveTransitioning = useReserveTransitionMarker(dockHidden, activeCalc); + useEffect(() => { + if (!activeCalc) return undefined; + let cancelled = false; + // Submitting a focused dock input unmounts the dock before React is + // guaranteed to dispatch blur. Clear the latch after teardown so the dock + // can resume hide-on-scroll when the calculator sheet closes. + queueMicrotask(() => { + if (!cancelled) setDockFocused(false); + }); + return () => { + cancelled = true; + }; + }, [activeCalc]); const compact = density === "compact"; @@ -535,9 +549,15 @@ export function CalculatorsSearchPage() { {/* Desktop: universal-style composer at the top, matching the site-wide @@ -659,17 +679,21 @@ export function CalculatorsSearchPage() { {activeCalc ? null : (
setDockFocused(true)} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDockFocused(false); }} className={cn( - "fixed inset-x-0 bottom-0 z-40 border-t border-[color:var(--border)] bg-[color:var(--surface-glass)] px-3 pb-[calc(0.75rem+var(--safe-area-bottom))] pt-3 backdrop-blur-md transition-transform duration-200 ease-out motion-reduce:transition-none sm:hidden", - dockHidden ? "translate-y-full" : "translate-y-0", + "answer-footer-search-dock answer-footer-search-edge fixed inset-x-0 bottom-0 z-40 px-3 pb-[calc(0.75rem+var(--safe-area-bottom))] pt-3 transition-[transform,opacity] motion-reduce:transition-none sm:hidden", + dockHidden + ? "pointer-events-none duration-[240ms] ease-[cubic-bezier(0.4,0,0.2,1)]" + : "duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]", )} aria-hidden={dockHidden} inert={dockHidden || undefined} > +