feat(rectification): 区间常驻条补代表分钟与已答题数,盘面五段折叠收成两段
Independent Staging Quality Gate / validate (push) Canceled after 3m14s
Independent Staging Quality Gate / publish (push) Canceled after 0s

R7 / 决策 D10。任务书 docs/tasks/TASK-cend-surfaces-claude-alignment-20260916.md。

T7.1 常驻条。任务书 E9 说区间只在盘面标题行,实际读代码:对话区顶部
已有常驻条 RectificationTimeline 且已显示区间与宽度,窄屏 .is-compact 下
也可见。真正只存在于 .rectification-board__clock 的是代表分钟——窄屏盘面
是默认关闭的 overlay,代表分钟就此看不见。所以没有另造第二条(会撞固定条
高这条正确性约束),而是在既有条上补齐缺的两项:

  05:07–05:09 · 3 分钟 · 代表分钟 05:08 · 已答 6 题

代表分钟与盘面标题行是同一次 workingRectificationTime(result) 调用,一个
值两处用,不是两条推导路径。已答题数逐字读服务端的
inference_state.answered_probe_count,投影没给就整项不显示,不补「已答 0 题」。
条上不写确认,边界句仍只在交付/采用旁白里出现一次。读数只增宽不增高:条高
在滚动容器外,变高会让贴底读者的内容无声滑出视野。

收窄进度未做,触发让步顺序第 5 条。没有这个字段(服务端只把这句话拼进旁白
正文),且前端自己减会算错——candidate_range 是当前窗口且会放宽(BUG-572),
拿它当「最初」相减会把一次放宽报成一次收窄。缺口与解除条件写进 BLOCKED.md。

T7.2 盘面折叠。实际有 8 个 details(任务书记的五处漏了交界节奏、错标了两块),
按内容归成「候选分钟」(换升时刻 + 次级候选)与「参数与口径」(宫位表、确认门、
本轮技法、两段本命上升、经历与大运对照含双轨一致性、交界节奏)。每块保留原有
渲染条件,engineMeaningToDisplayCopy() 仍是 5 处,逐分钟行的 is-changed /
当前时间 一字未改。共享组件 technique-audit-disclosure.tsx 未动。

顺带修掉一个静默失效的断言:rectification-agentic-entry 用
board.slice(indexOf(A), indexOf(B)),B 改名后 indexOf 返回 -1,slice(i, -1)
会悄悄退化成几乎整份文件,底下四条 doesNotMatch 照样绿。已换成带越界断言的
sliceBetween()。两处改动的断言都在测试里写了原值/新值/原因。

tsc 0 错;lint 0 error / 118 warning(同基线);npm test 3350 → 3360,失败
仍是 31 条无 Docker 用例且与基线逐条一致;next build exit 0,/ 仍 ○ Static;
产物 CSS gzip 39,017 → 38,973 B(−0.11%)。

窄屏 .is-compact 下常驻条的实际可见性无 Chrome 无法验证,已按任务书写成环境
缺口,未标通过;CSS 上已确认无任何规则在该状态下隐藏该条,并写成测试断言。

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 06:44:49 +00:00
co-authored by Claude Opus 5
parent a1a78c7ebc
commit ca0204984b
13 changed files with 751 additions and 72 deletions
+56 -1
View File
@@ -2523,6 +2523,11 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
}
/* Both take the single cell and stack. The chat keeps its three rows here, so
the range bar is on screen at every width — the overlay only covers it while
the board is deliberately open, and it is closed by default. Nothing below
may hide `.rectification-timeline` under `.is-compact`: on a narrow viewport
that bar is the only place the range and the working minute appear (D10). */
.rectification-workspace.is-compact .rectification-workspace__chat,
.rectification-workspace.is-compact .rectification-board-overlay {
grid-area: 1 / 1;
@@ -2562,11 +2567,19 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
border-bottom: 1px solid var(--color-border);
overflow: hidden;
}
/* One line, always. The bar's height is fixed on purpose (see above), so the
readout may grow wider but never taller; wrapping would push the axis out of
a box nothing is observing. Four short items clear 375px with room to spare,
and the bar's own `overflow: hidden` clips rather than reflows if they ever
do not. */
.rectification-timeline__readout {
margin: 0;
display: flex;
align-items: baseline;
gap: var(--space-2);
flex-wrap: nowrap;
gap: var(--space-2) var(--space-3);
min-width: 0;
white-space: nowrap;
font-size: 13px;
line-height: 1.2;
}
@@ -2578,6 +2591,22 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
.rectification-timeline__width {
color: var(--color-ink-secondary);
}
/* The working minute reads at the same weight as the range: on a compact
viewport the board is a closed overlay, so this is the only place it shows. */
.rectification-timeline__working {
font-variant-numeric: tabular-nums;
font-weight: 500;
color: var(--color-ink);
}
/* Last in the row and first to be clipped: the count is context, not the
answer, and it is the only item the reader can do without. */
.rectification-timeline__answered {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--color-ink-secondary);
font-variant-numeric: tabular-nums;
}
.rectification-timeline__axis {
position: relative;
height: 26px;
@@ -2832,6 +2861,22 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
padding-top: 0;
border-top: 0;
}
/* The board folds to two sections, so most disclosures are now nested one level
inside 候选分钟 or 参数与口径. The reset above strips every separator, which
left the nested rows flush against each other; the rhythm inside an open
section is restored here rather than by relaxing that reset, which the
top-level sections still want. */
.rectification-board .technique-audit > .technique-audit,
.rectification-board .technique-audit > .technique-audit-panel,
.rectification-board .technique-audit > .rectification-board__group,
.rectification-board .technique-audit > .rectification-board__minutes,
.rectification-board .technique-audit > .rectification-board__disclosure {
margin-top: var(--space-4);
}
/* A label sits closer to the list it names than to the block before it. */
.rectification-board .technique-audit > .rectification-board__group + .rectification-board__minutes {
margin-top: var(--space-2);
}
.rectification-board__section {
display: grid;
gap: var(--space-3);
@@ -2843,6 +2888,16 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
font-weight: 600;
letter-spacing: -.2px;
}
/* Names the list that follows it now that 换升时刻 is a group inside 候选分钟
rather than a fold of its own. Not a <summary>: this content is always open
once its section is, so a second disclosure to click would be one more step
to the same place. */
.rectification-board__group {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
font-weight: 600;
}
.rectification-board__minutes {
display: grid;
gap: var(--space-3);
@@ -43,6 +43,7 @@ import { RectificationRangeDelivery } from "@/components/rectification-range-del
import {
diffRectificationBoard,
RECTIFICATION_BOARD_SPLIT_MIN_PX,
workingRectificationTime,
} from "@/lib/rectification-board-model";
import {
rectificationAdoptingLabel,
@@ -1405,6 +1406,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
candidateTimes: candidateResult?.candidates.map((candidate) => candidate.time) ?? [],
inferenceMarks: candidateResult?.inferenceMarks ?? [],
stage: caseStage,
// The same call the board's title-row clock makes, on the same result, so
// the bar and `.rectification-board__clock` are one number by construction
// rather than by two code paths that happen to agree today.
workingTime: workingRectificationTime(candidateResult),
workingAdopted: Boolean(candidateResult?.selectedTime),
answeredProbeCount: candidateResult?.answeredProbeCount ?? null,
});
const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey;
const liveSelectionCardKey = persistedOfferKey
+77 -50
View File
@@ -70,6 +70,20 @@ function RectificationBoardBody({
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 (
<>
@@ -114,26 +128,17 @@ function RectificationBoardBody({
])}
/>
</figure>
<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} />
)}
</div>
)}
{(scoringMinutes.length > 0 || displayMinutes.length > 0) && (
<details className="technique-audit" aria-label="换升时刻">
{hasMinuteDetail && (
<details className="technique-audit" aria-label="候选分钟">
<summary>
· {grouped.length}
<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);
@@ -155,11 +160,12 @@ function RectificationBoardBody({
);
})}
</ol>
</>
)}
{displayMinutes.length > 0 && (
<details className="technique-audit">
<summary>
<small>{displayMinutes.length} </small>
</summary>
<ol className="rectification-board__minutes">
@@ -178,40 +184,61 @@ function RectificationBoardBody({
)}
</details>
)}
{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>
{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>
@@ -5,16 +5,25 @@
* minute do I take". Adoption stays on the delivery card, so this component
* takes no callbacks and renders no controls.
*
* It carries exactly two things the axis and the range readout. Counts, a
* legend, the stage name, the range-change sentence and an explanatory footnote
* were all considered and cut: that sentence is already written into the
* persisted assistant message (BUG-588 requires it there, so repeating it here
* would put one sentence on screen twice), and the rest explain what a single
* glance already shows.
* It carries the axis and a one-line readout: range, width, working minute and
* the answered-question count. The working minute is here because on a narrow
* viewport the board is an overlay and closed by default, which used to leave
* the one number the reader tracks off screen entirely (D10, 2026-09-16).
*
* Still cut, and staying cut: a legend, the stage name, and the range-change
* sentence that sentence is already written into the persisted assistant
* message (BUG-588 requires it there, so repeating it would put one sentence on
* screen twice).
*
* Every figure is echoed from the server projection. The working minute comes
* from the same `workingRectificationTime(result)` call the board's title-row
* clock makes, so the two surfaces cannot disagree; the count is the server's
* `answered_probe_count`. Nothing is computed here.
*
* The bar lives outside the scroll container as its own grid row, so its height
* must never change see `.rectification-timeline` in globals.css. An unready
* case renders the same-height skeleton rather than nothing.
* must never change see `.rectification-timeline` in globals.css. The readout
* stays one line for that reason: it grew wider, never taller. An unready case
* renders the same-height skeleton rather than nothing.
*/
"use client";
@@ -31,11 +40,23 @@ export function RectificationTimeline({ view }: { view: RectificationTimelineVie
<p className="rectification-timeline__readout">
<span className="rectification-timeline__range">{view.rangeLabel}</span>
<span className="rectification-timeline__width">{view.widthLabel}</span>
{view.workingLabel ? (
<span className="rectification-timeline__working">{view.workingLabel}</span>
) : null}
{view.answeredLabel ? (
<span className="rectification-timeline__answered">{view.answeredLabel}</span>
) : null}
</p>
<div
className="rectification-timeline__axis"
role="img"
aria-label={`出生时间搜索范围 ${view.axisStartLabel}${view.axisEndLabel},当前区间 ${view.rangeLabel}${view.widthLabel}`}
aria-label={[
`出生时间搜索范围 ${view.axisStartLabel}${view.axisEndLabel}`,
`当前区间 ${view.rangeLabel}`,
view.widthLabel,
view.workingLabel,
view.answeredLabel,
].filter(Boolean).join("")}
>
<span className="rectification-timeline__rule" />
{/* Placed and resized with transform only. Animating left/width would
@@ -105,6 +105,12 @@ export type RectificationCandidateResult = Readonly<{
verificationReportMarkdown: string | null;
/** Inference-layer minutes including eliminated ones. Empty when no inference_state. */
inferenceMarks?: readonly RectificationInferenceMark[];
/**
* `inference_state.answered_probe_count`, read verbatim off the wire. The
* count is the server's own; nothing here recounts answers or derives one
* from the transcript (VOICE.md rule 2: numbers come from the projection).
*/
answeredProbeCount?: number | null;
}>;
function record(value: unknown): Record<string, unknown> | null {
@@ -294,6 +300,17 @@ function parseCredibleRange(value: unknown): readonly [string, string] | null {
return [start, end];
}
/**
* The server's count of answered discrimination probes. Read, never computed:
* the bar above the conversation may only show numbers the projection already
* carries, so this returns null rather than counting anything locally.
*/
function parseAnsweredProbeCount(receipt: Record<string, unknown> | null): number | null {
const state = record(receipt?.inference_state);
const value = state?.answered_probe_count;
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
}
function parseInferenceMarks(receipt: Record<string, unknown> | null): readonly RectificationInferenceMark[] {
const state = record(receipt?.inference_state);
if (!state || !Array.isArray(state.candidates)) return [];
@@ -411,6 +428,7 @@ export function parseRectificationCandidateResult(value: unknown): Rectification
(snapshot.rangeDelivery ?? snapshot.range_delivery) as { verification_markdown?: unknown } | undefined,
),
inferenceMarks: parseInferenceMarks(receipt),
answeredProbeCount: parseAnsweredProbeCount(receipt),
};
}
@@ -15,6 +15,14 @@
* minute-level discriminating power, so a graded mark would render a
* difference the engine cannot support. The only encoding is in-range or
* excluded.
*
* There is deliberately no "narrowed from N minutes to M" readout. The opening
* window is on the wire, but no projection carries that comparison as a field,
* and VOICE.md rule 2 puts progress figures on the server. Computing it here
* would also be wrong as often as right: the axis is the *current* window and
* it widens (BUG-572), so the arithmetic would report progress through a
* widening. The narration keeps saying it, composed server-side where the case
* history is. See docs/tasks/PROGRESS-cend-rectification-20260916.md.
*/
const MINUTES_PER_DAY = 1440;
@@ -47,6 +55,20 @@ export type RectificationTimelineView = Readonly<{
rangeLabel: string;
/** `3 分钟` */
widthLabel: string;
/**
* `代表分钟 05:08`, or `已采用 04:53` once a minute has been adopted. Null
* when the case has no working minute yet.
*
* Adopted is not confirmed: the label states which minute is in use and makes
* no claim about the birth minute being settled. The full boundary sentence
* stays where it belongs, in the delivery/adopt narration.
*/
workingLabel: string | null;
/**
* `已答 6 题`, straight off `inference_state.answered_probe_count`. Null when
* the projection carries no count never a zero this module invented.
*/
answeredLabel: string | null;
}>;
/** `HH:MM` or `HH:MM:SS` to minutes past midnight; null when unparseable. */
@@ -149,6 +171,16 @@ export type RectificationTimelineInput = Readonly<{
*/
inferenceMarks?: readonly TimelineInferenceMark[];
stage: RectificationTimelineStage | null;
/**
* `workingRectificationTime(result)` the very call the board's title-row
* clock makes. Passing the resolved minute rather than the result keeps one
* source for both surfaces, so the bar and the board can never disagree.
*/
workingTime?: string | null;
/** True once the reader adopted this minute, which changes only the label. */
workingAdopted?: boolean;
/** `inference_state.answered_probe_count`; absent when not projected. */
answeredProbeCount?: number | null;
}>;
/**
@@ -218,5 +250,31 @@ export function buildRectificationTimeline(
ticks: buildTicks(axisStart, axisEnd),
rangeLabel: `${formatClockMinutes(bandStart)}${formatClockMinutes(bandEnd)}`,
widthLabel: timelineDurationLabel(bandEnd - bandStart + 1),
workingLabel: workingMinuteLabel(input.workingTime, input.workingAdopted === true),
answeredLabel: answeredProbeLabel(input.answeredProbeCount),
};
}
/**
* The working minute as the bar says it. The clock string is echoed, not
* recomputed: an unparseable value yields no label rather than a guess.
*/
export function workingMinuteLabel(
workingTime: string | null | undefined,
adopted: boolean,
): string | null {
const minutes = parseClockMinutes(workingTime);
if (minutes === null) return null;
const clock = formatClockMinutes(minutes);
return adopted ? `已采用 ${clock}` : `代表分钟 ${clock}`;
}
/**
* Renders the server's answered-probe count and nothing else. A missing count
* stays missing: showing `已答 0 题` where the projection said nothing would be
* a number this layer made up.
*/
export function answeredProbeLabel(count: number | null | undefined): string | null {
if (typeof count !== "number" || !Number.isInteger(count) || count <= 0) return null;
return `已答 ${count}`;
}