对话、星盘、星历、报告进同一 (app) 外壳,列表只拉一次。服务端不再列出空咨询;新建复用已有空会话。新标题改成「生时校正 · M月D日」,侧栏副标题用创建时间。
85 lines
4.0 KiB
TypeScript
85 lines
4.0 KiB
TypeScript
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");
|
|
});
|