From 22a765f5c518a673ef38e80ae41bd19a9ad7d742 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:30:29 +0800 Subject: [PATCH 1/6] test(ui): computed-style parity capture for chrome-class layering Adds scripts/capture-chrome-parity.ts: drives Playwright against the dev server with the smoke suite's demo API mocks and dumps getComputedStyle (incl. ::placeholder/::after) for the header + composer chrome across 16 states (home/answer/focus/document x mobile/desktop x light/dark). Selectors are refactor-stable (testids/aria/structure), never the chrome class names being migrated. --compare diffs two snapshots and exits nonzero on any difference, proving the @layer migration is pixel-identical. Snapshots write to the git-ignored scratch/ dir (machine-specific). Co-Authored-By: Claude Fable 5 --- scripts/capture-chrome-parity.ts | 278 +++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 scripts/capture-chrome-parity.ts diff --git a/scripts/capture-chrome-parity.ts b/scripts/capture-chrome-parity.ts new file mode 100644 index 0000000000..629d902962 --- /dev/null +++ b/scripts/capture-chrome-parity.ts @@ -0,0 +1,278 @@ +/** + * 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 (npm run ensure) at PLAYWRIGHT_BASE_URL or + * http://localhost:3500. 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 { demoAnswer, demoDocuments, getDemoDocumentPayload } from "../src/lib/demo-data"; + +const BASE = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3500"; +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): 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(`${BASE}/`, { 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 }) + .catch(() => undefined); + 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(`${BASE}${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 all: Record = {}; + for (const dark of [false, true]) { + const context = await browser.newContext({ deviceScaleFactor: 1 }); + Object.assign(all, await captureAll(context, dark)); + 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); +}); From ceb19489c70303adf4dfc64b883fafbdd7513ba8 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:16:25 +0800 Subject: [PATCH 2/6] fix(ui): replace raw violet-600 with a themed --tone-purple token The "purple" mode-home pill tone (Youth service pathway dot) used a raw Tailwind palette color, the only non-token color introduced by #171. It did not adapt to dark mode or forced-colors like the surrounding tokens. Add --tone-purple (light #7c3aed == violet-600 so light mode is unchanged; brighter #a78bfa on dark; CanvasText under forced-colors) and reference it via bg-[color:var(--tone-purple)]. Co-Authored-By: Claude Fable 5 --- src/app/globals.css | 7 +++++++ src/components/mode-home-template.tsx | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/globals.css b/src/app/globals.css index de6102785a..5900d8c5a8 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%); @@ -1016,6 +1022,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/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)]", }; From d36d0fd63130b62d1982fa1bc36a8d4238f62ff3 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:44:42 +0800 Subject: [PATCH 3/6] refactor(css): layer the header chrome so utilities win (pixel-identical) The header + mode pill was the source of the recurring overlap/shadow bugs. Move edge-glass-header (+ responsive padding), universal-header, universal-header-mode-button, and universal-header-icon-control into @layer components so Tailwind utilities on those elements now win instead of silently losing to the unlayered class. Reconciled the three shadow conflicts so rendered output is unchanged: drop the dead shadow-[var(--shadow-tight)] on the two edge-glass-header headers (class already sets box-shadow:none), and drop shadow-[var(--shadow-inset)] on the mode button and the New-chat icon control so the layered class supplies its shadow directly (avoids Tailwind's shadow utility injecting transparent ring-placeholder layers). Verified byte-identical computed styles across 16 states (home/answer/focus/document x mobile/desktop x light/dark) with scripts/capture-chrome-parity.ts. The frosted COMPOSER chrome stays unlayered (deferred): PR #171 pushed it to ~92 conflicts in shared ui-primitives constants; tracked in docs/process-hardening.md. Co-Authored-By: Claude Fable 5 --- docs/process-hardening.md | 4 +- src/app/globals.css | 125 ++++++++++-------- src/components/DocumentViewer.tsx | 2 +- .../master-search-header.tsx | 6 +- 4 files changed, 78 insertions(+), 59 deletions(-) diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 2bcaf737f8..0dc9feff44 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -80,5 +80,7 @@ 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. diff --git a/src/app/globals.css b/src/app/globals.css index 5900d8c5a8..72c251e1d2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -431,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 { @@ -681,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)); } @@ -766,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)); 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")} > -
+
From 1d7aaa09699c9b251026febabbd6e74254b57120 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:24:30 +0800 Subject: [PATCH 4/6] fix(test): harden chrome parity capture --- scripts/capture-chrome-parity.ts | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/scripts/capture-chrome-parity.ts b/scripts/capture-chrome-parity.ts index 629d902962..98e0a00c30 100644 --- a/scripts/capture-chrome-parity.ts +++ b/scripts/capture-chrome-parity.ts @@ -11,18 +11,18 @@ * npx tsx scripts/capture-chrome-parity.ts --label baseline [--out ] * npx tsx scripts/capture-chrome-parity.ts --compare * - * Requires the dev server (npm run ensure) at PLAYWRIGHT_BASE_URL or - * http://localhost:3500. 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). + * 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 BASE = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3500"; const DOCUMENT_PATH = "/documents/11111111-1111-4111-8111-111111111111?page=1&chunk=44444444-4444-4444-8444-444444444442"; const QUERY_TEXT = "Synthetic lithium monitoring guidance"; @@ -108,7 +108,13 @@ async function mockApis(page: Page) { json: { documents: demoDocuments, demoMode: true, - pagination: { limit: 150, offset: 0, total: demoDocuments.length, nextOffset: demoDocuments.length, hasMore: false }, + pagination: { + limit: 150, + offset: 0, + total: demoDocuments.length, + nextOffset: demoDocuments.length, + hasMore: false, + }, }, }); }); @@ -164,7 +170,7 @@ async function settle(page: Page) { await page.waitForTimeout(500); } -async function captureAll(context: BrowserContext, dark: boolean): Promise> { +async function captureAll(context: BrowserContext, dark: boolean, baseUrl: string): Promise> { const states: Record = {}; const page = await context.newPage(); await mockApis(page); @@ -180,7 +186,7 @@ async function captureAll(context: BrowserContext, dark: boolean): Promise undefined); + await page.getByTestId("plain-answer-response").waitFor({ timeout: 20_000 }); await settle(page); states[`answer-${viewport.tag}-${suffix}`] = await captureState(page); @@ -201,7 +204,7 @@ async function captureAll(context: BrowserContext, dark: boolean): Promise = {}; for (const dark of [false, true]) { const context = await browser.newContext({ deviceScaleFactor: 1 }); - Object.assign(all, await captureAll(context, dark)); + Object.assign(all, await captureAll(context, dark, baseUrl)); await context.close(); } await browser.close(); From 4f1fe6aac5defb238dfb1034cff171530a2a1a6c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:54:21 +0800 Subject: [PATCH 5/6] refactor(dashboard): extract AuthPanel into clinical-dashboard/auth-panel Move-only: AuthPanel plus its solely-consumed auth-email snapshot helpers (subscribeAuthEmail/getAuthEmailSnapshot/getServerAuthEmailSnapshot and the authEmailChangeEvent const) out of the ClinicalDashboard monolith into a self-contained module. No closure over main-component state; testid/aria strings unchanged (sha1 identical); pinned AST/rendered-text guards green. First step of the approved move-map decomposition (docs/redesign/04-deferred). Co-Authored-By: Claude Fable 5 --- src/components/ClinicalDashboard.tsx | 128 +--------------- .../clinical-dashboard/auth-panel.tsx | 138 ++++++++++++++++++ 2 files changed, 140 insertions(+), 126 deletions(-) create mode 100644 src/components/clinical-dashboard/auth-panel.tsx diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index bff397a83d..bed74dbad9 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -25,10 +25,8 @@ import { Layers, ListChecks, Loader2, - LogIn, LogOut, LockKeyhole, - Mail, Palette, PanelTop, Plus, @@ -52,7 +50,6 @@ import { } from "lucide-react"; import { type CSSProperties, - FormEvent, memo, useCallback, useEffect, @@ -94,7 +91,6 @@ import { fieldIcon, floatingControl, iconTilePremium, - fieldLabel, metadataPill, panelSubtle, primaryControl, @@ -118,10 +114,11 @@ import { toneSuccess, toneWarning, } from "@/components/ui-primitives"; -import { AUTH_EMAIL_STORAGE_KEY, useAuthSession } from "@/lib/supabase/client"; +import { useAuthSession } from "@/lib/supabase/client"; import { SafeBoldText } from "@/components/SafeBoldText"; import { Sheet } from "@/components/ui/sheet"; import { AnswerEmptyState, AnswerSkeleton, CopyButton } from "@/components/clinical-dashboard/answer-status"; +import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; import { StatusBadge, StrengthBadge } from "@/components/clinical-dashboard/badges"; @@ -271,7 +268,6 @@ function useMobilePreviewSheet() { return useSyncExternalStore(subscribeToMobilePreviewMedia, getMobilePreviewSnapshot, () => false); } -const authEmailChangeEvent = "clinical-kb-auth-email-change"; export const recentQueryStorageKey = "clinical-kb-recent-queries"; const documentPageSize = 150; const activeIndexingPollFallbackMs = 5_000; @@ -485,32 +481,6 @@ function normalizeNavigationHash(hash: string) { return navigationHashes.includes(hash as (typeof navigationHashes)[number]) ? hash : "#search"; } -function getAuthEmailSnapshot() { - if (typeof window === "undefined") return ""; - try { - return window.localStorage.getItem(AUTH_EMAIL_STORAGE_KEY) ?? ""; - } catch { - return ""; - } -} - -function getServerAuthEmailSnapshot() { - return ""; -} - -function subscribeAuthEmail(onStoreChange: () => void) { - if (typeof window === "undefined") return () => undefined; - const notify = () => onStoreChange(); - - window.addEventListener("storage", notify); - window.addEventListener(authEmailChangeEvent, notify); - - return () => { - window.removeEventListener("storage", notify); - window.removeEventListener(authEmailChangeEvent, notify); - }; -} - const SourceImage = memo(function SourceImage({ endpoint, caption, @@ -4040,100 +4010,6 @@ function StagedAnswerResultSurface({ ); } -function AuthPanel() { - const { status, error, isConfigured, signInWithEmail, signOut, session } = useAuthSession(); - const savedEmail = useSyncExternalStore(subscribeAuthEmail, getAuthEmailSnapshot, getServerAuthEmailSnapshot); - const [draftEmail, setDraftEmail] = useState(null); - const email = draftEmail ?? savedEmail; - const busy = status === "loading"; - const isExpired = status === "expired"; - - async function submit(event: FormEvent) { - event.preventDefault(); - if (!email.trim()) return; - await signInWithEmail(email.trim()); - } - - if (!isConfigured) { - return ( -
-
- -
-

Real-data sign-in unavailable

-

- Configure the Supabase public URL and publishable key before using private documents. -

-
-
-
- ); - } - - if (status === "authenticated") { - return ( -
-
-
-

Signed in for private documents

-

{session?.user.email ?? "Authenticated session"}

-
- -
-
- ); - } - - return ( -
-
- -
-

- {isExpired ? "Sign-in link expired" : "Sign in for private documents"} -

-

- {isExpired - ? "Send a fresh link if this one failed or already timed out." - : "Real-data search, upload, and source previews require a Supabase Auth session."} -

-
-
- - - {error && ( -

- {error} -

- )} -
- ); -} - const tagQualityTone: Record = { noisy: toneDanger, duplicate: toneWarning, diff --git a/src/components/clinical-dashboard/auth-panel.tsx b/src/components/clinical-dashboard/auth-panel.tsx new file mode 100644 index 0000000000..c78fc806d3 --- /dev/null +++ b/src/components/clinical-dashboard/auth-panel.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { type FormEvent, useState, useSyncExternalStore } from "react"; +import { Loader2, LogIn, LogOut, Mail, ShieldAlert } from "lucide-react"; + +import { AUTH_EMAIL_STORAGE_KEY, useAuthSession } from "@/lib/supabase/client"; +import { + cn, + fieldControlWithIcon, + fieldIcon, + fieldLabel, + floatingControl, + panelSubtle, + primaryControl, + textMuted, +} from "@/components/ui-primitives"; + +const authEmailChangeEvent = "clinical-kb-auth-email-change"; + +function getAuthEmailSnapshot() { + if (typeof window === "undefined") return ""; + try { + return window.localStorage.getItem(AUTH_EMAIL_STORAGE_KEY) ?? ""; + } catch { + return ""; + } +} + +function getServerAuthEmailSnapshot() { + return ""; +} + +function subscribeAuthEmail(onStoreChange: () => void) { + if (typeof window === "undefined") return () => undefined; + const notify = () => onStoreChange(); + + window.addEventListener("storage", notify); + window.addEventListener(authEmailChangeEvent, notify); + + return () => { + window.removeEventListener("storage", notify); + window.removeEventListener(authEmailChangeEvent, notify); + }; +} + +export function AuthPanel() { + const { status, error, isConfigured, signInWithEmail, signOut, session } = useAuthSession(); + const savedEmail = useSyncExternalStore(subscribeAuthEmail, getAuthEmailSnapshot, getServerAuthEmailSnapshot); + const [draftEmail, setDraftEmail] = useState(null); + const email = draftEmail ?? savedEmail; + const busy = status === "loading"; + const isExpired = status === "expired"; + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!email.trim()) return; + await signInWithEmail(email.trim()); + } + + if (!isConfigured) { + return ( +
+
+ +
+

Real-data sign-in unavailable

+

+ Configure the Supabase public URL and publishable key before using private documents. +

+
+
+
+ ); + } + + if (status === "authenticated") { + return ( +
+
+
+

Signed in for private documents

+

{session?.user.email ?? "Authenticated session"}

+
+ +
+
+ ); + } + + return ( +
+
+ +
+

+ {isExpired ? "Sign-in link expired" : "Sign in for private documents"} +

+

+ {isExpired + ? "Send a fresh link if this one failed or already timed out." + : "Real-data search, upload, and source previews require a Supabase Auth session."} +

+
+
+ + + {error && ( +

+ {error} +

+ )} +
+ ); +} From d92cfe85b9b3435ece5e43020349389b8be755e4 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:01:10 +0800 Subject: [PATCH 6/6] docs: record AuthPanel extraction + hand-off for remaining decomposition Co-Authored-By: Claude Fable 5 --- docs/process-hardening.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/process-hardening.md b/docs/process-hardening.md index b43cbbd37c..abbf4578cd 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -30,6 +30,19 @@ This document turns the current process review into phased, durable repo practic - Added `src/components/clinical-dashboard/` as the module boundary. - `src/app/page.tsx` now imports `ClinicalDashboard` from the module path (`@/components/clinical-dashboard`) while preserving the legacy source declaration file for AST and merge-guard compatibility. +- **2026-07-03:** extracted `AuthPanel` (+ its solely-consumed auth-email snapshot helpers) into `clinical-dashboard/auth-panel.tsx`. Monolith 7924 → 7800 lines. Per-module gate established: `npm run typecheck` + `npx vitest run tests/clinical-dashboard-merge-artifacts.test.ts tests/rendered-text-formatting.test.ts` + a `data-testid`/`aria-label` sha1 checksum over `ClinicalDashboard.tsx` + `clinical-dashboard/*.tsx` (must be byte-identical before/after each move) + lint + prettier. + +#### Remaining decomposition — hand-off (do on a stable `main`, one module per commit) + +The approved move map (`docs/redesign/04-deferred.md` §2) has 5 modules left. Unlike `auth-panel`, these are **interdependent** — they share a clinical-detail/notes helper family, so order matters and cross-module `export`s are required. Recommended order and the key dependency to resolve first: + +1. `answer-content.tsx` — `SourceImage`, `ScopeAndGovernanceNotice`, `SourcePreviewContent`, `NaturalLanguageAnswer` (**AST-pinned** — retarget `tests/clinical-dashboard-merge-artifacts.test.ts` to scan this file for `NaturalLanguageAnswer`), `UserQuestionBubble`, `KeyClinicalItems` + answer formatters. Widen `tests/rendered-text-formatting.test.ts` to also scan this file. +2. `evidence-panels.tsx` — the clinical-detail/notes helper family (`displayItemsForClinicalDetailSection`, `sortClinicalDetailSections`, `clinicalDetailSummaryItems`, and siblings) **plus** `ClinicalNotesChecklistPanel`, `SafetyFindingsPanel`, `EvidenceGapPanel`, `EvidenceCounts`, `AnswerSourceStatus`, `EvidenceSummaryCard`, `AnswerInsightBar`, `EvidenceVerificationStrip`, `AnswerFeedbackPanel`, `VerificationWorkspace`, `AnswerViewModeControl`, `EvidenceMapTable`, `AnswerSafetyNotice`, `QuoteCards`. **Export the helper family** so output-panel can import it. Must land before output-panel. +3. `output-panel.tsx` — `ClinicalOutputPanel` (**AST-pinned** — retarget `dashboardPath` in `tests/clinical-dashboard-merge-artifacts.test.ts` to resolve declarations across the monolith + this file). Imports the detail helpers from `evidence-panels`. +4. `visual-evidence.tsx` — `VisualEvidenceStrip`, `InlineTableCard`, `MobileEvidenceSheetContent`, `MobileEvidenceTabPanel`, `UnifiedEvidenceDrawerContent`. +5. `document-results.tsx` — `WhyThisMatchedPanel`, `RelatedDocumentsPanel`, `StagedAnswerResultSurface`. + +For each: trace which module-scope helpers/icons/types it uses; move solely-consumed ones with it, import shared ones; strip newly-orphaned monolith imports (lint flags them); run the per-module gate above; commit immediately. Keep the main `ClinicalDashboard` export in `ClinicalDashboard.tsx` (the barrel/bridge stays). Admin surfaces (`DocumentDrawer`, `SettingsDialog`, `ToolsHub`, `MobileSectionFab`) are out of the approved map — a later pass. ## Phase 4 - Release maturity