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
71 changes: 36 additions & 35 deletions docs/branch-review-ledger.md

Large diffs are not rendered by default.

107 changes: 51 additions & 56 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -648,21 +648,34 @@ summary::-webkit-details-marker {
0 1px 2px rgb(16 24 40 / 4%),
0 8px 24px rgb(16 24 40 / 8%);
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
transition:
border-color 180ms ease,
box-shadow 180ms ease,
transform 180ms ease;
}
}

/* 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). */
/* Top progressive-blur scrim behind the glass header — the SINGLE source of the
bar's frost. The <header> itself must NOT carry a backdrop-filter: per Filter
Effects L2 an element with backdrop-filter forms a backdrop root, and these
nested passes would then sample the header's own paint instead of the page
(redundant in Chromium, historically inert in WebKit). With the bar left as a
pure translucent tint, the three masked passes below sample the real page in
every engine: strongest at the physical top edge (status-bar/notch band via
safe-area-inset-top), still firm across the bar, then a short fade that
reaches TRUE ZERO before content rests (~85% of the scrim) so no veil sits on
the first line at rest — mirroring how the bottom dock zeroes its
content-facing edge. 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).

AUTHORING RULE for every backdrop-filter in this file: write ONLY the
unprefixed property. The CSS pipeline auto-generates the -webkit- pair in
the built client stylesheet (verified in the served chunk), which is what
covers prefix-only Safari <= 17. Hand-writing the -webkit- duplicate in
source is what made Lightning CSS drop the whole property group — the
historical tint-only-glass bug — and a guard test now rejects hand-authored
-webkit-backdrop-filter declarations. */
.edge-glass-header-backdrop {
pointer-events: none;
position: absolute;
Expand All @@ -675,14 +688,13 @@ summary::-webkit-details-marker {
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%
color-mix(in srgb, var(--background) 18%, transparent) 34%,
color-mix(in srgb, var(--background) 8%, transparent) 64%,
transparent 88%
);
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%);
backdrop-filter: blur(14px) saturate(135%);
Comment thread
BigSimmo marked this conversation as resolved.
mask-image: linear-gradient(180deg, black 0%, black 64%, rgb(0 0 0 / 45%) 74%, transparent 86%);
-webkit-mask-image: linear-gradient(180deg, black 0%, black 64%, rgb(0 0 0 / 45%) 74%, transparent 86%);
}

.edge-glass-header-backdrop::before,
Expand All @@ -694,31 +706,15 @@ summary::-webkit-details-marker {
}

.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%
);
backdrop-filter: blur(20px) saturate(135%);
mask-image: linear-gradient(180deg, black 0%, black 45%, transparent 72%);
-webkit-mask-image: linear-gradient(180deg, black 0%, black 45%, transparent 72%);
}

.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%);
backdrop-filter: blur(26px) saturate(140%);
mask-image: linear-gradient(180deg, black 0%, black 26%, transparent 48%);
-webkit-mask-image: linear-gradient(180deg, black 0%, black 26%, transparent 48%);
}

.answer-footer-search-backdrop {
Expand All @@ -736,7 +732,6 @@ summary::-webkit-details-marker {
color-mix(in srgb, var(--background) 46%, transparent) 100%
);
backdrop-filter: blur(14px) saturate(130%);
-webkit-backdrop-filter: blur(14px) saturate(130%);
mask-image: linear-gradient(180deg, transparent 0%, black 32%, black 100%);
-webkit-mask-image: linear-gradient(180deg, transparent 0%, black 32%, black 100%);
}
Expand Down Expand Up @@ -794,7 +789,6 @@ summary::-webkit-details-marker {
color-mix(in srgb, var(--background) 42%, transparent) 100%
);
backdrop-filter: blur(2px) saturate(130%);
-webkit-backdrop-filter: blur(2px) saturate(130%);
mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 45%) 28%, black 55%, black 100%);
-webkit-mask-image: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 45%) 28%, black 55%, black 100%);
}
Expand All @@ -809,7 +803,6 @@ summary::-webkit-details-marker {

.answer-footer-search-dock .answer-footer-search-backdrop::before {
backdrop-filter: blur(8px) saturate(130%);
-webkit-backdrop-filter: blur(8px) saturate(130%);
mask-image: linear-gradient(180deg, transparent 0%, rgb(0 0 0 / 35%) 20%, black 45%, black 80%, transparent 100%);
-webkit-mask-image: linear-gradient(
180deg,
Expand All @@ -823,7 +816,6 @@ summary::-webkit-details-marker {

.answer-footer-search-dock .answer-footer-search-backdrop::after {
backdrop-filter: blur(22px) saturate(140%);
-webkit-backdrop-filter: blur(22px) saturate(140%);
mask-image: linear-gradient(180deg, transparent 0%, transparent 50%, black 68%, black 100%);
-webkit-mask-image: linear-gradient(180deg, transparent 0%, transparent 50%, black 68%, black 100%);
}
Expand All @@ -833,7 +825,6 @@ summary::-webkit-details-marker {
.answer-footer-search-dock .answer-footer-search-backdrop::before,
.answer-footer-search-dock .answer-footer-search-backdrop::after {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}

.answer-footer-search-dock .answer-footer-search-backdrop {
Expand All @@ -852,7 +843,6 @@ summary::-webkit-details-marker {
.edge-glass-header-backdrop::before,
.edge-glass-header-backdrop::after {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}

.edge-glass-header-backdrop {
Expand All @@ -865,9 +855,13 @@ summary::-webkit-details-marker {
);
}

.edge-glass-header,
.universal-header {
background: var(--surface);
/* Layered so call-site utilities keep winning (the header-chrome layering
contract above); same-layer source order beats the translucent base. */
@layer components {
.edge-glass-header,
.universal-header {
background: var(--surface);
}
}
}

Expand All @@ -876,7 +870,6 @@ summary::-webkit-details-marker {
.answer-footer-search-dock .answer-footer-search-backdrop::before,
.answer-footer-search-dock .answer-footer-search-backdrop::after {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}

.answer-footer-search-dock .answer-footer-search-backdrop {
Expand All @@ -894,7 +887,6 @@ summary::-webkit-details-marker {
.edge-glass-header-backdrop::before,
.edge-glass-header-backdrop::after {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}

.edge-glass-header-backdrop {
Expand All @@ -907,9 +899,14 @@ summary::-webkit-details-marker {
);
}

.edge-glass-header,
.universal-header {
background: var(--surface);
/* Layered so call-site utilities keep winning (the header-chrome layering
contract above); the fallback only needs to beat the layered translucent
base, which same-layer source order gives it. */
@layer components {
.edge-glass-header,
.universal-header {
background: var(--surface);
}
}
}

Expand Down Expand Up @@ -997,7 +994,6 @@ summary::-webkit-details-marker {
.mode-action-panel {
max-height: var(--mode-action-max-height);
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
}

.mode-action-surface[data-placement="up"] .mode-action-panel {
Expand Down Expand Up @@ -1172,7 +1168,6 @@ summary::-webkit-details-marker {
font-weight: 700;
box-shadow: 0 2px 10px rgb(16 24 40 / 6%);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
transition:
border-color 160ms ease,
background-color 160ms ease,
Expand Down Expand Up @@ -1626,7 +1621,6 @@ summary::-webkit-details-marker {
.answer-footer-search-dock .answer-footer-search-chip {
background: var(--surface);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}

.answer-footer-search-dock .answer-footer-search-pill:hover {
Expand Down Expand Up @@ -2109,7 +2103,9 @@ summary::-webkit-details-marker {
}

/* Glass header: color-mix(Canvas, transparent) would thin system-color
contrast, so force a solid Canvas bar and drop the scrim blur entirely. */
contrast, so force a solid Canvas bar and drop the scrim blur entirely.
Deliberately UNLAYERED (unlike the other fallbacks): in forced-colors
mode even call-site utilities must not restore a translucent bar. */
.edge-glass-header,
.universal-header {
background: Canvas;
Expand All @@ -2120,7 +2116,6 @@ summary::-webkit-details-marker {
.edge-glass-header-backdrop::after {
background: Canvas;
backdrop-filter: none;
-webkit-backdrop-filter: none;
mask-image: none;
-webkit-mask-image: none;
}
Expand Down
10 changes: 8 additions & 2 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3588,11 +3588,17 @@ export function ClinicalDashboard({
{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.
// column top and hide behind the glass bar. Sticky so the recovery
// actions stay reachable while the user scrolls — pinned below the
// overlaid glass bar in answer mode, just under the in-flow header
// otherwise (main is the scroll container, so sticky works here).
<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"
className={cn(
"sticky z-20 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",
searchMode === "answer" ? "top-[calc(4.5rem+max(0.5rem,env(safe-area-inset-top)))]" : "top-2",
)}
>
<p>
The original private document scope is unavailable. Choose the documents again or confirm a broader
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1421,7 +1421,10 @@ export function MasterSearchHeader({
id="search"
data-scroll-hidden={hideStrategy === "overlay" && headerChromeHidden ? "true" : undefined}
className={cn(
"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",
// No backdrop-filter on the header itself: it would form a backdrop
// root and starve the .edge-glass-header-backdrop scrim (the single
// source of the bar's frost) of the real page behind it.
"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 <main>, so
// sticky is unnecessary on phones and fights the 0fr grid collapse by
// pinning the bar inside the viewport. All-breakpoints overlay hosts take
Expand Down
13 changes: 13 additions & 0 deletions src/components/clinical-dashboard/use-hide-on-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@ 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 (<main> 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.
useEffect(() => {
hiddenRef.current = false;
lastOffsetRef.current = 0;
const frame = window.requestAnimationFrame(() => setHidden(false));
return () => window.cancelAnimationFrame(frame);
}, [allowAllBreakpoints]);

return { hidden: active && hidden, reportScroll };
}

Expand Down
10 changes: 10 additions & 0 deletions tests/clinical-dashboard-merge-artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ describe("ClinicalDashboard merge-artifact guards", () => {
expect(clinicalDashboardSource).not.toContain("max-sm:min-h-0 max-sm:flex-1");
});

it("never hand-authors -webkit-backdrop-filter declarations", () => {
// Writing the prefixed duplicate in source makes Lightning CSS drop the
// whole backdrop-filter property group (the tint-only-glass bug); the
// pipeline auto-generates the -webkit- pair for Safari <= 17 from the
// unprefixed declaration alone. Feature probes inside @supports
// conditions are fine — only declarations (line-leading property) are
// rejected. See the authoring rule beside .edge-glass-header-backdrop.
expect(globalStylesSource).not.toMatch(/^\s*-webkit-backdrop-filter\s*:/m);
});

it("reserves phone space for the fixed mode-home composer", () => {
expect(globalSearchShellSource).toContain("const mobileComposerReserve = !shouldShowSearchComposer");
expect(globalSearchShellSource).not.toContain("const mobileComposerReserve = !reservesFloatingComposer");
Expand Down
37 changes: 37 additions & 0 deletions tests/ui-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2708,6 +2708,12 @@ test.describe("Clinical KB UI smoke coverage", () => {
}),
)
.toBe(0);
// The scrim tail (taller than the bar) may leave only a whisper at the top
// edge while hidden — bound it so it can't grow into a visible band.
const scrimBottom = await page
.locator(".edge-glass-header-backdrop")
.evaluate((node) => node.getBoundingClientRect().bottom);
expect(scrimBottom).toBeLessThanOrEqual(34);

// Any deliberate scroll up slides the glass bar back in.
for (const offset of [160, 120, 60]) {
Expand All @@ -2718,6 +2724,37 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(header).not.toHaveAttribute("data-scroll-hidden", "true");
});

test("private-scope alert stays reachable while the answer view scrolls", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockDemoApi(page);
// An unauthenticated session with a routed private-scope ref resolves to
// privateScopeStatus="unavailable", which renders the recovery alert.
await gotoApp(page, "/?mode=answer&scopeRef=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");

const alert = page.getByTestId("private-scope-unavailable");
await expect(alert).toBeVisible({ timeout: 15000 });

const main = page.locator("main#main-content");
await main.evaluate((node) => {
const spacer = document.createElement("div");
spacer.style.height = "2000px";
node.appendChild(spacer);
});
for (const offset of [80, 160, 260, 380]) {
await main.evaluate((node, top) => {
node.scrollTop = top;
}, offset);
}

// Sticky inside <main>: the recovery actions must remain on-screen (they
// used to scroll away with content, stranding the user mid-thread).
await expect(alert).toBeVisible();
const box = await alert.boundingBox();
expect(box).not.toBeNull();
expect(box!.y).toBeGreaterThanOrEqual(0);
expect(box!.y).toBeLessThanOrEqual(200);
});

test("answer glass header hides and returns on desktop widths too", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 860 });
await gotoApp(page, "/?mode=answer");
Expand Down