From ba3741b9658a603309143d691aac2f951f0dc83d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 09:39:54 +0000 Subject: [PATCH 1/2] feat(ui): edge-to-edge glass header with progressive-blur scrolling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the top header up to the bottom composer dock's standard: a translucent glass bar with a top progressive-blur scrim, edge-to-edge under the notch/status bar, that content scrolls beneath and frosts against — mirroring the dock's masked 2px/8px/22px three-pass blur but inverted so it is strongest at the physical top edge. - Glass bar: .edge-glass-header/.universal-header backgrounds become color-mix(var(--surface) 72%, transparent), activating the header's previously inert backdrop-blur; the hard border-bottom and the old below-header ::after gradient are superseded by the new .edge-glass-header-backdrop scrim (rendered inside
so it slides away with it). Mode pill and icon controls keep their opaque inline surfaces and stay legible. - Answer view overlays the header at every breakpoint: the header goes absolute inside the (now relative) content column and
reserves the bar's exact measured height (72px + safe-area-top) as top padding, so scrolled content passes beneath the glass; the reserve is constant (it sits at scroll-start and is already off-screen whenever the header hides). scroll-padding-top keeps scrollIntoView targets clear of the bar. The header slides away on scroll-down and returns on scroll-up at all widths (new allBreakpoints overlay in useScrollHideReporter / MasterSearchHeader; activation offset raised to the bar height). - Non-answer modes keep the in-flow header + phone-only collapse (their sm+ composer renders beneath the header, which an absolute bar would bury) while still gaining the glass + scrim visuals. - Accessibility fallbacks mirror the dock: no-backdrop-filter and prefers-reduced-transparency force an opaque bar + stronger scrim tint; forced-colors paints solid Canvas; motion-reduce disables the slide transition. - Tests: the header-hide smoke test is rewritten for the overlay mechanism (absolute header, data-scroll-hidden on
, reserve == bar height), plus new desktop hide/reveal and non-answer collapse coverage. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A1ekcXQZVFVKKzdoVpjyVQ --- src/app/globals.css | 143 ++++++++++++++++-- src/components/ClinicalDashboard.tsx | 80 ++++++---- .../master-search-header.tsx | 41 +++-- .../clinical-dashboard/use-hide-on-scroll.ts | 20 ++- tests/ui-smoke.spec.ts | 84 +++++++++- 5 files changed, 306 insertions(+), 62 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 88624cfc7d..2c3b03f673 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -543,24 +543,17 @@ summary::-webkit-details-marker { isolation: isolate; padding-left: max(0.75rem, var(--safe-area-left)); padding-right: max(0.75rem, var(--safe-area-right)); - border-bottom: 1px solid var(--border); - background: var(--surface); + /* Translucent glass bar: the header's backdrop-blur utilities frost the + content scrolling beneath it (they were inert over the old opaque + var(--surface)). The .edge-glass-header-backdrop scrim supersedes the + old hard border-bottom and below-header ::after gradient. Keep this + value in lock-step with .universal-header below (chrome parity). */ + background: color-mix(in srgb, var(--surface) 72%, transparent); box-shadow: none; } - .edge-glass-header::after { - pointer-events: none; - position: absolute; - inset-inline: 0; - bottom: -1.25rem; - z-index: -1; - height: 1.25rem; - background: linear-gradient(180deg, color-mix(in srgb, var(--background) 54%, transparent), transparent); - content: ""; - } - .universal-header { - background: var(--surface); + background: color-mix(in srgb, var(--surface) 72%, transparent); } .universal-header-mode-button { @@ -663,6 +656,64 @@ summary::-webkit-details-marker { } } +/* Top progressive-blur scrim behind the glass header — the vertical mirror of + the bottom dock's .answer-footer-search-backdrop three-pass blur below. + Strongest blur/tint hugs the physical top edge (status-bar/notch band via + safe-area-inset-top) and fades downward into content, so scrolling text + frosts as it slides under the bar. Lives INSIDE
(first child) so it + translates away with the header on scroll-hide. Unlayered on purpose, like + the rest of the positioning chrome (see the layering note above). */ +.edge-glass-header-backdrop { + pointer-events: none; + position: absolute; + inset-inline: 0; + top: 0; + z-index: -1; + height: max(6.5rem, calc(var(--safe-area-top) + 5.5rem)); + overflow: hidden; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--background) 42%, transparent) 0%, + color-mix(in srgb, var(--background) 32%, transparent) 12%, + color-mix(in srgb, var(--background) 18%, transparent) 32%, + color-mix(in srgb, var(--background) 8%, transparent) 62%, + transparent 100% + ); + backdrop-filter: blur(2px) saturate(130%); + -webkit-backdrop-filter: blur(2px) saturate(130%); + mask-image: linear-gradient(180deg, black 0%, black 45%, rgb(0 0 0 / 45%) 72%, rgb(0 0 0 / 20%) 100%); + -webkit-mask-image: linear-gradient(180deg, black 0%, black 45%, rgb(0 0 0 / 45%) 72%, rgb(0 0 0 / 20%) 100%); +} + +.edge-glass-header-backdrop::before, +.edge-glass-header-backdrop::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; +} + +.edge-glass-header-backdrop::before { + backdrop-filter: blur(8px) saturate(130%); + -webkit-backdrop-filter: blur(8px) saturate(130%); + mask-image: linear-gradient(180deg, black 0%, black 20%, rgb(0 0 0 / 55%) 45%, rgb(0 0 0 / 35%) 65%, transparent 100%); + -webkit-mask-image: linear-gradient( + 180deg, + black 0%, + black 20%, + rgb(0 0 0 / 55%) 45%, + rgb(0 0 0 / 35%) 65%, + transparent 100% + ); +} + +.edge-glass-header-backdrop::after { + backdrop-filter: blur(22px) saturate(140%); + -webkit-backdrop-filter: blur(22px) saturate(140%); + mask-image: linear-gradient(180deg, black 0%, black 32%, transparent 50%, transparent 100%); + -webkit-mask-image: linear-gradient(180deg, black 0%, black 32%, transparent 50%, transparent 100%); +} + .answer-footer-search-backdrop { pointer-events: none; position: fixed; @@ -787,6 +838,30 @@ summary::-webkit-details-marker { color-mix(in srgb, var(--background) 92%, transparent) 100% ); } + + /* No blur available: the glass header goes opaque so text never sits on + un-frosted scrolled content, and the top scrim swaps to a stronger tint. */ + .edge-glass-header-backdrop, + .edge-glass-header-backdrop::before, + .edge-glass-header-backdrop::after { + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + .edge-glass-header-backdrop { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--background) 92%, transparent) 0%, + color-mix(in srgb, var(--background) 78%, transparent) 40%, + color-mix(in srgb, var(--background) 45%, transparent) 72%, + transparent 100% + ); + } + + .edge-glass-header, + .universal-header { + background: var(--surface); + } } @media (prefers-reduced-transparency: reduce) { @@ -806,6 +881,29 @@ summary::-webkit-details-marker { color-mix(in srgb, var(--background) 94%, transparent) 100% ); } + + /* Reduced transparency: same opaque-bar treatment as the no-blur fallback. */ + .edge-glass-header-backdrop, + .edge-glass-header-backdrop::before, + .edge-glass-header-backdrop::after { + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + .edge-glass-header-backdrop { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--background) 94%, transparent) 0%, + color-mix(in srgb, var(--background) 82%, transparent) 42%, + color-mix(in srgb, var(--background) 50%, transparent) 74%, + transparent 100% + ); + } + + .edge-glass-header, + .universal-header { + background: var(--surface); + } } @layer components { @@ -2002,6 +2100,23 @@ summary::-webkit-details-marker { select { forced-color-adjust: auto; } + + /* Glass header: color-mix(Canvas, transparent) would thin system-color + contrast, so force a solid Canvas bar and drop the scrim blur entirely. */ + .edge-glass-header, + .universal-header { + background: Canvas; + } + + .edge-glass-header-backdrop, + .edge-glass-header-backdrop::before, + .edge-glass-header-backdrop::after { + background: Canvas; + backdrop-filter: none; + -webkit-backdrop-filter: none; + mask-image: none; + -webkit-mask-image: none; + } } @media print { diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 218fa3b409..83a5386815 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -709,9 +709,6 @@ export function ClinicalDashboard({ mainRef.current = node; setMainScrollRoot(node); }, []); - const phoneScrollHide = useScrollHideReporter(); - const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll); - reportPhoneScrollHideRef.current = phoneScrollHide.reportScroll; const [bottomSearchScrollHidden, setBottomSearchScrollHidden] = useState(false); const composerInputRef = useRef(null); const scrollFrameRef = useRef(null); @@ -737,6 +734,12 @@ export function ClinicalDashboard({ const [answerThreadBootstrapped, setAnswerThreadBootstrapped] = useState(false); const [query, setQuery] = useState(initialQuery); const [searchMode, setSearchMode] = useState(initialSearchMode); + // Answer mode hides the glass header at every breakpoint (all-breakpoints + // overlay); other modes keep the phone-only collapse, so the reporter only + // widens past the phone media gate while in answer mode. + const phoneScrollHide = useScrollHideReporter(false, searchMode === "answer"); + const reportPhoneScrollHideRef = useRef(phoneScrollHide.reportScroll); + reportPhoneScrollHideRef.current = phoneScrollHide.reportScroll; const [modeSearchSubmitted, setModeSearchSubmitted] = useState(() => Boolean(autoRunSearch && initialQuery.trim() && initialSearchMode !== "tools"), ); @@ -3465,7 +3468,7 @@ export function ClinicalDashboard({ onPrefetchApplications={prefetchApplications} /> -
+
, - // so hiding must collapse its layout space to hand it to content. - hideOnScroll={{ strategy: "collapse", scrollHidden: phoneScrollHide.hidden }} + // Answer view: the header overlays the scrolling
at every width + // (main reserves matching top padding) so content frosts under the + // glass bar, and it slides away/returns with scroll direction. Other + // modes keep the phone-only collapse (their sm+ composer renders + // in-flow below the header, which an absolute header would bury). + hideOnScroll={ + searchMode === "answer" + ? { strategy: "overlay", allBreakpoints: true, scrollHidden: phoneScrollHide.hidden } + : { strategy: "collapse", scrollHidden: phoneScrollHide.hidden } + } onBottomComposerScrollHiddenChange={setBottomSearchScrollHidden} /> - {privateScopeStatus === "unavailable" ? ( -
-

- The original private document scope is unavailable. Choose the documents again or confirm a broader - search. -

-
- - -
-
- ) : null} -
reserves its exact height as top padding (72px borderless + // bar = 4rem content/padding + the max(0.5rem, safe-area) top inset — + // measured; must stay 1:1 with the rendered #search height so all the + // dvh-based section floors below keep their meaning). Padding, not + // margin: padding scrolls with content, which is what lets it slide + // up and frost beneath the bar. Kept constant when the header + // scroll-hides — the reserve lives at scroll-start, already off-screen + // whenever the header is hidden, so reclaiming it would only jump + // the content. + searchMode === "answer" && + "pt-[calc(4rem+max(0.5rem,env(safe-area-inset-top)))] [scroll-padding-top:calc(4.5rem+max(0.5rem,env(safe-area-inset-top)))]", searchMode === "answer" ? compactMobileModeHome ? "mb-0" @@ -3584,6 +3585,29 @@ export function ClinicalDashboard({ )} >

Clinical Guide

+ {privateScopeStatus === "unavailable" ? ( + // Lives inside
(not as a header sibling): in the answer view + // the header is absolute, so a sibling alert would reflow to the + // column top and hide behind the glass bar. +
+

+ The original private document scope is unavailable. Choose the documents again or confirm a broader + search. +

+
+ + +
+
+ ) : null}