diff --git a/docs/process-hardening.md b/docs/process-hardening.md
index d41ba98b21..a64614b92e 100644
--- a/docs/process-hardening.md
+++ b/docs/process-hardening.md
@@ -80,7 +80,9 @@ This document turns the current process review into phased, durable repo practic
- The custom component classes in `src/app/globals.css` predate cascade layers, so they sat unlayered and silently beat Tailwind v4 utilities (which live in `@layer utilities`) on the same element. This caused three shipped UI bugs: the header source ledger ignoring responsive `hidden`, the composer clear button covering typed text (`pr-*` defeated), and the standalone-home status chips sliding under the mode pill.
- Conflict-free helper classes (`app-edge-backdrop`, `mobile-app-shell`, `mobile-popover-scroll`, `citation-link`, `animate-skeleton-shimmer`, `focus-ring-premium`, `source-capsule-hover`, `polished-scroll`) now live in `@layer components`, so utilities override them normally. Their call sites were audited for same-property utility collisions before the move.
-- **Remaining debt:** the chrome classes (`edge-glass-header`, `universal-header-*`, `answer-footer-search-*`, `*-composer-edge`, `desktop-home-search-*`, `document-mobile-search-*`) stay intentionally unlayered because call sites stack utilities that set the same properties and today rely on the class winning (e.g. footer input font-size/padding, pill min-height, header shadow). Layering them requires reconciling each call site so rendered output is unchanged. Until then: when adding a utility to an element carrying one of these classes, check the class body first — the class wins.
+- **Header chrome now layered (2026-07-02):** `edge-glass-header` (+ responsive padding), `universal-header`, `universal-header-mode-button`, and `universal-header-icon-control` moved into `@layer components`. The header/mode-pill was the source of the recurring overlap+shadow bugs, so utilities on those elements now win. Reconciliation was three call-site shadow edits (drop the dead `shadow-[var(--shadow-tight)]` on the two `edge-glass-header` headers; drop `shadow-[var(--shadow-inset)]` on the mode button and the New-chat icon control so the layered class supplies the shadow directly). Proven byte-identical across 16 states with `scripts/capture-chrome-parity.ts`.
+- **Verification tooling:** `scripts/capture-chrome-parity.ts` dumps `getComputedStyle` (incl. `::placeholder`/`::after`) for the header+composer chrome across home/answer/focus/document × mobile/desktop × light/dark and diffs two snapshots. **Gotcha:** it is only reliable once the dev server has fully settled on the current CSS — capture right after a hot-reload and it silently compares against the stale bundle. Always capture twice and confirm the two agree before trusting a diff.
+- **Remaining debt (grew with PR #171):** the COMPOSER chrome (`answer-footer-search-*`, `desktop-home-search-*`, `document-mobile-search-pill`, `*-composer-edge`) stays intentionally unlayered. #171's frosted rework pushed these from ~19 to ~92 computed-style conflicts, concentrated in the shared `ui-primitives` constants (`chatComposerShell/Input`, `chatSendButton`, `chatComposerIconButton`) used by other call sites without the class. Layering them needs a base/delta split of those constants; deferred. Until then: when adding a utility to a composer-chrome element, check the class body first — the class wins.
- `tests/ui-overlap.spec.ts` is the standing regression guard for the visible symptom (overlapping header controls, composer clear-button geometry) across 640-1536px widths.
## Cross-browser test robustness under client-only rendering (2026-07-02)
diff --git a/scripts/capture-chrome-parity.ts b/scripts/capture-chrome-parity.ts
new file mode 100644
index 0000000000..98e0a00c30
--- /dev/null
+++ b/scripts/capture-chrome-parity.ts
@@ -0,0 +1,282 @@
+/**
+ * Computed-style parity capture for the chrome-class @layer migration.
+ *
+ * Captures getComputedStyle snapshots for the header/composer chrome across
+ * deterministic app states, so a CSS refactor can prove pixel-identical
+ * output: capture a baseline at the pre-change commit, re-capture after each
+ * change, and diff the JSON. Selectors are refactor-stable (testids, aria
+ * labels, structure) — never the chrome class names being migrated.
+ *
+ * Usage:
+ * npx tsx scripts/capture-chrome-parity.ts --label baseline [--out
]
+ * npx tsx scripts/capture-chrome-parity.ts --compare
+ *
+ * Requires the dev server from npm run ensure. PLAYWRIGHT_BASE_URL is accepted
+ * only after the repo identity guard verifies it. Snapshots are
+ * machine-specific; keep them out of the repo (default output dir is
+ * scratch/chrome-parity, git-ignored by the format gate and never committed).
+ */
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { chromium, type BrowserContext, type Page, type Route } from "playwright-core";
+
+import { getPlaywrightBaseUrl } from "./playwright-base-url";
+import { demoAnswer, demoDocuments, getDemoDocumentPayload } from "../src/lib/demo-data";
+
+const DOCUMENT_PATH =
+ "/documents/11111111-1111-4111-8111-111111111111?page=1&chunk=44444444-4444-4444-8444-444444444442";
+const QUERY_TEXT = "Synthetic lithium monitoring guidance";
+
+const readySetupChecks = [
+ { id: "env", label: ".env.local configured", status: "ready", detail: "Parity environment ready." },
+ { id: "project", label: "Clinical KB Database target", status: "ready", detail: "Parity Supabase ready." },
+ { id: "schema", label: "supabase/schema.sql applied", status: "ready", detail: "Parity schema ready." },
+ { id: "search", label: "Search RPC and vector indexes", status: "ready", detail: "Parity search ready." },
+ { id: "openai", label: "OpenAI API key available", status: "ready", detail: "Parity OpenAI ready." },
+ { id: "worker", label: "npm run worker running", status: "unknown", detail: "Worker not required." },
+];
+
+const capturedProperties = [
+ "display",
+ "box-shadow",
+ "padding-top",
+ "padding-right",
+ "padding-bottom",
+ "padding-left",
+ "row-gap",
+ "column-gap",
+ "min-height",
+ "height",
+ "width",
+ "font-size",
+ "font-weight",
+ "line-height",
+ "letter-spacing",
+ "color",
+ "background-color",
+ "border-top-width",
+ "border-right-width",
+ "border-bottom-width",
+ "border-left-width",
+ "border-top-color",
+ "border-bottom-color",
+ "border-top-left-radius",
+ "border-bottom-right-radius",
+ "transition-property",
+ "transition-duration",
+ "transform",
+ "top",
+ "right",
+ "bottom",
+ "left",
+ "isolation",
+] as const;
+
+/** Refactor-stable selector groups. `pseudo` captures an extra pseudo-element snapshot. */
+const selectorGroups: Array<{ key: string; selector: string; pseudo?: string }> = [
+ { key: "header", selector: "header#search", pseudo: "::after" },
+ { key: "header-buttons", selector: "header#search button" },
+ { key: "mode-pill", selector: '[aria-label^="Current app mode"]' },
+ { key: "composer-form", selector: 'form:has([data-testid="global-search-input"])' },
+ { key: "composer-children", selector: 'form:has([data-testid="global-search-input"]) > *' },
+ { key: "composer-pill-children", selector: 'form:has([data-testid="global-search-input"]) > div > *' },
+ { key: "composer-input", selector: '[data-testid="global-search-input"]', pseudo: "::placeholder" },
+ { key: "composer-buttons", selector: 'form:has([data-testid="global-search-input"]) button' },
+ { key: "evidence-chip", selector: 'button[aria-label="Open evidence-backed answer sources"]' },
+ { key: "evidence-chip-icon", selector: 'button[aria-label="Open evidence-backed answer sources"] svg' },
+ { key: "scope-chip", selector: 'button[aria-label="Open source scope"]' },
+ { key: "scope-chip-icon", selector: 'button[aria-label="Open source scope"] svg' },
+ { key: "viewer-header", selector: "main header, body > div > header", pseudo: "::after" },
+ { key: "viewer-composer", selector: 'form:has(input[placeholder^="Search or answer"])' },
+ { key: "viewer-composer-children", selector: 'form:has(input[placeholder^="Search or answer"]) > *' },
+];
+
+type Snapshot = Record>;
+
+async function mockApis(page: Page) {
+ await page.route("**/api/setup-status**", async (route) => {
+ await route.fulfill({ json: { demoMode: true, checks: readySetupChecks } });
+ });
+ await page.route(/\/api\/documents\/[0-9a-f-]+(?:\?.*)?$/, async (route) => {
+ const id = new URL(route.request().url()).pathname.split("/").pop() ?? "";
+ const payload = getDemoDocumentPayload(id);
+ if (payload) await route.fulfill({ json: payload });
+ else await route.fulfill({ status: 404, json: { error: "not found" } });
+ });
+ await page.route(/\/api\/documents(?:\?.*)?$/, async (route) => {
+ await route.fulfill({
+ json: {
+ documents: demoDocuments,
+ demoMode: true,
+ pagination: {
+ limit: 150,
+ offset: 0,
+ total: demoDocuments.length,
+ nextOffset: demoDocuments.length,
+ hasMore: false,
+ },
+ },
+ });
+ });
+ await page.route(/\/api\/ingestion\/(jobs|batches|quality)(?:\?.*)?$/, async (route) => {
+ await route.fulfill({ json: { jobs: [], batches: [], items: [], demoMode: true } });
+ });
+ await page.route(/\/api\/answer(?:\/stream)?(?:\?.*)?$/, async (route: Route) => {
+ const body = route.request().postDataJSON() as { query?: string; documentId?: string; documentIds?: string[] };
+ const payload = { ...demoAnswer(body.query ?? QUERY_TEXT, body.documentId, body.documentIds), demoMode: true };
+ if (new URL(route.request().url()).pathname.endsWith("/stream")) {
+ await route.fulfill({
+ body: [
+ `event: progress\ndata: ${JSON.stringify({ stage: "retrieving", message: "Searching indexed documents." })}`,
+ `event: final\ndata: ${JSON.stringify(payload)}`,
+ "",
+ ].join("\n\n"),
+ contentType: "text/event-stream; charset=utf-8",
+ headers: { "Cache-Control": "no-cache, no-transform" },
+ });
+ return;
+ }
+ await route.fulfill({ json: payload });
+ });
+}
+
+async function captureState(page: Page, groups = selectorGroups): Promise {
+ return page.evaluate(
+ ({ groupList, properties }) => {
+ const snapshot: Record> = {};
+ for (const group of groupList) {
+ const elements = Array.from(document.querySelectorAll(group.selector));
+ elements.forEach((element, index) => {
+ const record: Record = {};
+ const style = getComputedStyle(element);
+ for (const property of properties) record[property] = style.getPropertyValue(property);
+ snapshot[`${group.key}[${index}]`] = record;
+ if (group.pseudo) {
+ const pseudoStyle = getComputedStyle(element, group.pseudo);
+ const pseudoRecord: Record = {};
+ for (const property of properties) pseudoRecord[property] = pseudoStyle.getPropertyValue(property);
+ snapshot[`${group.key}[${index}]${group.pseudo}`] = pseudoRecord;
+ }
+ });
+ }
+ return snapshot;
+ },
+ { groupList: groups, properties: capturedProperties as unknown as string[] },
+ );
+}
+
+async function settle(page: Page) {
+ await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
+ await page.waitForTimeout(500);
+}
+
+async function captureAll(context: BrowserContext, dark: boolean, baseUrl: string): Promise> {
+ const states: Record = {};
+ const page = await context.newPage();
+ await mockApis(page);
+ if (dark) {
+ await page.addInitScript(() => window.localStorage.setItem("clinical-kb-theme", "dark"));
+ }
+ const suffix = dark ? "dark" : "light";
+
+ for (const viewport of [
+ { tag: "mobile", width: 390, height: 820 },
+ { tag: "desktop", width: 1280, height: 900 },
+ ]) {
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
+
+ // 1. Home (desktop-home / mobile composer variants).
+ await page.goto(`${baseUrl}/`, { waitUntil: "domcontentloaded" });
+ await settle(page);
+ states[`home-${viewport.tag}-${suffix}`] = await captureState(page);
+
+ // 2. Answer-footer state: type + submit the mocked answer.
+ const input = page.locator('[data-testid="global-search-input"]:visible').first();
+ await input.fill(QUERY_TEXT);
+ await page.keyboard.press("Control+Enter");
+ await page.getByTestId("plain-answer-response").waitFor({ timeout: 20_000 });
+ await settle(page);
+ states[`answer-${viewport.tag}-${suffix}`] = await captureState(page);
+
+ // 3. Focus sub-state (focus-within ring on the pill).
+ await input.focus();
+ await page.waitForTimeout(250);
+ states[`answer-focus-${viewport.tag}-${suffix}`] = await captureState(page);
+
+ // 4. Document viewer chrome.
+ await page.goto(`${baseUrl}${DOCUMENT_PATH}`, { waitUntil: "domcontentloaded" });
+ await settle(page);
+ states[`document-${viewport.tag}-${suffix}`] = await captureState(page);
+ }
+
+ await page.close();
+ return states;
+}
+
+function compare(fileA: string, fileB: string): number {
+ const a = JSON.parse(readFileSync(fileA, "utf8")) as Record;
+ const b = JSON.parse(readFileSync(fileB, "utf8")) as Record;
+ const differences: string[] = [];
+ const stateKeys = new Set([...Object.keys(a), ...Object.keys(b)]);
+ for (const state of stateKeys) {
+ const stateA = a[state] ?? {};
+ const stateB = b[state] ?? {};
+ const elementKeys = new Set([...Object.keys(stateA), ...Object.keys(stateB)]);
+ for (const element of elementKeys) {
+ const recordA = stateA[element];
+ const recordB = stateB[element];
+ if (!recordA || !recordB) {
+ differences.push(`${state} :: ${element} :: ${!recordA ? "missing in A" : "missing in B"}`);
+ continue;
+ }
+ for (const property of Object.keys(recordA)) {
+ if (recordA[property] !== recordB[property]) {
+ differences.push(`${state} :: ${element} :: ${property}: "${recordA[property]}" -> "${recordB[property]}"`);
+ }
+ }
+ }
+ }
+ if (differences.length === 0) {
+ console.log(`PARITY OK — no computed-style differences (${stateKeys.size} states)`);
+ return 0;
+ }
+ console.error(`PARITY FAILED — ${differences.length} difference(s):`);
+ for (const difference of differences.slice(0, 200)) console.error(` ${difference}`);
+ if (differences.length > 200) console.error(` … and ${differences.length - 200} more`);
+ return 1;
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+ const compareIndex = args.indexOf("--compare");
+ if (compareIndex !== -1) {
+ process.exit(compare(args[compareIndex + 1], args[compareIndex + 2]));
+ }
+
+ const labelIndex = args.indexOf("--label");
+ const label = labelIndex !== -1 ? args[labelIndex + 1] : "capture";
+ const outIndex = args.indexOf("--out");
+ const outDir = outIndex !== -1 ? args[outIndex + 1] : path.join(process.cwd(), "scratch", "chrome-parity");
+ mkdirSync(outDir, { recursive: true });
+
+ const browser = await chromium.launch();
+ const baseUrl = getPlaywrightBaseUrl();
+ const all: Record = {};
+ for (const dark of [false, true]) {
+ const context = await browser.newContext({ deviceScaleFactor: 1 });
+ Object.assign(all, await captureAll(context, dark, baseUrl));
+ await context.close();
+ }
+ await browser.close();
+
+ const outPath = path.join(outDir, `${label}.json`);
+ writeFileSync(outPath, JSON.stringify(all, null, 1));
+ const stateCount = Object.keys(all).length;
+ const elementCount = Object.values(all).reduce((sum, state) => sum + Object.keys(state).length, 0);
+ console.log(`captured ${stateCount} states / ${elementCount} element snapshots -> ${outPath}`);
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exit(1);
+});
diff --git a/src/app/globals.css b/src/app/globals.css
index de6102785a..72c251e1d2 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -171,6 +171,9 @@
--danger: var(--danger-text);
--danger-soft: var(--danger-bg);
+ /* Categorical dot accent with no semantic triad (e.g. Youth service pathway). */
+ --tone-purple: #7c3aed;
+
--disabled: #98a2b3;
--focus: var(--clinical-accent);
--overlay-backdrop: rgb(4 8 14 / 56%);
@@ -307,6 +310,9 @@
--danger: var(--danger-text);
--danger-soft: var(--danger-bg);
+ /* Categorical dot accent, brightened for dark surfaces. */
+ --tone-purple: #a78bfa;
+
--disabled: #555e6b;
--focus: var(--clinical-accent);
--overlay-backdrop: rgb(0 0 0 / 72%);
@@ -425,58 +431,85 @@ summary::-webkit-details-marker {
}
/*
- * Layout chrome — INTENTIONALLY UNLAYERED: these classes beat Tailwind
- * utilities on the same element (unlayered CSS wins over all layers). Their
- * call sites stack utilities that set the same properties and rely on the
- * class winning; the conflict-free helpers live in @layer components below.
+ * Header chrome — LAYERED. These classes live in @layer components so
+ * Tailwind utilities on the same element win (the header/mode-pill was the
+ * source of the recurring overlap/shadow bugs). Call-site shadow utilities
+ * were reconciled to the class values (verified byte-identical via
+ * scripts/capture-chrome-parity.ts). Responsive padding overrides are nested
+ * here so the whole class stays in one layer.
+ *
+ * The COMPOSER chrome further below (answer-footer-search-*,
+ * desktop-home-search-*, document-mobile-search-pill) stays INTENTIONALLY
+ * UNLAYERED: its call sites stack utilities from shared ui-primitives
+ * constants that rely on the class winning, and PR #171's frosted rework
+ * left ~40 value conflicts there. Reconciling those is tracked in
+ * docs/process-hardening.md ("CSS cascade layering").
*/
-.edge-glass-header {
- 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);
- box-shadow: none;
-}
+@layer components {
+ .edge-glass-header {
+ 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);
+ 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: "";
-}
+ .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);
-}
+ .universal-header {
+ background: var(--surface);
+ }
-.universal-header-mode-button {
- border-color: var(--border);
- background: var(--surface);
- box-shadow: var(--shadow-tight);
-}
+ .universal-header-mode-button {
+ border-color: var(--border);
+ background: var(--surface);
+ box-shadow: var(--shadow-tight);
+ }
-.universal-header-icon-control {
- border-color: var(--border);
- background: var(--surface);
- box-shadow: none;
-}
+ .universal-header-icon-control {
+ border-color: var(--border);
+ background: var(--surface);
+ box-shadow: none;
+ }
-.universal-header-icon-control:hover {
- border-color: var(--border-strong);
- background: var(--surface-subtle);
-}
+ .universal-header-icon-control:hover {
+ border-color: var(--border-strong);
+ background: var(--surface-subtle);
+ }
-.dark .universal-header-mode-button {
- box-shadow: var(--shadow-tight);
-}
+ .dark .universal-header-mode-button {
+ box-shadow: var(--shadow-tight);
+ }
-.dark .universal-header-icon-control {
- box-shadow: none;
+ .dark .universal-header-icon-control {
+ box-shadow: none;
+ }
+
+ /* Responsive header padding (moved in from the shared @media blocks below so
+ the whole .edge-glass-header class is layered together). */
+ @media (min-width: 640px) {
+ .edge-glass-header {
+ padding-left: max(1rem, var(--safe-area-left));
+ padding-right: max(1rem, var(--safe-area-right));
+ }
+ }
+
+ @media (min-width: 1024px) {
+ .edge-glass-header {
+ padding-left: max(1.5rem, var(--safe-area-left));
+ padding-right: max(1.5rem, var(--safe-area-right));
+ }
+ }
}
.floating-composer-edge {
@@ -675,11 +708,6 @@ summary::-webkit-details-marker {
}
@media (min-width: 640px) {
- .edge-glass-header {
- padding-left: max(1rem, var(--safe-area-left));
- padding-right: max(1rem, var(--safe-area-right));
- }
-
.floating-composer-edge {
bottom: max(1rem, var(--safe-area-bottom));
}
@@ -760,11 +788,6 @@ summary::-webkit-details-marker {
}
@media (min-width: 1024px) {
- .edge-glass-header {
- padding-left: max(1.5rem, var(--safe-area-left));
- padding-right: max(1.5rem, var(--safe-area-right));
- }
-
.dashboard-composer-edge {
left: calc(var(--clinical-sidebar-width, 20rem) + 2rem);
right: max(2rem, var(--safe-area-right));
@@ -1016,6 +1039,7 @@ summary::-webkit-details-marker {
--warning-soft: Canvas;
--danger: Mark;
--danger-soft: Canvas;
+ --tone-purple: CanvasText;
--focus: Highlight;
--surface-lux: Canvas;
--surface-wash: Canvas;
diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx
index 39a6f958b7..e2a28984b5 100644
--- a/src/components/DocumentViewer.tsx
+++ b/src/components/DocumentViewer.tsx
@@ -2359,7 +2359,7 @@ export function DocumentViewer({
tabIndex={-1}
className={cn(appBackdrop, "min-h-[100dvh] overflow-x-clip text-[color:var(--text)] focus:outline-none")}
>
-
+
diff --git a/src/components/mode-home-template.tsx b/src/components/mode-home-template.tsx
index 7816addaec..42241bf689 100644
--- a/src/components/mode-home-template.tsx
+++ b/src/components/mode-home-template.tsx
@@ -43,7 +43,7 @@ const pillToneClass: Record, string> = {
info: "bg-[color:var(--info)]",
neutral: "bg-[color:var(--text-soft)]",
primary: "bg-[color:var(--clinical-accent)]",
- purple: "bg-violet-600",
+ purple: "bg-[color:var(--tone-purple)]",
success: "bg-[color:var(--success)]",
teal: "bg-[color:var(--clinical-accent)]",
};