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
This commit is contained in:
co-authored by
Claude Opus 5
parent
20435a1916
commit
317e9f1886
@@ -655,7 +655,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.app-loading-error .app-loading-content { padding: 28px; border: 1px solid var(--color-border); background: var(--color-canvas); border-color: var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-elevated); }
|
||||
|
||||
.group\/sidebar-provider[data-viewport] { height: 100vh; min-height: 0; overflow: hidden; }
|
||||
.chat-app { width: 100%; height: 100%; min-height: 0; overflow: hidden; display: grid; background: var(--color-canvas); grid-template-columns: minmax(0, 1fr); }
|
||||
.chat-app { width: 100%; height: 100%; min-height: 0; overflow: clip; display: grid; background: var(--color-canvas); grid-template-columns: minmax(0, 1fr); }
|
||||
.sidebar { position: relative; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: flex; flex-direction: column; border-right: 1px solid var(--sidebar-border); color: var(--sidebar-foreground); padding: var(--space-5) var(--space-3) 0; background: var(--sidebar-background); backdrop-filter: saturate(130%) blur(20px); }
|
||||
[data-sidebar="header"] { flex: 0 0 auto; padding-bottom: var(--space-3); }
|
||||
[data-sidebar="content"] { min-height: 0; flex: 1 1 auto; display: flex; flex-direction: column; gap: var(--space-3); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; }
|
||||
@@ -844,7 +844,11 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
[data-state="collapsed"] .sidebar-footer { padding-top: var(--space-3); }
|
||||
}
|
||||
|
||||
.chat-panel { height: 100%; min-width: 0; min-height: 0; overflow: hidden; display: grid; background: var(--color-canvas); grid-template-rows: 46px minmax(0, 1fr) auto; }
|
||||
/* `clip`, not `hidden`: an overflow:hidden box is still programmatically
|
||||
scrollable. A focus() inside the transcript scrolled this panel by 88px and
|
||||
pushed the 46px header off the top for good — there is no scrollbar to get
|
||||
it back. `clip` creates no scroll container, so it cannot happen again. */
|
||||
.chat-panel { height: 100%; min-width: 0; min-height: 0; overflow: clip; display: grid; background: var(--color-canvas); grid-template-rows: 46px minmax(0, 1fr) auto; }
|
||||
.chat-panel.is-rectification { grid-template-rows: 46px minmax(0, 1fr); }
|
||||
.chat-header { z-index: 2; min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); padding: 0 var(--space-4); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); text-align: left; }
|
||||
.chat-header > div { min-width: 0; }
|
||||
|
||||
@@ -1283,7 +1283,7 @@ export default function Home() {
|
||||
|
||||
useEffect(() => {
|
||||
if (hydrated && accountId && !profileComplete && onboardingStep === "name" && presetMessageFinished && activeAccountDialog === null) {
|
||||
composerInput.current?.focus();
|
||||
composerInput.current?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [accountId, activeAccountDialog, hydrated, onboardingStep, presetMessageFinished, profileComplete]);
|
||||
|
||||
@@ -1360,7 +1360,7 @@ export default function Home() {
|
||||
setDraftTheme(theme ?? null);
|
||||
setDraftEntrypoint(entrypoint);
|
||||
setComposerNotice("");
|
||||
window.requestAnimationFrame(() => composerInput.current?.focus());
|
||||
window.requestAnimationFrame(() => composerInput.current?.focus({ preventScroll: true }));
|
||||
}
|
||||
|
||||
async function startSuggestedConsultation(
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -209,7 +209,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
const recalled = queued.take();
|
||||
if (!recalled) return;
|
||||
setDraft(appendQueuedText(composerDraftSnapshot(), recalled));
|
||||
composerInput.current?.focus();
|
||||
composerInput.current?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -442,7 +442,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
setRequestError(null);
|
||||
cancellationFeedbackRequest.current = pending.requestId;
|
||||
setComposerNotice("已停止,问题已放回输入框,正在确认点数…");
|
||||
window.requestAnimationFrame(() => composerInput.current?.focus());
|
||||
window.requestAnimationFrame(() => composerInput.current?.focus({ preventScroll: true }));
|
||||
|
||||
if (pending.phase === "undo" || isPreview) {
|
||||
if (pendingConsultation.current?.requestId === pending.requestId) {
|
||||
|
||||
@@ -58,7 +58,10 @@ test("generating does not disable the textarea; Enter queues instead of dropping
|
||||
assert.match(page, /if \(isLoading \|\| cancellationPending\) return void enqueueQueuedDraft\(composerDraftSnapshot\(\)\);/);
|
||||
assert.match(consultationRun, /queuedDraftSettleAction\(settlePhase\) === "send"/);
|
||||
assert.match(consultationRun, /function enqueueQueuedDraft/);
|
||||
assert.match(consultationRun, /composerInput\.current\?\.focus\(\)/);
|
||||
// 原值 `composerInput.current?.focus()` / 新值 `focus({ preventScroll: true })`
|
||||
// / 原因:裸 focus 会让浏览器滚动所有可滚动祖先,包括 overflow 的 `.chat-panel`——
|
||||
// 线上实测把 46px 顶栏顶到了 y=-88 且无法滚回。焦点行为本身没变,只是不再连带滚动。
|
||||
assert.match(consultationRun, /composerInput\.current\?\.focus\(\{ preventScroll: true \}\)/);
|
||||
|
||||
assert.match(rectification, /inputDisabled=\{readonly\}/);
|
||||
assert.doesNotMatch(
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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");
|
||||
});
|
||||
@@ -69,7 +69,10 @@ test("the composer keeps its Chinese input, focus and accessibility contract", (
|
||||
assert.match(pageSource, /const composerInput = useRef<HTMLTextAreaElement>\(null\)/);
|
||||
assert.match(pageSource, /inputRef=\{composerInput\}/);
|
||||
assert.match(composerSource, /<Textarea\n\s*ref=\{inputRef\}/);
|
||||
assert.match(pageSource, /composerInput\.current\?\.focus\(\)/);
|
||||
// 原值 `composerInput.current?.focus()` / 新值 `focus({ preventScroll: true })`
|
||||
// / 原因:裸 focus 会让浏览器滚动所有可滚动祖先,包括 overflow 的 `.chat-panel`——
|
||||
// 线上实测把 46px 顶栏顶到了 y=-88 且无法滚回。焦点行为本身没变,只是不再连带滚动。
|
||||
assert.match(pageSource, /composerInput\.current\?\.focus\(\{ preventScroll: true \}\)/);
|
||||
|
||||
// Then: the form shell, labels, hit targets and the stop control are unchanged.
|
||||
assert.match(composerSource, /<form className="composer" onSubmit=\{onSubmit\}>/);
|
||||
|
||||
Reference in New Issue
Block a user