产品实测:生时校正答了三题后 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
352 lines
13 KiB
TypeScript
352 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { X } from "lucide-react";
|
||
import { useEffect, useRef } from "react";
|
||
import {
|
||
groupWindowTransitions,
|
||
houseTableToNorthIndianChart,
|
||
rectificationBoardPeekCopy,
|
||
workingRectificationTime,
|
||
type RectificationBoardDiff,
|
||
} from "@/lib/rectification-board-model";
|
||
import { engineMeaningToDisplayCopy } from "@/lib/rectification-agentic/user-copy";
|
||
import {
|
||
workingRectificationHouseTable,
|
||
type RectificationCandidateResult,
|
||
} from "@/lib/rectification-candidate-result";
|
||
import {
|
||
windowScanLayerLabel,
|
||
type WindowScanLayer,
|
||
} from "@/lib/rectification-agentic/v9/refinement-packet";
|
||
import { rectificationBoardEmptyCopy } from "@/lib/rectification-surface-state";
|
||
import { VedicChartSvg } from "@/components/personal-report/vedic-chart-svg";
|
||
import { RectificationHouseTableView } from "./rectification-house-table";
|
||
import { ConfirmationGateDisclosure, TechniqueAuditDisclosure } from "./technique-audit-disclosure";
|
||
|
||
function LayerChips({ layers }: Readonly<{ layers: readonly WindowScanLayer[] }>) {
|
||
if (layers.length === 0) return null;
|
||
return (
|
||
<ul className="rectification-board__chips">
|
||
{layers.map((layer) => (
|
||
<li key={layer}>
|
||
<span className="rectification-board__chip">{windowScanLayerLabel(layer)}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
}
|
||
|
||
function RectificationBoardBody({
|
||
result,
|
||
declaredTime,
|
||
birthTimeSource,
|
||
savedStatus,
|
||
diff,
|
||
compact,
|
||
titleId,
|
||
onClose,
|
||
}: Readonly<{
|
||
result: RectificationCandidateResult | null;
|
||
declaredTime: string | null;
|
||
birthTimeSource?: string | null;
|
||
savedStatus: "accepted" | "confirmed" | null;
|
||
diff: RectificationBoardDiff;
|
||
compact: boolean;
|
||
titleId: string;
|
||
onClose: () => void;
|
||
}>) {
|
||
const table = result ? workingRectificationHouseTable(result) : null;
|
||
const workingTime = workingRectificationTime(result);
|
||
const emptyCopy = rectificationBoardEmptyCopy(declaredTime, birthTimeSource);
|
||
const grouped = result ? groupWindowTransitions(result.windowTransitions) : [];
|
||
const scoringMinutes = grouped.filter((row) => row.scoringLayers.length > 0);
|
||
const displayMinutes = grouped.filter((row) => row.displayLayers.length > 0);
|
||
const changedHouses = new Set(diff.houseNumbers);
|
||
const changedMinutes = new Set(diff.transitionMinutes);
|
||
const stage = result?.precisionStage?.user_meaning
|
||
?? result?.natalRecast?.user_meaning
|
||
?? null;
|
||
const ledger = result?.eventDashaLedger ?? [];
|
||
const contrast = result?.lagnaContrast ?? null;
|
||
const agreement = result?.dashaAgreement ?? null;
|
||
const nakshatra = result?.nakshatraBoundary ?? null;
|
||
// Two sections, not five (D10). Nothing was dropped in the regrouping — the
|
||
// minutes moved under 候选分钟, and the house table, the confirmation gate,
|
||
// the technique audit, the two-lagna split, the event/dasha ledger with its
|
||
// dual-track agreement line and the nakshatra note all moved under 参数与口径.
|
||
// Each block keeps the render condition it had, so a state that showed
|
||
// nothing before still shows nothing: the gate and the audit stay behind
|
||
// `table`, exactly as when they sat inside `.rectification-snapshot`.
|
||
const hasMinuteDetail = scoringMinutes.length > 0 || displayMinutes.length > 0;
|
||
const hasParameterDetail = Boolean(
|
||
table
|
||
|| contrast
|
||
|| ledger.length > 0
|
||
|| (nakshatra?.near_boundary && nakshatra.user_meaning),
|
||
);
|
||
|
||
return (
|
||
<>
|
||
{compact ? <div className="rectification-board__handle" aria-hidden="true" /> : null}
|
||
<header className="rectification-board__header">
|
||
<div className="rectification-board__title-row">
|
||
<h2 id={titleId}>当前盘面</h2>
|
||
{workingTime
|
||
? <span className="rectification-board__clock">{workingTime}</span>
|
||
: emptyCopy.clock
|
||
? <span className="rectification-board__clock is-declared">{emptyCopy.clock}</span>
|
||
: null}
|
||
{compact && (
|
||
<button
|
||
className="rectification-board__close"
|
||
type="button"
|
||
aria-label="关闭盘面"
|
||
onClick={onClose}
|
||
>
|
||
<X aria-hidden="true" strokeWidth={2} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
{stage ? <p className="rectification-board__stage">{engineMeaningToDisplayCopy(stage)}</p> : null}
|
||
</header>
|
||
<div className="rectification-board__body">
|
||
<p className="sr-only" role="status" aria-live="polite" aria-atomic="true">{diff.liveMessage}</p>
|
||
{!table && (
|
||
<div className="rectification-board__empty">
|
||
{emptyCopy.lines.map((line) => <p key={line}>{line}</p>)}
|
||
</div>
|
||
)}
|
||
{table && (
|
||
<div className="rectification-snapshot">
|
||
<figure className="rectification-board__chart">
|
||
<VedicChartSvg
|
||
chart={houseTableToNorthIndianChart(table)}
|
||
ariaLabel={`当前本命盘,上升${table.lagna}`}
|
||
highlightHouseNumbers={new Set([
|
||
...changedHouses,
|
||
...(diff.lagnaChanged ? [1] : []),
|
||
])}
|
||
/>
|
||
</figure>
|
||
</div>
|
||
)}
|
||
{hasMinuteDetail && (
|
||
<details className="technique-audit" aria-label="候选分钟">
|
||
<summary>
|
||
候选分钟
|
||
<small>{grouped.length} 个时刻</small>
|
||
</summary>
|
||
{scoringMinutes.length > 0 && (
|
||
<>
|
||
<p className="rectification-board__group">换升时刻</p>
|
||
<ol className="rectification-board__minutes">
|
||
{scoringMinutes.map((row) => {
|
||
const changed = changedMinutes.has(row.at);
|
||
const working = row.at === workingTime;
|
||
return (
|
||
<li
|
||
key={row.at}
|
||
className={[
|
||
"rectification-board__minute",
|
||
changed ? "is-changed" : "",
|
||
working ? "is-working" : "",
|
||
].filter(Boolean).join(" ")}
|
||
>
|
||
<time dateTime={row.at}>{row.at}</time>
|
||
{working ? <span className="rectification-board__minute-note">当前时间</span> : null}
|
||
{changed ? <span className="rectification-board__minute-note">已更新</span> : null}
|
||
<LayerChips layers={row.scoringLayers} />
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
</>
|
||
)}
|
||
{displayMinutes.length > 0 && (
|
||
<details className="technique-audit">
|
||
<summary>
|
||
次级候选
|
||
<small>{displayMinutes.length} 个时刻</small>
|
||
</summary>
|
||
<ol className="rectification-board__minutes">
|
||
{displayMinutes.map((row) => (
|
||
<li
|
||
key={`display-${row.at}`}
|
||
className={changedMinutes.has(row.at) ? "rectification-board__minute is-changed" : "rectification-board__minute"}
|
||
>
|
||
<time dateTime={row.at}>{row.at}</time>
|
||
{changedMinutes.has(row.at) ? <span className="rectification-board__minute-note">已更新</span> : null}
|
||
<LayerChips layers={row.displayLayers} />
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</details>
|
||
)}
|
||
</details>
|
||
)}
|
||
{hasParameterDetail && (
|
||
<details className="technique-audit" aria-label="参数与口径">
|
||
<summary>参数与口径</summary>
|
||
{table && (
|
||
<>
|
||
<details className="technique-audit">
|
||
<summary>本命宫位表</summary>
|
||
<RectificationHouseTableView
|
||
table={table}
|
||
changedHouses={changedHouses}
|
||
lagnaChanged={diff.lagnaChanged}
|
||
/>
|
||
</details>
|
||
{result && <ConfirmationGateDisclosure gate={result.confirmationGate} />}
|
||
{savedStatus === "accepted" && result && result.techniqueAudit.length > 0 && (
|
||
<TechniqueAuditDisclosure rows={result.techniqueAudit} />
|
||
)}
|
||
</>
|
||
)}
|
||
{contrast && (
|
||
<details className="technique-audit">
|
||
<summary>两段本命上升</summary>
|
||
<div className="rectification-board__disclosure">
|
||
<p>{engineMeaningToDisplayCopy(contrast.user_meaning)}</p>
|
||
<ul>
|
||
{contrast.intervals.map((interval) => (
|
||
<li key={`${interval.start}-${interval.end}`}>
|
||
{interval.start}-{interval.end}:{interval.lagna}
|
||
{interval.lords.l1 ? `;一/四/七/十宫主 ${[interval.lords.l1, interval.lords.l4, interval.lords.l7, interval.lords.l10].filter(Boolean).join("、")}` : ""}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</details>
|
||
)}
|
||
{ledger.length > 0 && (
|
||
<details className="technique-audit">
|
||
<summary>
|
||
经历与大运对照
|
||
<small>{ledger.length} 项</small>
|
||
</summary>
|
||
<ul className="rectification-board__disclosure">
|
||
{ledger.map((item) => (
|
||
<li key={item.summary}>{engineMeaningToDisplayCopy(item.user_meaning)}</li>
|
||
))}
|
||
</ul>
|
||
{agreement ? <p className="rectification-board__disclosure">{engineMeaningToDisplayCopy(agreement.user_meaning)}</p> : null}
|
||
</details>
|
||
)}
|
||
{nakshatra?.near_boundary && nakshatra.user_meaning && (
|
||
<details className="technique-audit">
|
||
<summary>交界节奏</summary>
|
||
<p className="rectification-board__disclosure">{engineMeaningToDisplayCopy(nakshatra.user_meaning)}</p>
|
||
</details>
|
||
)}
|
||
</details>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export function RectificationBoard({
|
||
result,
|
||
declaredTime,
|
||
birthTimeSource,
|
||
savedStatus,
|
||
diff,
|
||
compact,
|
||
open,
|
||
boardId,
|
||
titleId,
|
||
onClose,
|
||
}: Readonly<{
|
||
result: RectificationCandidateResult | null;
|
||
declaredTime: string | null;
|
||
birthTimeSource?: string | null;
|
||
savedStatus: "accepted" | "confirmed" | null;
|
||
diff: RectificationBoardDiff;
|
||
compact: boolean;
|
||
open: boolean;
|
||
boardId: string;
|
||
titleId: string;
|
||
onClose: () => void;
|
||
}>) {
|
||
const sheetRef = useRef<HTMLElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (!compact || !open) return;
|
||
/* 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();
|
||
};
|
||
window.addEventListener("keydown", handleKey);
|
||
return () => window.removeEventListener("keydown", handleKey);
|
||
}, [compact, open, onClose]);
|
||
|
||
if (compact && !open) return null;
|
||
|
||
const sheet = (
|
||
<aside
|
||
ref={sheetRef}
|
||
id={boardId}
|
||
className={`rectification-board${compact ? " is-sheet" : ""}`}
|
||
aria-labelledby={titleId}
|
||
role={compact ? "dialog" : undefined}
|
||
aria-modal={compact ? true : undefined}
|
||
tabIndex={compact ? -1 : undefined}
|
||
>
|
||
<RectificationBoardBody
|
||
result={result}
|
||
declaredTime={declaredTime}
|
||
birthTimeSource={birthTimeSource}
|
||
savedStatus={savedStatus}
|
||
diff={diff}
|
||
compact={compact}
|
||
titleId={titleId}
|
||
onClose={onClose}
|
||
/>
|
||
</aside>
|
||
);
|
||
|
||
if (!compact) return sheet;
|
||
|
||
return (
|
||
<div className="rectification-board-overlay">
|
||
<div className="rectification-board-scrim" aria-hidden="true" onClick={onClose} />
|
||
{sheet}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function RectificationBoardPeek({
|
||
result,
|
||
declaredTime = null,
|
||
birthTimeSource,
|
||
expanded,
|
||
boardId,
|
||
onOpen,
|
||
}: Readonly<{
|
||
result: RectificationCandidateResult | null;
|
||
declaredTime?: string | null;
|
||
birthTimeSource?: string | null;
|
||
expanded: boolean;
|
||
boardId: string;
|
||
onOpen: () => void;
|
||
}>) {
|
||
const peek = rectificationBoardPeekCopy(result, declaredTime, birthTimeSource);
|
||
return (
|
||
<button
|
||
className="rectification-board-peek"
|
||
type="button"
|
||
aria-label={peek.detail ? `${peek.title},${peek.detail}` : peek.title}
|
||
aria-expanded={expanded}
|
||
aria-controls={boardId}
|
||
onClick={onOpen}
|
||
>
|
||
<strong>{peek.title}</strong>
|
||
<span>{peek.detail}</span>
|
||
</button>
|
||
);
|
||
}
|