产品实测:生时校正答了三题后 46px 顶栏消失。devtools 取证——栅格是对的
(rows "46px 1297px"、panel y=0),但 header 自己的 rect 在 **y=-88**:
面板被程序化滚动了 88px。
根因:`overflow: hidden` 仍然是滚动容器,它只是去掉了滚动条。
BirthTimeChoiceQuestion 在每答完一题后 focus 新题的第一个选项,浏览器
为把它带进视野会滚动所有可滚动祖先,`.chat-panel` 就是其中之一——
而用户没有滚动条可以滚回来,顶栏于是永久消失。
两处都修:
- 病因:面板内 7 处程序化 focus 一律加 { preventScroll: true }。
消息区有自己的 useConversationScrollAnchor,本来就不需要浏览器代劳。
账户弹窗的 closeButton / returnTarget 不在此列——那是对话框焦点管理。
同一教训 use-billing-panel.ts 已经吃过一次(那里早写了 preventScroll)。
- 结构:.chat-panel 与 .chat-app 从 overflow:hidden 改成 overflow:clip。
clip 根本不创建滚动容器,此后任何 focus / scrollIntoView 都无法位移它。
新增 tests/chat-panel-scroll-guard.test.ts:锁住两个容器必须是 clip、
面板内不得有裸 focus(),并遍历 rectification/birth-time/chat- 全部组件,
新组件再写裸 focus 会直接打红。
测试 3372(+3),fail 仍 31 且与基线逐条一致;四个路由标记不变。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
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/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");
|
|
});
|