fix(rectification): 顶栏被 focus 滚出视野——面板改 clip,面板内 focus 一律 preventScroll
Independent Staging Quality Gate / validate (push) Successful in 10m24s
Independent Staging Quality Gate / publish (push) Successful in 19m9s

产品实测:生时校正答了三题后 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
This commit is contained in:
Jesse_Chen
2026-09-16 08:56:07 +00:00
co-authored by Claude Opus 5
parent 20435a1916
commit 317e9f1886
10 changed files with 113 additions and 13 deletions
@@ -48,7 +48,10 @@ export function BirthTimeChoiceQuestion(props: ChoiceQuestionProps) {
const firstChoiceRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!props.pending && (props.progress.answeredCount > 0 || props.error)) {
firstChoiceRef.current?.focus();
/* preventScroll: the transcript has its own anchor; letting the browser
scroll ancestors here scrolls `.chat-panel`, an overflow:hidden box with
no scrollbar, and the 46px header never comes back. */
firstChoiceRef.current?.focus({ preventScroll: true });
}
}, [props.error, props.pending, props.progress.answeredCount, props.question.questionId]);
const select = (option: PublicDynamicChoiceQuestion["options"][number]) => {
@@ -103,7 +106,7 @@ export function BirthTimeUnmatchedClarification(props: ClarificationProps) {
const reframeRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!props.pending && (props.progress.answeredCount > 0 || props.error)) {
reframeRef.current?.focus();
reframeRef.current?.focus({ preventScroll: true });
}
}, [props.error, props.pending, props.progress.answeredCount]);
return (
@@ -59,7 +59,7 @@ export function BirthTimeRectification(props: BirthTimeRectificationProps) {
if (!changed || props.journey.journeyProtocol !== "dynamic-choice-v2") return;
const action = props.journey.nextAction;
if (action.kind !== "ask_dynamic_choice" && action.kind !== "clarify_unmatched_answer") {
assessmentHeadingRef.current?.focus();
assessmentHeadingRef.current?.focus({ preventScroll: true });
}
}, [props.journey]);
if (props.journey.journeyProtocol !== "dynamic-choice-v2") {
@@ -1824,7 +1824,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const recalled = queued.take();
if (!recalled) return;
setDraft(appendQueuedText(recalled, draft));
composer.current?.focus();
composer.current?.focus({ preventScroll: true });
} } : undefined}
onSubmit={submit}
onChange={(event) => setDraft(event.target.value)}
@@ -273,7 +273,10 @@ export function RectificationBoard({
useEffect(() => {
if (!compact || !open) return;
sheetRef.current?.focus();
/* preventScroll for the same reason as the choice options: this sheet
lives in the panel's grid cell, and the browser would scroll the
panel to reach it. */
sheetRef.current?.focus({ preventScroll: true });
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};