From f5ccea63de50a63c449dbfd91e74cb33b2b94a36 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:04:52 +0800 Subject: [PATCH 1/7] style: soften phone search footer glass --- docs/search-chrome-behaviour.md | 23 +++++++------ src/app/globals.css | 26 +++++++------- src/components/DocumentViewer.tsx | 2 +- src/components/calculators/search-page.tsx | 8 +++-- .../master-search-header.tsx | 2 +- tests/mobile-chrome-paint-contract.test.ts | 10 ++++-- tests/ui-phone-scroll.spec.ts | 34 ++++++++++++++----- 7 files changed, 67 insertions(+), 38 deletions(-) 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..fa5d7aa9 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 { 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..153a8b7d 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -659,17 +659,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} > +