Files
Jyotisha/frontend/tests/chat-panel-scroll-guard.test.ts
T
Jesse_ChenandClaude Opus 5 317e9f1886
Independent Staging Quality Gate / validate (push) Successful in 10m24s
Independent Staging Quality Gate / publish (push) Successful in 19m9s
fix(rectification): 顶栏被 focus 滚出视野——面板改 clip,面板内 focus 一律 preventScroll
产品实测:生时校正答了三题后 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
2026-09-16 08:56:07 +00:00

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");
});