Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 136 additions & 14 deletions src/app/globals.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand DownExpand Up@@ -663,6 +656,71 @@ 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 <header> (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;
Expand DownExpand Up@@ -787,6 +845,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) {
Expand All@@ -806,6 +888,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 {
Expand DownExpand Up@@ -2002,6 +2107,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 {
Expand Down
80 changes: 52 additions & 28 deletions src/components/ClinicalDashboard.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<HTMLInputElement>(null);
const scrollFrameRef = useRef<number | null>(null);
Expand All@@ -737,6 +734,12 @@ export function ClinicalDashboard({
const [answerThreadBootstrapped, setAnswerThreadBootstrapped] = useState(false);
const [query, setQuery] = useState(initialQuery);
const [searchMode, setSearchMode] = useState<AppModeId>(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"),
);
Expand DownExpand Up@@ -3465,7 +3468,7 @@ export function ClinicalDashboard({
onPrefetchApplications={prefetchApplications}
/>

<div className="flex min-h-0 min-w-0 flex-1 flex-col md:h-full">
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col md:h-full">
<MasterSearchHeader
documents={documents}
documentTotal={indexedDocumentTotal}
Expand DownExpand Up@@ -3518,40 +3521,38 @@ export function ClinicalDashboard({
differentialsCompareAddonActive ? differentialsMobileCompareAddonSlotId : undefined
}
desktopHomeComposerSlotId={desktopHomeComposerSlotId}
// Phone-only: the header sits above the internally scrolling <main>,
// 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 <main> 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" ? (
<div
role="alert"
data-testid="private-scope-unavailable"
className="mx-3 mt-3 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] px-3 py-2 text-sm text-[color:var(--text)] sm:mx-4 lg:mx-8"
>
<p>
The original private document scope is unavailable. Choose the documents again or confirm a broader
search.
</p>
<div className="flex flex-wrap gap-2">
<button type="button" className={floatingControl} onClick={reselectUnavailablePrivateScope}>
Reselect documents
</button>
<button type="button" className={floatingControl} onClick={runWithoutUnavailablePrivateScope}>
Run without private scope
</button>
</div>
</div>
) : null}

<main
id="main-content"
ref={assignMainRef}
tabIndex={-1}
onScroll={handleMainScroll}
className={cn(
"min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain [-webkit-overflow-scrolling:touch] focus:outline-none",
// Answer view: the glass header is absolute over this scroll container,
// so <main> 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"
Expand DownExpand Up@@ -3584,6 +3585,29 @@ export function ClinicalDashboard({
)}
>
<h1 className="sr-only">Clinical Guide</h1>
{privateScopeStatus === "unavailable" ? (
// Lives inside <main> (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.
<div
role="alert"
data-testid="private-scope-unavailable"
className="mx-3 mt-3 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] px-3 py-2 text-sm text-[color:var(--text)] sm:mx-4 lg:mx-8"
>
<p>
The original private document scope is unavailable. Choose the documents again or confirm a broader
search.
</p>
<div className="flex flex-wrap gap-2">
<button type="button" className={floatingControl} onClick={reselectUnavailablePrivateScope}>
Reselect documents
</button>
<button type="button" className={floatingControl} onClick={runWithoutUnavailablePrivateScope}>
Run without private scope
</button>
</div>
</div>
) : null}
<SearchCommandProvider value={searchCommandContextValue}>
<div
className={cn(
Expand Down
41 changes: 32 additions & 9 deletions src/components/clinical-dashboard/master-search-header.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -245,6 +245,12 @@ export function MasterSearchHeader({
* `useScrollHideReporter` wired to that element's scroll events. */
hideOnScroll?: {
strategy: "overlay" | "collapse";
/**
* Overlay-only: apply the hide/reveal (and the out-of-flow absolute header)
* at every breakpoint instead of phones only. The host must reserve
* matching top padding on its scroll container.
*/
allBreakpoints?: boolean;
/** Parent-owned hidden state for hosts that report scroll via React `onScroll`. */
scrollHidden?: boolean;
};
Comment on lines 246 to 256

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash# Description: Confirm every current caller that sets allBreakpoints also sets scrollHidden.
rg -n -B3 -A3 'allBreakpoints' src/components

Repository: BigSimmo/Database

Length of output: 2463


🏁 Script executed:

#!/bin/bashset -euo pipefail
# Inspect the component logic around hideOnScroll and the hook implementation.
rg -n -A8 -B8 'internalScrollHidden|overlayAllBreakpoints|hideOnScroll\.scrollHidden|useHideOnScroll|strategy: "overlay"|allBreakpoints' src/components/clinical-dashboard/master-search-header.tsx src/components/ClinicalDashboard.tsx src/components -g '!**/*.map'# Show the relevant section of the header component with line numbers.
sed -n '1360,1465p' src/components/clinical-dashboard/master-search-header.tsx
# Show the hook implementation if present.
fd -a 'use-hide-on-scroll.ts' src

Repository: BigSimmo/Database

Length of output: 38165


🏁 Script executed:

#!/bin/bashset -euo pipefail
sed -n '1,220p' src/components/clinical-dashboard/use-hide-on-scroll.ts

Repository: BigSimmo/Database

Length of output: 6591


Thread all-breakpoints into the scroll fallback. The overlay + allBreakpoints path still depends on scrollHidden, but the internal fallback stays phone-only. If a caller sets hideOnScroll={{ strategy: "overlay", allBreakpoints: true }} without also passing scrollHidden, the header will become absolute on desktop but never hide/reveal there. Either require scrollHidden for that mode or pass the all-breakpoints flag into the scroll hook.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/components/clinical-dashboard/master-search-header.tsx` around lines 246
- 256, Update the internal scroll fallback used by hideOnScroll so overlay mode
with allBreakpoints enabled also computes hide/reveal state on non-phone
breakpoints when scrollHidden is not supplied. Thread
hideOnScroll.allBreakpoints into the scroll hook or fallback configuration,
while preserving parent-provided scrollHidden behavior and existing phone-only
behavior otherwise.

Source: Path instructions

Expand DownExpand Up@@ -1388,6 +1394,10 @@ export function MasterSearchHeader({
}

const hideStrategy = hideOnScroll?.strategy;
// Overlay hosts that opt into all breakpoints take the header fully out of
// flow (absolute over the scrolling <main>, which reserves matching top
// padding) so content frosts under the glass bar at every width.
const overlayAllBreakpoints = hideStrategy === "overlay" && Boolean(hideOnScroll?.allBreakpoints);
const chromeFocusProps = hideOnScroll
? {
onFocusCapture: () => setHeaderChromeFocused(true),
Expand All@@ -1409,22 +1419,35 @@ export function MasterSearchHeader({
<>
<header
id="search"
data-scroll-hidden={hideStrategy === "overlay" && headerChromeHidden ? "true" : undefined}
className={cn(
"edge-glass-header universal-header z-30 border-b border-[color:var(--border)] py-2 pt-[max(0.5rem,env(safe-area-inset-top))] text-[color:var(--text)] backdrop-blur-xl",
"edge-glass-header universal-header z-30 py-2 pt-[max(0.5rem,env(safe-area-inset-top))] text-[color:var(--text)] backdrop-blur-xl backdrop-saturate-150",
// Collapse hosts keep the header above an internally scrolling <main>, so
// sticky is unnecessary on phones and fights the 0fr grid collapse by
// pinning the bar inside the viewport. Overlay hosts need sticky so the
// header rides document scroll and can translate away with zero layout shift.
hideStrategy === "collapse" ? "max-sm:relative sm:sticky sm:top-0" : "sticky top-0",
// Overlay hide-on-scroll (phones): a plain translate reveals the content
// already flowing beneath it. No transform is applied while visible so
// the fixed-position mobile mode menu keeps the viewport as its containing block.
// pinning the bar inside the viewport. All-breakpoints overlay hosts take
// the header out of flow entirely (absolute over the padded <main>) —
// sticky would be inert there because the scroll container is <main>, 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"
: overlayAllBreakpoints
? "absolute inset-x-0 top-0"
: "sticky top-0",
// Overlay hide-on-scroll: a plain translate reveals the content already
// flowing beneath it. No transform is applied while visible so the
// fixed-position mobile mode menu keeps the viewport as its containing block.
hideStrategy === "overlay" &&
"max-sm:transition-transform max-sm:duration-200 max-sm:ease-out motion-reduce:transition-none",
hideStrategy === "overlay" && headerChromeHidden && "max-sm:-translate-y-full",
(overlayAllBreakpoints
? "transition-transform duration-200 ease-out motion-reduce:transition-none"
: "max-sm:transition-transform max-sm:duration-200 max-sm:ease-out motion-reduce:transition-none"),
hideStrategy === "overlay" &&
headerChromeHidden &&
(overlayAllBreakpoints ? "-translate-y-full" : "max-sm:-translate-y-full"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Translate the full scrim offscreen

In answer mode after scrolling down, this -translate-y-full only moves the header by its own rendered height (~72px), but the newly added .edge-glass-header-backdrop is taller (max(6.5rem, safe-area + 5.5rem)) and is not clipped by the header. That leaves roughly 1.5–2rem of the blurred/tinted scrim still visible at the top after the header is marked hidden, so content never gets the fully clear viewport the hide-on-scroll behavior is meant to provide; the current bounding-rect test misses it because the overflowing backdrop does not affect the header rect.

Useful? React with 👍 / 👎.

)}
{...(hideStrategy === "overlay" ? chromeFocusProps : undefined)}
>
<div className="edge-glass-header-backdrop" aria-hidden="true" />
<div
className={cn(
"relative mx-auto grid min-h-14 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 sm:gap-3",
Expand Down
Loading
Loading