import assert from "node:assert/strict"; import { readdirSync, readFileSync } from "node:fs"; import test from "node:test"; /** * The chat panel must never be scrollable, and nothing inside it may ask the * browser to scroll an ancestor. * * Observed on staging: after answering three rectification questions the 46px * header was gone. The grid was right (`46px 1297px`, panel at y=0) but the * header's own rect was at **y = -88** — the panel had been scrolled 88px. * `overflow: hidden` still creates a scroll container: it only removes the * scrollbar, so a programmatic scroll sticks and the user cannot undo it. * * The cause was `BirthTimeChoiceQuestion` focusing the first option after every * answered question. Focus scrolls every scrollable ancestor by default, and the * transcript's own `useConversationScrollAnchor` was not the box that moved. */ const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); function rule(selector: string): string { const match = globalStyles.match(new RegExp(`^\\${selector} \\{([^}]*)\\}`, "m")); assert.ok(match, `${selector} must exist in globals.css`); return match![1]; } test("the chat panel and app shell clip rather than hide, so neither is a scroll container", () => { // `hidden` would pass a naive "does it overflow" check while still being // scrollable; `clip` is the only value that makes the box unscrollable. assert.match(rule(".chat-panel"), /overflow: clip/); assert.match(rule(".chat-app"), /overflow: clip/); assert.doesNotMatch(rule(".chat-panel"), /overflow: hidden/); assert.doesNotMatch(rule(".chat-app"), /overflow: hidden/); }); test("nothing inside the chat panel focuses without preventScroll", () => { // Components that render inside `.chat-panel`. A focus() here reaches the // panel through the default scroll-into-view; the transcript has its own // anchor and does not want the browser's. const insidePanel = [ "src/app/(app)/page.tsx", "src/hooks/use-consultation-run.ts", "src/components/birth-time-choice-question.tsx", "src/components/birth-time-rectification.tsx", "src/components/rectification-agentic-chat.tsx", "src/components/rectification-board.tsx", ]; /* Dialog focus management is the one legitimate bare focus() in these files: an account dialog is an overlay above the panel, not content inside it, and moving focus into it must not be suppressed. */ const dialogFocus = /closeButton|returnTarget|focusTrap/; for (const path of insidePanel) { const source = readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); for (const [line] of source.matchAll(/^.*\.focus\((.*)$/gm)) { if (/focus-visible|:focus|onFocus/.test(line)) continue; if (dialogFocus.test(line)) continue; assert.match( line, /focus\(\{ preventScroll: true \}\)/, `${path}: ${line.trim()}\n` + "A bare focus() inside the chat panel scrolls it. Pass { preventScroll: true } " + "and let useConversationScrollAnchor own the transcript's scroll position.", ); } } }); test("the guard covers every component that renders inside the panel today", () => { // If a new rectification/consultation component starts calling focus(), this // list has to grow with it — otherwise the contract above silently stops // covering the surface it was written for. const components = readdirSync(new URL("../src/components/", import.meta.url), { recursive: true, encoding: "utf8" }) .filter((entry) => entry.endsWith(".tsx") && /rectification|birth-time|chat-/.test(entry)); const missing: string[] = []; for (const entry of components) { const source = readFileSync(new URL(`../src/components/${entry}`, import.meta.url), "utf8"); for (const [line] of source.matchAll(/^.*\.focus\((.*)$/gm)) { if (/focus-visible|:focus|onFocus/.test(line)) continue; if (!/preventScroll: true/.test(line)) missing.push(`${entry}: ${line.trim()}`); } } assert.deepEqual(missing, [], "these focus() calls inside the chat surface would scroll the panel"); });