From ca0204984b3d3a4064be6b8da0cde3b19459d190 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 16 Sep 2026 06:38:49 +0000 Subject: [PATCH] =?UTF-8?q?feat(rectification):=20=E5=8C=BA=E9=97=B4?= =?UTF-8?q?=E5=B8=B8=E9=A9=BB=E6=9D=A1=E8=A1=A5=E4=BB=A3=E8=A1=A8=E5=88=86?= =?UTF-8?q?=E9=92=9F=E4=B8=8E=E5=B7=B2=E7=AD=94=E9=A2=98=E6=95=B0=EF=BC=8C?= =?UTF-8?q?=E7=9B=98=E9=9D=A2=E4=BA=94=E6=AE=B5=E6=8A=98=E5=8F=A0=E6=94=B6?= =?UTF-8?q?=E6=88=90=E4=B8=A4=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P --- BLOCKED.md | 8 + .../PROGRESS-cend-rectification-20260916.md | 187 +++++++++++++ docs/tasks/README.md | 2 +- frontend/DESIGN.md | 28 +- frontend/src/app/globals.css | 57 +++- .../components/rectification-agentic-chat.tsx | 7 + .../src/components/rectification-board.tsx | 127 +++++---- .../src/components/rectification-timeline.tsx | 39 ++- .../src/lib/rectification-candidate-result.ts | 18 ++ .../src/lib/rectification-timeline-scale.ts | 58 ++++ .../tests/rectification-agentic-entry.test.ts | 29 +- .../tests/rectification-board-model.test.ts | 9 +- .../rectification-range-bar-20260916.test.ts | 254 ++++++++++++++++++ 13 files changed, 751 insertions(+), 72 deletions(-) create mode 100644 docs/tasks/PROGRESS-cend-rectification-20260916.md create mode 100644 frontend/tests/rectification-range-bar-20260916.test.ts diff --git a/BLOCKED.md b/BLOCKED.md index 67a456cc..35433e82 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,5 +1,13 @@ # BLOCKED +## 生时校正常驻条:缺「收窄进度」服务端字段(2026-09-16,分支 `codex/cend-rectification-20260916`,T7.1) + +- **触发让步顺序第 5 条。** 任务书要常驻条写「当前区间、宽度、收窄进度、已答题数」四项。前三项里只有**收窄进度**没做,其余三项已上条。 +- **缺的是什么:** 没有任何投影把「已从 N 分钟收到 M 分钟」作为字段给出。服务端能拼出这句话(`user-copy.ts` 的 `progressClause()`,由 `answer-choice.ts` / `agent-run.ts` 传 `openingRangeFromDossier(dossier)` 调用),但它只作为**旁白正文**落库,没有结构化出口。客户端要么解析消息文本,要么自己减——两条都不行。 +- **为什么不在前端减。** 开场窗口 `case.candidate_range` 确实在线上(`searchWindowFromSnapshot`),但:① VOICE.md 第 2 条把进度数字划给服务端;② 更要命的是会算错——`candidate_range` 是**当前**搜索窗口且会放宽(BUG-572:±15 → ±30 → ±60 → ±120),拿它当「最初」去减,一次放宽会被报成一次收窄。 +- **现状不是回归:** 收窄进度这句话服务端照旧写进旁白,用户仍看得到,只是不在常驻条上。 +- **解除需要:** 投影里加一个稳定的开场宽度 / 收窄进度字段(`inference_state` 或 `case` 上都行),要求它记的是**开场**窗口而不是当前窗口,放宽时不得跟着变。届时常驻条加一项即可,`rectification-timeline-scale.ts` 已留好位置。另立单,本轮不做。 + ## 对话额度拆分(2026-09-16,分支 `codex/consultation-session-capacity-20260915`,BUG-732) - **无 Docker:** 本机 `docker` 不在 PATH。`npm run test:db` 与 `npm run db:migrate:check`(需要 `SCHEMA_DATABASE_URL` 连 Postgres)未跑。`frontend/tests/database-consultation-session-capacity.test.ts` 在无 Docker 时 skip,**不得写成通过**。替代证据是 SQL 静态合同:额度求和表达式不含 `thinkingText` / `thinkingSections`,物理上限为 `length(elem::text)`,算式写在迁移注释里。 diff --git a/docs/tasks/PROGRESS-cend-rectification-20260916.md b/docs/tasks/PROGRESS-cend-rectification-20260916.md new file mode 100644 index 00000000..75960ee9 --- /dev/null +++ b/docs/tasks/PROGRESS-cend-rectification-20260916.md @@ -0,0 +1,187 @@ +# PROGRESS · R7 生时校正(2026-09-16) + +任务书:`docs/tasks/TASK-cend-surfaces-claude-alignment-20260916.md` 的 **R7**(T7.1 + T7.2,决策 D10)。 + +- 基线:`origin/staging` = `50ce02c8` +- 分支:`codex/cend-rectification-20260916` +- worktree:`/workspace/Jyotisha/.claude/worktrees/agent-a8ca21a2f5ced3002`(本会话被隔离在这个 agent worktree,不是任务书里的 `.worktrees/cend-rectification-20260916`) +- BUG 编号:**未占用**。本轮是产品改造,未发现需要建档的产品缺陷,`docs/BUG_HISTORY.md` 当前最大号仍是 **BUG-738**(已核对)。 + +--- + +## 1. 开工基线数字 + +在 `50ce02c8` 上 `npm ci` 后实测: + +| 项 | 基线 | +| --- | --- | +| `tsc --noEmit` | 0 错 | +| `npm run lint` | **0 error / 118 warning** | +| `npm test` | `# tests 3350 / # pass 3304 / # fail 31` | +| 失败清单 | 31 条,全部是无 Docker 的数据库 / 部署 / 迁移类;存档在 `base.txt` | +| `next build` | exit 0,`/` = `○ Static` | +| 产物 CSS gzip | `.next/static/chunks/*.css` 合计 **39,017 B**(raw 251,634 B) | + +--- + +## 2. 逐条验收对照 + +### T7.1 可信区间提成常驻条 + +**先说一个与任务书事故实证不一致的发现,它改变了本轮的做法。** + +任务书 E9 写「区间只以 `.rectification-board__clock` 的形式出现在右侧盘面板的标题行」。实际读代码:`rectification-agentic-chat.tsx:1645` 起,对话区顶部**已经有一条常驻条** `RectificationTimeline`(BUG-602/603 那一轮做的),它是 `.rectification-workspace__chat` 的第一个 grid 行,已经在显示**当前区间与宽度**(`view.rangeLabel` / `view.widthLabel`)。窄屏 `.is-compact` 下 `.rectification-workspace__chat` 取 `grid-area: 1 / 1` 且三行俱在,所以这条**本来就可见**。 + +真正只存在于 `.rectification-board__clock` 的是**代表分钟**——窄屏下盘面变成默认关闭的 overlay,代表分钟就此看不见。这才是 E9 描述的那个死角。 + +因此本轮没有另造第二条常驻条(那会撞上固定条高这条正确性约束,也等于手写第二套),而是**在既有常驻条上补齐缺的两项**。 + +| 验收标准 | 结论 | 证据 | +| --- | --- | --- | +| 窄屏 `.is-compact` 下常驻条仍然可见 | **CSS 上确认,真机未验(环境缺口)** | 见 §5 | +| 边界语义不弱化 | 通过 | 条上只写「代表分钟 HH:MM」/「已采用 HH:MM」,不写确认;`REPRESENTATIVE_MINUTE_DISCLAIMER` 与交付/采用旁白一行未改。测试 `an adopted minute is labelled adopted...` 断言组件与 scale 模块都不出现「已确认 / 确认的唯一」 | +| 区间数字与 `.rectification-board__clock` 同源 | 通过 | 同一次调用:盘面 `rectification-board.tsx:59` `workingRectificationTime(result)`;条由 `rectification-agentic-chat.tsx` 传 `workingTime: workingRectificationTime(candidateResult)`。不是两条推导路径,是一个值两处用。测试第 1 条锁死这三行源码 | + +条上最终四项,一行排完: + +``` +05:07–05:09 · 3 分钟 · 代表分钟 05:08 · 已答 6 题 +``` + +每项的服务端来源: + +| 读数 | 来源 | 是否前端口算 | +| --- | --- | --- | +| 当前区间 | `credible_range`(服务端投影) | 否 | +| 宽度 | 该区间自身的闭区间宽度,`timelineDurationLabel()`,既有函数未改 | 否(任务书让步顺序第 5 条明确允许「只显示当前区间与宽度」) | +| 代表分钟 | `workingRectificationTime(result)` = `selectedTime ?? representativeTime ?? houseTable.time`,与盘面同源 | 否 | +| 已答题数 | `decisionReceipt.inference_state.answered_probe_count`,**服务端自己的计数**,逐字读出 | 否 | +| 收窄进度 | — | **未做,触发让步顺序第 5 条,见 §3** | + +`answered_probe_count` 此前已经在线上(`compactInferenceProjection()` 写进 `inference_state`,随 receipt 下发),只是客户端没解析。本轮加 `parseAnsweredProbeCount()` 读出来,投影没给就整项不显示,不补 `已答 0 题`。 + +### T7.2 盘面板折叠块收敛 + +任务书说五块(`:117/:132/:160/:182/:198`)。实际数下来盘面里有 **8 个** `
`:本命宫位表、唯一分钟确认门、本轮技法、换升时刻(内嵌显示层)、两段本命上升、经历与大运对照、交界节奏。任务书把 `:117` 记成「盘面技法审计」、`:182` 记成「口径披露」,并漏了 `:212` 的交界节奏。按实际内容归的类: + +| 新段 | 装进去的原块 | +| --- | --- | +| **候选分钟**(`aria-label="候选分钟"`) | 换升时刻(改为段内组标题 + 原列表)、次级候选(原「显示层」子折叠) | +| **参数与口径**(`aria-label="参数与口径"`) | 本命宫位表、唯一分钟确认门、本轮技法、两段本命上升、经历与大运对照(含双轨一致性那句)、交界节奏 | + +| 验收标准 | 结论 | 证据 | +| --- | --- | --- | +| 五块内容一条不丢,只换归属 | 通过 | 测试 `the board folds to two sections and loses none of the five blocks` 在两段的源码切片里逐块断言 | +| `engineMeaningToDisplayCopy()` 调用点全部保留 | 通过 | 改前 5 处,改后 5 处,测试里 `assert.equal(..., 5)` 锁死 | +| 候选分钟列表 `is-changed` / 「当前时间」行为不变 | 通过 | 逐分钟行的 JSX 原样搬运未改;测试 `regrouping changed no minute-row behaviour` 锁 `is-changed` / `is-working` / 「当前时间」/「已更新」四处 | +| 键盘可达性不弱于改前 | 通过(静态) | 每块仍是真 ``,无 `role="button"`、无 `onClick` 开合;测试断言 `` 数 ≥ 6 且无 `open`。嵌套多了一层 → 到最深一块要多按一次 Enter,这是收敛的固有代价,不是可达性缺失 | + +**渲染条件逐块保持原样。** 确认门与本轮技法原本写在 `{table && ...}` 里面,现在仍跟着 `table` 走——没有星盘时它们本来就不出现,改后也一样。不是「一条不丢」的反面,是不多出一条。 + +### 硬约束核对 + +| # | 约束 | 结论 | +| --- | --- | --- | +| 1 | 数字只来自服务端投影 | 通过,四项来源见上表;收窄进度按让步顺序不做 | +| 2 | 边界语义不弱化、候选不得写成 confirmed | 通过 | +| 3 | 常驻条区间与盘面 clock 同源 | 通过 | +| 4 | 五块一条不丢、`engineMeaningToDisplayCopy()` 全留、`is-changed`/「当前时间」不变 | 通过 | +| 5 | 窄屏 `.is-compact` 下常驻条可见 | CSS 确认,真机是缺口(§5) | +| 6 | 只动 `rectification-*` 与 `globals.css` 的 `.rectification-*` / `.technique-audit` 区段 | 通过,见 §6 | +| 7 | 复用 `ChatComposer` / `useConversationScrollAnchor` | 通过,两者一行未改;未新增输入框与滚动跟随 | +| 8 | `tsc` 0 错、`lint` 0 error、warning ≤ 118 | 通过:0 错 / **0 error / 118 warning**(与基线同数) | +| 9 | `npm test` 失败清单与基线逐条一致 | 通过:`# tests 3360 / # pass 3314 / # fail 31`,`diff base.txt after.txt` **无差异** | +| 10 | 改断言写「原值/新值/原因」 | 通过,见 §4 | +| 11 | `next build` 后 `/` 仍 `○ Static` | 通过,exit 0,`┌ ○ /` | +| 12 | 揭幕后无 spinner / 骨架 /「正在加载」 | 通过,未改 `data-state="pending"` 等高空骨架;新增元素都是静态 `` | +| 13 | 同提交更新 `DESIGN.md`、对照 `VOICE.md` | 通过,§10 与 §12 重写;文案对照见下 | + +**首屏体积(红线:`/` gzip ±2%)。** Next 16.3.1 的构建输出没有 Size / First Load 两列,沿用 BUG-601 那轮的口径直接量产物:`.next/static/chunks/*.css` 合计 gzip **39,017 → 38,973 B(−44 B,−0.11%)**,在 ±2% 内。raw 从 251,634 涨到 252,540 B(+906 B),gzip 反而略降属正常。新增 JS(`rectification-timeline-scale.ts` 的两个 label 函数、`parseAnsweredProbeCount`)全部落在校正面的 `dynamic()` 分包里,不进 `/` 首屏。 + +**VOICE.md 对照。** 「代表分钟 05:08」「已采用 05:08」「已答 6 题」三句都是短事实,无公文腔,不含被禁的「概率 / 置信度 / 确定 / 相对支持度」,也不碰 DESIGN.md 里已明令砍掉的「已对照 N 件经历」——那是经历吻合数,归交付卡;条上的「已答 N 题」是问答题数,两个量不同,已在 DESIGN.md 表里写明区别。 + +--- + +## 3. 让步顺序:触发了第 5 条 + +**让步顺序第 5 条「T7.1 的收窄进度:若服务端投影里没有现成的『已从 N 分钟收到 M 分钟』字段 —— 只显示当前区间与宽度,不要前端口算进度」——已触发。** + +依据,两条,第二条比第一条更要紧: + +1. **没有这个字段。** 服务端确实能拼出这句话——`user-copy.ts` 的 `progressClause()`,由 `answer-choice.ts:1885/2060` 与 `agent-run.ts:1189` 传 `openingRangeFromDossier(dossier)` 调用——但它只作为**旁白正文**落库,没有结构化出口。客户端要拿到只能解析消息文本。 +2. **前端自己减会算错,不只是违规。** 开场窗口在客户端确实有(`case.candidate_range` → `searchWindow`),但它是**当前**搜索窗口,会放宽(BUG-572:±15 → ±30 → ±60 → ±120)。拿它当「最初」去减,一次放宽会被报成一次收窄。这条写进了 `rectification-timeline-scale.ts` 的模块注释,免得下一个人「顺手补上」。 + +缺口已写进 `BLOCKED.md`(顶部新条目),含解除条件:投影里加一个记**开场**窗口、放宽时不跟着变的宽度/进度字段。`rectification-timeline-scale.ts` 已留好接入位置。**收窄进度这句话服务端照旧写进旁白,用户仍看得到,只是不在常驻条上——不是回归。** + +让步顺序第 6 条(T7.2 退到三段)**未触发**,收敛成两段。 + +--- + +## 4. 被改断言:原值 / 新值 / 原因 + +两处,都在测试文件里就地写了三行注释。 + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `frontend/tests/rectification-board-model.test.ts`("board shows a north-indian chart and folds 换升时刻 by default") | `assert.match(html, /换升时刻 · 2 个/)` | `assert.match(html, /候选分钟/)` + `/2 个时刻/` + `/换升时刻/`,另加 `/参数与口径/` | D10 五段收敛成两段,「换升时刻」从 summary 降为段内组标题,计数移到「候选分钟」的 summary。计数仍是 `grouped.length`,仍默认折叠。**没有弱化**:原来断言 1 个字符串,现在断言 4 个 | +| `frontend/tests/rectification-agentic-entry.test.ts`("the natal house table is a live board beside the chat, not a message") | `snapshot` 切到 `"{scoringMinutes.length > 0 \|\| displayMinutes.length > 0"`,并断言 `= 0)` / `assert.ok(end > start)` 的 `sliceBetween()` 辅助函数,标记改名会直接报错并打印找不到的那个标记。 + +新增测试文件 `frontend/tests/rectification-range-bar-20260916.test.ts`,10 项:同源锁、采用标签与边界、无值不猜、计数来自投影、**无收窄进度口算**、一行四读数与固定条高、窄屏不隐藏、两段收敛不丢块、分钟行行为不变、折叠默认收起且可达。测试总数 3350 → 3360,未降。 + +--- + +## 5. 环境缺口(不得写成通过) + +1. **「窄屏 `.is-compact` 下常驻条的实际可见性」——无 Chrome、无登录态,真机未验。** 这是本轮的核心收益,按任务书要求写成缺口。 + **CSS 上确认到的事实(不等于真机通过):** `.rectification-workspace.is-compact` 把 `.rectification-workspace__chat` 与 `.rectification-board-overlay` 都放进 `grid-area: 1 / 1` 叠起来;`__chat` 在窄屏保留三行栅格,第一行就是常驻条;overlay 只在 `boardOpen` 时渲染,默认关闭。全表扫过一遍,**没有任何规则**在 `.is-compact` 下对 `.rectification-timeline` 写 `display: none` / `visibility: hidden` / `height: 0`;这条写成了测试断言(遍历所有 `.is-compact ... .rectification-timeline` 规则块)。 + **真机仍需确认的:** 四项读数在 375px 实机字体下是否真的一行放得下(算下来约 269px,容器约 343px,留有余量,但中文字宽随字体变);被裁时是不是先裁「已答 N 题」;以及贴底跟随在条变宽后仍然正常(条高未变,理论上不受影响)。 +2. **盘面两段折叠的键盘走查未做。** 静态上每块仍是 ``、默认收起、无假按钮;「Tab / Enter 依次展开两段并进到最深一块」需要真人按一遍。 +3. **无 Docker。** `npm run test:db` 未跑。本轮未动表、未动迁移。 +4. **本轮未推送**(按任务要求)。staging 部署与 `/api/health` 的 `deployment.gitCommit` 核对不在本轮。 + +前两条建议并入 `docs/testing/` 的校正走查清单;本轮未新建清单文件,避免与 R4–R8 其它轮次的清单重复。 + +--- + +## 6. 改动范围 + +只碰了这些,没有越界到 R5/R6/R8 的地盘(`.chart-page-*` / `.ephemeris-*` / `.report-center-*` / `.personal-report-*` / `.settings-*` / `.membership-*` 一个字符未动): + +| 文件 | 改了什么 | +| --- | --- | +| `frontend/src/components/rectification-timeline.tsx` | 读数行加代表分钟与已答题数两个 ``;轴的 `aria-label` 跟着补齐 | +| `frontend/src/lib/rectification-timeline-scale.ts` | 视图加 `workingLabel` / `answeredLabel`;新增 `workingMinuteLabel()` / `answeredProbeLabel()`;模块注释写明为何没有收窄进度 | +| `frontend/src/lib/rectification-candidate-result.ts` | 加 `answeredProbeCount` 字段与 `parseAnsweredProbeCount()` | +| `frontend/src/components/rectification-agentic-chat.tsx` | 给 `buildRectificationTimeline()` 多传三个参数;import `workingRectificationTime` | +| `frontend/src/components/rectification-board.tsx` | 五段折叠收敛成两段;加 `hasMinuteDetail` / `hasParameterDetail` | +| `frontend/src/app/globals.css` | `.rectification-timeline__readout` 改 nowrap + 新增 `__working` / `__answered`;`.rectification-board__group`;盘面嵌套 `.technique-audit` 的间距;`.is-compact` 规则加注释 | +| `frontend/DESIGN.md` | §10 重写(四项读数、只增宽不增高、代表分钟上条的理由与同源要求、收窄进度为何不在条上、砍掉清单加两行);§12 重写(两段折叠表) | +| `BLOCKED.md` | 顶部新增收窄进度字段缺口 | +| 三个测试文件 | 见 §4 | + +`technique-audit-disclosure.tsx` **未改**——它是普通聊天也在用的共享组件,两个 `
` 原样嵌进「参数与口径」里。 + +--- + +## 7. 一件必须交代的事故:共享 stash 栈 + +量首屏 gzip 需要基线构建,本会话用了 `git stash push -u` → 构建基线 → `git stash pop`。**这是错的**:git 的 stash 栈是整个仓库共用的,不按 worktree 分。同一时刻 R5(`cend-chart-eph`)和 R6(`cend-report`)两个会话也在 stash。结果是我的 `pop` 弹出的不是我自己的 stash,而是 R5 的 chart-page / ephemeris 改动(9 个文件),落进了我的 worktree。 + +处理:先把 R5 的改动原样备份成 patch,再 `git stash push -u -m "RECOVERED cend-chart-eph work..."` 整份放回栈上,一个字符没丢。查 `git log -g refs/stash` 时发现 R6 会话也对我的 stash 做了同样的事(`RECOVERED cend-r7-wip ...`),互相救了回来。我自己的改动最后用 `git diff ` 生成 patch、`patch -p1` 打回,未跟踪的新测试文件用 `git show :` 取回,11 个文件与 stash 的 diffstat 逐行一致,随后立刻提交,不再留在栈上。 + +**给后面轮次的教训:多 worktree 并行时不要用 `git stash`。** 要基线构建就另开一个 worktree 或临时 commit,别碰共享栈。 + +--- + +## 8. 最终数字 + +| 项 | 基线 | 本轮 | 结论 | +| --- | --- | --- | --- | +| `tsc --noEmit` | 0 错 | 0 错 | 通过 | +| `npm run lint` | 0 error / 118 warning | 0 error / 118 warning | 通过 | +| `npm test` 总数 | 3350 | 3360(+10) | 未降 | +| `npm test` 失败 | 31 | 31 | 清单逐条一致,`diff` 无差异 | +| `next build` | exit 0,`/` `○ Static` | exit 0,`/` `○ Static` | 通过 | +| 产物 CSS gzip | 39,017 B | 38,973 B | −0.11%,在 ±2% 内 | diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 81ab1754..9c57715b 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -157,7 +157,7 @@ | `TASK-engine-runtime-hygiene-20260901.md` | `PROGRESS-engine-runtime-hygiene-20260901.md` | Python 引擎运行时治理 | 已验收 | `124d3990`(staging 实机三条待部署窗口) | | `TASK-billing-pricing-20260830.md` | `PROGRESS-billing-pricing-20260830.md` | 计费闭环与功能级定价 | 已合入 | `aaaa5fb7`~`a499c344` | | `TASK-cend-ui-claude-alignment-20260916.md` | `PROGRESS-cend-ui-r1/r2/r3-20260916.md` | **C 端界面向 claude.ai 产品界面对齐(三轮串行 R1→R2→R3,都动 `globals.css`,不得并行)**:根因是 `frontend/CLAUDE_DESIGN.md` 扒的是 **claude.com 营销官网**,它自己在 Known Gaps 里写明 claude.ai 产品界面不在范围内,而 `DESIGN.md:3` 把它当成了产品界面的实现契约。**R1**:`--font-display` 里 Tiempos Headline / StyreneB **从未加载**(无 `@font-face`、`public/` 无字体、`layout.tsx` 只 vendor 了 Inter),中文标题全站落到 **宋体 / SimSun**,波及 20 处含助手回答的 h2/h3(BUG-737);亮色强调色 `#85432f` 与暗色 `#d78064` 不同源,产品拍板亮色换 **Claude coral `#cc785c`**,**易漏点**是 `globals.css:16` 的 `--color-ring` 硬编码在 `@theme inline` 里不跟随 `:root`,另有第四个 `:root` 亮色块(`:4358`)必须同步(BUG-738);`.composer-footer` 常驻 44px + 顶栏 68px + `--composer-reserve` 148px,每屏固定吃掉 216px,模型选择器移进输入框内部、删掉底栏、顶栏收到 46px 并删「分析对象」副标题。**R2**:空状态是营销落地页(hero 卡 + 两张 132px 入口大卡 + 3 列 156px 主题卡),输入框被压在 **800px 以上**内容之下,重排成「问候 + 居中输入框 + 两枚入口 pill + 一排 chip」。**R3**:侧栏两个 `
` 拍平成一条「最近」、星盘的两个入口(侧栏分组 + 账户菜单)收敛到一处、删掉逐条助手头像。**决策记录 D3 推翻 DESIGN.md「报告强调色与应用同源」一句**(报告刻意保留深棕)。原型图 https://claude.ai/code/artifact/da275da6-2954-4f50-99aa-32bb8694d38b(三套画面 + 明暗,页面标题就是建议字体栈的实际渲染)。环境缺口:无登录态无 Chrome,四项真机观感留 `docs/testing/`。BUG 段 737–738 | 待领取 | — | -| `TASK-cend-surfaces-claude-alignment-20260916.md` | `PROGRESS-cend-shell-20260916.md`、`PROGRESS-cend-report-20260916.md` | **次级页面对齐(上一单的续篇,R4→R5/R6,R7、R8 可并行)**:星盘 `/chart`、星历 `/ephemeris`、报告 `/reports` **各是脱离 app 外壳的独立全屏页**,顶部只有一个「返回对话」链接、侧栏整个消失,且三家各写了一套一模一样的 `*-shell`/`*-topbar`/`*-hero` 骨架——与上一单 E5 同根因(营销站 band 结构被套到产品界面)。**R4** 抽只读导航外壳 `AppNavRail`(只用现成的 `GET /api/sessions` + `GET /api/account`,会话行走 `sessionHref` 跳 `/?c=`;**刻意不带**重命名/删除/收藏/归档——那套连着 `Home()` 的乐观更新与回滚,搬过来会撞 useState 增长门禁)。**R5** 星盘五 tab 下划线化 + 参数合表 + 行星表横向滚动;星历日期导航改 `‹ 日期 ›`。**R6** 报告中心卡片网格改行式列表;阅读页加常驻目录。**R7** 生时校正把可信区间从盘面板标题行提成常驻条(窄屏 `.is-compact` 下盘面板是 overlay,现在默认看不到区间),五个 `technique-audit` 折叠块收成两段。**R8** 设置内容区收窄(880px 弹窗里表单铺了 690px)、套餐卡三修饰符收敛成两态。**已解锁**:原挡路的设置单已于 `111b4a84`(BUG-698)合入。**两条不得回退**:BUG-698 的 `@supports (height: 1dvh)` 写法(重复声明回退会被 Lightning CSS 折叠)、BUG-616/617 的报告盘面 grid 实现。默认不占 BUG 号 | **R4、R6、R7、R8 已实现,R5 进行中** | R4:抽出 `AppNavRail`(只读,两个 GET,零写操作)+ `SecondaryShell`,三个次级页并入 app 外壳并删掉各自的 shell/topbar/hero;四个路由渲染标记**完全不变**(`/` `/chart` `/ephemeris` 仍 Static);CSS gzip −0.25%。`/reports/[reportId]` 留给 R6 与目录一起做。两处自身健壮性问题被测试抓到:`usePathname()` 可为 null、`fetch` 可能不存在。差点弄丢 BUG-717 的 eyebrow 文案(已放回)。R8:表单分区收窄到 440px(列表分区不变)、套餐卡三修饰符收敛成互斥的 `is-current` / `is-recommended`,`--highlighted` 删除改为滚动定位;手机端 `order:-1` 改挂 `[data-plan-alias]`(版位不是状态)。测试 3350→3354(净增 4),失败清单与基线逐条一致;`/` 仍 Static;我的干净构建实测 CSS gzip −3 字节。**遗留待产品拍板**:`?plan=` 深链现在完全没有视觉指向,只有滚动位置。R6:报告中心卡片网格改行式列表、阅读页并入外壳并把目录挪到右侧常驻。**任务书 E10 过期**——目录在 `cfcd369d` 就已存在,本轮是挪位置定稿而非从零加。挂外壳带出一个真实打印风险已处理:`.chat-app`/`.chat-panel` 是 `height:100%;overflow:hidden`,裸 `window.print()` 会把九节报告裁成一页,阅读页因此多挂一条只在挂载期生效的 print 样式解锁外壳。「生成中的分节进度」做不了——`REPORT_LIST_COLUMNS` 不返回节数,按 VOICE.md 不许前端编 | +| `TASK-cend-surfaces-claude-alignment-20260916.md` | `PROGRESS-cend-shell-20260916.md`、`PROGRESS-cend-report-20260916.md`、`PROGRESS-cend-rectification-20260916.md` | **次级页面对齐(上一单的续篇,R4→R5/R6,R7、R8 可并行)**:星盘 `/chart`、星历 `/ephemeris`、报告 `/reports` **各是脱离 app 外壳的独立全屏页**,顶部只有一个「返回对话」链接、侧栏整个消失,且三家各写了一套一模一样的 `*-shell`/`*-topbar`/`*-hero` 骨架——与上一单 E5 同根因(营销站 band 结构被套到产品界面)。**R4** 抽只读导航外壳 `AppNavRail`(只用现成的 `GET /api/sessions` + `GET /api/account`,会话行走 `sessionHref` 跳 `/?c=`;**刻意不带**重命名/删除/收藏/归档——那套连着 `Home()` 的乐观更新与回滚,搬过来会撞 useState 增长门禁)。**R5** 星盘五 tab 下划线化 + 参数合表 + 行星表横向滚动;星历日期导航改 `‹ 日期 ›`。**R6** 报告中心卡片网格改行式列表;阅读页加常驻目录。**R7** 生时校正把可信区间从盘面板标题行提成常驻条(窄屏 `.is-compact` 下盘面板是 overlay,现在默认看不到区间),五个 `technique-audit` 折叠块收成两段。**R8** 设置内容区收窄(880px 弹窗里表单铺了 690px)、套餐卡三修饰符收敛成两态。**已解锁**:原挡路的设置单已于 `111b4a84`(BUG-698)合入。**两条不得回退**:BUG-698 的 `@supports (height: 1dvh)` 写法(重复声明回退会被 Lightning CSS 折叠)、BUG-616/617 的报告盘面 grid 实现。默认不占 BUG 号 | **R4、R6、R7、R8 已实现,R5 进行中** | R4:抽出 `AppNavRail`(只读,两个 GET,零写操作)+ `SecondaryShell`,三个次级页并入 app 外壳并删掉各自的 shell/topbar/hero;四个路由渲染标记**完全不变**(`/` `/chart` `/ephemeris` 仍 Static);CSS gzip −0.25%。`/reports/[reportId]` 留给 R6 与目录一起做。两处自身健壮性问题被测试抓到:`usePathname()` 可为 null、`fetch` 可能不存在。差点弄丢 BUG-717 的 eyebrow 文案(已放回)。R8:表单分区收窄到 440px(列表分区不变)、套餐卡三修饰符收敛成互斥的 `is-current` / `is-recommended`,`--highlighted` 删除改为滚动定位;手机端 `order:-1` 改挂 `[data-plan-alias]`(版位不是状态)。测试 3350→3354(净增 4),失败清单与基线逐条一致;`/` 仍 Static;我的干净构建实测 CSS gzip −3 字节。**遗留待产品拍板**:`?plan=` 深链现在完全没有视觉指向,只有滚动位置。R6:报告中心卡片网格改行式列表、阅读页并入外壳并把目录挪到右侧常驻。**任务书 E10 过期**——目录在 `cfcd369d` 就已存在,本轮是挪位置定稿而非从零加。挂外壳带出一个真实打印风险已处理:`.chat-app`/`.chat-panel` 是 `height:100%;overflow:hidden`,裸 `window.print()` 会把九节报告裁成一页,阅读页因此多挂一条只在挂载期生效的 print 样式解锁外壳。「生成中的分节进度」做不了——`REPORT_LIST_COLUMNS` 不返回节数,按 VOICE.md 不许前端编。R7:区间常驻条与盘面折叠收敛。**任务书 E9 也不准确**——对话区顶部早有常驻条 `RectificationTimeline` 且窄屏可见,真正只在盘面标题行的是**代表分钟**;因此没另造第二条,在既有条上补齐代表分钟与已答题数(与盘面同一次 `workingRectificationTime()` 调用)。折叠块实际是 **8 个**不是 5 个。**触发让步顺序第 5 条**:收窄进度未做——服务端无该字段,且 `candidate_range` 会放宽(BUG-572),前端相减会把一次放宽报成收窄,已写进 `BLOCKED.md`。顺带修掉一个**静默失效的旧断言**(`slice(indexOf(A), indexOf(B))` 在 B 改名后变成几乎整份文件,四条 `doesNotMatch` 假通过)| ### 待开的任务书(2026-09-03 审查决定) diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 27beb5c5..c4b5448e 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -614,17 +614,30 @@ Admin 的 antd `` 是独立设计系统,不在此表。 读数和轴的水平内边距对齐助手列:桌面 `space-8 + --assistant-content-inset`,767px 以下 `space-4 + --assistant-content-inset`,与 `.message-list` 沟槽和头像让位相同。底边发丝线和背景仍拉满聊天列,不把条做成第二条滚动容器。 -**三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与区间读数(`05:07–05:09 · 3 分钟`)。以下一律不得加回来——每一条都是被明确砍掉的: +**三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与一行读数。读数四项、一行排完: + +`05:07–05:09 · 3 分钟 · 代表分钟 05:08 · 已答 6 题` + +**读数只增宽,不增高。** 条高仍是硬规定二的 64 / 56px,`.rectification-timeline__readout` 因此是 `flex-wrap: nowrap` + `white-space: nowrap`;真放不下时由条本身的 `overflow: hidden` 裁掉尾部,四项里「已答 N 题」排最后、最先被裁——它是背景信息,区间和代表分钟不是。 + +**代表分钟上条是 D10(2026-09-16)的产品决定,推翻了「条上只有区间」的原设计。** 理由是窄屏:`.is-compact` 下右栏盘面整体变成默认关闭的 overlay,代表分钟此前只在 `.rectification-board__clock` 出现,于是用户最该一直看着的那个数字恰恰看不见。条上的取值必须与盘面标题行**同一次调用**——`workingRectificationTime(result)`,由 `rectification-agentic-chat.tsx` 算一次、两处共用,不允许第二条推导路径。已采用时写「已采用 HH:MM」,否则写「代表分钟 HH:MM」;**采用不是确认**,完整边界句仍只在交付 / 采用旁白里出现一次,条上不写、也不得暗示某一分钟已确认。 + +**条上不得出现收窄进度(「已从 30 分钟收到 7 分钟」)。** 开场窗口确实在线上,但没有任何投影把这个对比作为字段给出,而 VOICE.md 第 2 条要求进度数字来自服务端。前端自己减还会算错:轴是**当前**窗口且会放宽(BUG-572),相减会把一次放宽报成进度。这句话继续由服务端拼进旁白。缺的字段见 `BLOCKED.md`。 + +以下一律不得加回来——每一条都是被明确砍掉的: | 不得出现 | 理由 | | --- | --- | | 范围变化句 | 服务端已写进落库助手消息,BUG-588 防复发要求它在那里;条上再写=同句同屏两次 | +| 收窄进度(「已从 N 分钟收到 M 分钟」) | 无服务端字段;前端相减会把放宽报成收窄。见上 | | 「仍在范围内 N / 已排除 N」计数与图例 | 实心=在内、空心=已排除,答完第一题即自明;图例是过度提示的典型形态 | | 阶段标签(时段 / 分钟) | `block_scan` / `minute` 是后台词汇,贴近 BUG-043 红线 | -| 「已对照 N 件经历」 | 归交付卡;卡上是人话「N 件里 M 件对得上」,不是计数条 | +| 「已对照 N 件经历」 | 仍归交付卡;卡上是人话「N 件里 M 件对得上」。它和条上的「已答 N 题」不是同一个量:后者是服务端 `inference_state.answered_probe_count`,问了几道答了几道,不是经历吻合数 | | 「时间轴只读」注脚 | 在解释一个不存在的东西;没有元素看起来可点,就不需要声明不可点 | | 预计剩余时间、定时器插值动画 | 与报告进度条同一条原则:不得演出后端没做的动作 | +**条上每个数字都必须是服务端投影里已有的字段。** 区间来自 `credible_range`,代表分钟来自上面那一次调用,已答题数来自 `answered_probe_count`;投影没给计数就整项不显示,不得补一个 `已答 0 题`。 + ### 轴与标记 - **轴 = 当前搜索窗口(`candidate_range`),不是开场窗口。** 开场窗口不是定值(±15 / ±30 / ±60 / ±120 / 整天),且放宽会顶穿它(BUG-572:±15 → ±30 → ±60 → ±120)。窗口变化时轴重新对到新窗口,区间带与标记用 CSS transition 过渡到新位置(`prefers-reduced-motion` 由全局 `*` 规则收成一帧)。窗口不变时轴稳定,收窄照样读得出来。 @@ -653,7 +666,16 @@ Admin 的 antd `` 是独立设计系统,不在此表。 **本命宫位改北印星盘。** 用 `VedicChartSvg` 渲染代表分钟的 `house_table`。宫位表退到星盘下方的 `
`(读屏与复制仍可用)。换分钟时只高亮变化的宫(`changedHouses` / `lagnaChanged`),不做动画。星盘组件在校正面 `dynamic()` 分包里,不进首页首屏。 -**换升时刻默认折叠。** 时间轴已常驻显示范围与候选点,右栏「换升时刻」整段默认收进 `
`,summary 为「换升时刻 · N 个」;逐分钟 LayerChips 不变。「显示层」子折叠并入同一折叠。确认门折叠、两段本命上升折叠保留原样。 +**折叠块只有两段(D10,2026-09-16)。** 此前盘面从上到下并排五个 `
`,一屏扫下去全是折叠头,看不出哪个该点。现在收敛成两段,星盘本身不折叠: + +| 段 | 装什么 | +| --- | --- | +| **候选分钟** · N 个时刻 | 「换升时刻」(段内组标题 + 逐分钟 LayerChips 列表)、「次级候选」子折叠(原「显示层」) | +| **参数与口径** | 「本命宫位表」、「唯一分钟确认门」、「本轮技法」、「两段本命上升」、「经历与大运对照」(含双轨一致性那句)、「交界节奏」 | + +**这是换归属,不是删内容。** 六块一条不少,每块保留自己原来的渲染条件——确认门与技法表仍跟着 `table` 走,此前没有星盘时它们本来就不出现,现在也一样。所有引擎串仍逐一经过 `engineMeaningToDisplayCopy()`。逐分钟行的 `is-changed` / `is-working` / 「当前时间」 / 「已更新」标记一律不变。 + +两段都默认收起,且都是真 ``——嵌套多了一层,键盘可达性因此只能靠 `` 保证,不得把任何一块改成挂 `onClick` 的 `div`。盘面内 `.technique-audit` 的分隔线仍是全局关掉的,段内节奏由 `.rectification-board .technique-audit > *` 的 `margin-top` 给。 ## 13. 回答的两层可见性 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 590edaa2..041441f8 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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 : 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); diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index aaf43767..778dfa7c 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -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 diff --git a/frontend/src/components/rectification-board.tsx b/frontend/src/components/rectification-board.tsx index a57401de..8ad62933 100644 --- a/frontend/src/components/rectification-board.tsx +++ b/frontend/src/components/rectification-board.tsx @@ -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({ ])} /> -
- 本命宫位表 - -
- {result && } - {savedStatus === "accepted" && result && result.techniqueAudit.length > 0 && ( - - )} )} - {(scoringMinutes.length > 0 || displayMinutes.length > 0) && ( -
+ {hasMinuteDetail && ( +
- 换升时刻 · {grouped.length} 个 + 候选分钟 + {grouped.length} 个时刻 {scoringMinutes.length > 0 && ( + <> +

换升时刻

    {scoringMinutes.map((row) => { const changed = changedMinutes.has(row.at); @@ -155,11 +160,12 @@ function RectificationBoardBody({ ); })}
+ )} {displayMinutes.length > 0 && (
- 显示层 + 次级候选 {displayMinutes.length} 个时刻
    @@ -178,40 +184,61 @@ function RectificationBoardBody({ )}
)} - {contrast && ( -
- 两段本命上升 -
-

{engineMeaningToDisplayCopy(contrast.user_meaning)}

-
    - {contrast.intervals.map((interval) => ( -
  • - {interval.start}-{interval.end}:{interval.lagna} - {interval.lords.l1 ? `;一/四/七/十宫主 ${[interval.lords.l1, interval.lords.l4, interval.lords.l7, interval.lords.l10].filter(Boolean).join("、")}` : ""} -
  • - ))} -
-
-
- )} - {ledger.length > 0 && ( -
- - 经历与大运对照 - {ledger.length} 项 - -
    - {ledger.map((item) => ( -
  • {engineMeaningToDisplayCopy(item.user_meaning)}
  • - ))} -
- {agreement ?

{engineMeaningToDisplayCopy(agreement.user_meaning)}

: null} -
- )} - {nakshatra?.near_boundary && nakshatra.user_meaning && ( -
- 交界节奏 -

{engineMeaningToDisplayCopy(nakshatra.user_meaning)}

+ {hasParameterDetail && ( +
+ 参数与口径 + {table && ( + <> +
+ 本命宫位表 + +
+ {result && } + {savedStatus === "accepted" && result && result.techniqueAudit.length > 0 && ( + + )} + + )} + {contrast && ( +
+ 两段本命上升 +
+

{engineMeaningToDisplayCopy(contrast.user_meaning)}

+
    + {contrast.intervals.map((interval) => ( +
  • + {interval.start}-{interval.end}:{interval.lagna} + {interval.lords.l1 ? `;一/四/七/十宫主 ${[interval.lords.l1, interval.lords.l4, interval.lords.l7, interval.lords.l10].filter(Boolean).join("、")}` : ""} +
  • + ))} +
+
+
+ )} + {ledger.length > 0 && ( +
+ + 经历与大运对照 + {ledger.length} 项 + +
    + {ledger.map((item) => ( +
  • {engineMeaningToDisplayCopy(item.user_meaning)}
  • + ))} +
+ {agreement ?

{engineMeaningToDisplayCopy(agreement.user_meaning)}

: null} +
+ )} + {nakshatra?.near_boundary && nakshatra.user_meaning && ( +
+ 交界节奏 +

{engineMeaningToDisplayCopy(nakshatra.user_meaning)}

+
+ )}
)} diff --git a/frontend/src/components/rectification-timeline.tsx b/frontend/src/components/rectification-timeline.tsx index f5e7adbf..d1e9a716 100644 --- a/frontend/src/components/rectification-timeline.tsx +++ b/frontend/src/components/rectification-timeline.tsx @@ -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

{view.rangeLabel} {view.widthLabel} + {view.workingLabel ? ( + {view.workingLabel} + ) : null} + {view.answeredLabel ? ( + {view.answeredLabel} + ) : null}

{/* Placed and resized with transform only. Animating left/width would diff --git a/frontend/src/lib/rectification-candidate-result.ts b/frontend/src/lib/rectification-candidate-result.ts index 4ecdc103..8e6054c5 100644 --- a/frontend/src/lib/rectification-candidate-result.ts +++ b/frontend/src/lib/rectification-candidate-result.ts @@ -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 | 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 | 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 | 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), }; } diff --git a/frontend/src/lib/rectification-timeline-scale.ts b/frontend/src/lib/rectification-timeline-scale.ts index 54742d8d..621636c2 100644 --- a/frontend/src/lib/rectification-timeline-scale.ts +++ b/frontend/src/lib/rectification-timeline-scale.ts @@ -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} 题`; +} diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index af2b637f..9647fe7b 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -510,18 +510,33 @@ test("the natal house table is a live board beside the chat, not a message", () chat.indexOf("{messages.map((message) => {"), chat.indexOf("{savedTime &&"), ); - const snapshot = board.slice( - board.indexOf("className=\"rectification-snapshot\""), - board.indexOf("{scoringMinutes.length > 0 || displayMinutes.length > 0"), - ); + // A slice whose end marker has gone missing silently becomes `slice(i, -1)`, + // i.e. almost the whole file, and every doesNotMatch below stops meaning what + // it says. Fail loudly on a renamed marker instead. + const sliceBetween = (source: string, from: string, to: string): string => { + const start = source.indexOf(from); + const end = source.indexOf(to); + assert.ok(start >= 0, `marker not found: ${from}`); + assert.ok(end > start, `marker not found after ${from}: ${to}`); + return source.slice(start, end); + }; + // 原值: snapshot 切到 `{scoringMinutes.length > 0 || displayMinutes.length > 0`, + // 并断言 `= 0); - assert.ok(snapshotIndex >= 0 && houseIndex > snapshotIndex); + // The chart is what stays unfolded; the table is one click into 参数与口径. + assert.match(snapshot, / 0); assert.doesNotMatch(snapshot, / })); assert.match(html, /role="img"/); assert.match(html, /当前本命盘,上升金牛座/); - assert.match(html, /换升时刻 · 2 个/); + // 原值: assert.match(html, /换升时刻 · 2 个/) + // 新值: 「候选分钟」段的 summary 带 `2 个时刻`,`换升时刻` 变成段内组标题。 + // 原因: D10 五段收敛成两段,换升时刻与次级候选并入「候选分钟」。计数仍是 + // grouped.length,仍然默认折叠——只是标签换了归属,内容一条不丢。 + assert.match(html, /候选分钟/); + assert.match(html, /2 个时刻/); + assert.match(html, /换升时刻/); assert.match(html, /
]*\sopen/); assert.match(html, /本命宫位表/); + assert.match(html, /参数与口径/); assert.doesNotMatch(html, /]*aria-label="换升时刻"/); }); diff --git a/frontend/tests/rectification-range-bar-20260916.test.ts b/frontend/tests/rectification-range-bar-20260916.test.ts new file mode 100644 index 00000000..c842559f --- /dev/null +++ b/frontend/tests/rectification-range-bar-20260916.test.ts @@ -0,0 +1,254 @@ +/** + * D10 (2026-09-16): the credible range is promoted to a bar that is on screen at + * every width, and the board's five folds collapse to two. + * + * What these tests are really guarding is the honesty rule, not the layout. + * Every figure on that bar has to be one the server already computed — see + * frontend/docs/VOICE.md rule 2. The narrowing-progress readout the brief asked + * for is deliberately absent because no projection carries it; the assertions + * below keep it absent rather than letting someone add the arithmetic later. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { + answeredProbeLabel, + buildRectificationTimeline, + workingMinuteLabel, +} from "../src/lib/rectification-timeline-scale.ts"; +import { parseRectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts"; +import { workingRectificationTime } from "../src/lib/rectification-board-model.ts"; +import { RectificationTimeline } from "../src/components/rectification-timeline.tsx"; + +const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); +const scale = read("../src/lib/rectification-timeline-scale.ts"); +const bar = read("../src/components/rectification-timeline.tsx"); +const board = read("../src/components/rectification-board.tsx"); +const chat = read("../src/components/rectification-agentic-chat.tsx"); +const styles = read("../src/app/globals.css"); + +const RESULT_ID = "77777777-7777-4777-8777-777777777770"; +const CANDIDATE_ID = "77777777-7777-4777-8777-777777777771"; + +function candidateSnapshot(extra: Record = {}) { + return parseRectificationCandidateResult({ + resultId: RESULT_ID, + candidates: [ + { candidateId: CANDIDATE_ID, rank: 1, time: "05:08", relativeSupport: 16, tiedMinuteCount: 1 }, + ], + overallConfidence: "low", + selectionAllowed: true, + confirmationAllowed: false, + representativeTime: "05:08", + selectedTime: null, + credibleRange: ["05:07", "05:09"], + ...extra, + }); +} + +test("the bar's working minute is the board clock's own value, not a second derivation", () => { + // The board title row renders `workingRectificationTime(result)`. The chat + // passes the result of that same call into the bar, so the two surfaces + // cannot drift apart — there is one expression, not two that agree today. + assert.match(board, /const workingTime = workingRectificationTime\(result\);/); + assert.match(board, /className="rectification-board__clock">\{workingTime\}/); + assert.match(chat, /workingTime: workingRectificationTime\(candidateResult\)/); + + const result = candidateSnapshot(); + assert.ok(result); + const view = buildRectificationTimeline({ + searchWindow: ["04:45", "05:15"], + credibleRange: result.credibleRange, + candidateTimes: ["05:08"], + stage: "minute", + workingTime: workingRectificationTime(result), + workingAdopted: Boolean(result.selectedTime), + }); + assert.equal(view?.workingLabel, "代表分钟 05:08"); + assert.equal(view?.rangeLabel, "05:07–05:09"); +}); + +test("an adopted minute is labelled adopted, and adoption is still not confirmation", () => { + const adopted = candidateSnapshot({ selectedTime: "05:08" }); + assert.ok(adopted); + assert.equal(workingRectificationTime(adopted), "05:08"); + assert.equal(workingMinuteLabel("05:08", true), "已采用 05:08"); + assert.equal(workingMinuteLabel("05:08", false), "代表分钟 05:08"); + // The bar states which minute is in use. It must never assert the birth + // minute is settled — that boundary lives in the delivery/adopt narration, + // and AGENTS.md Part B4 bans a confirmed candidate outright. + assert.doesNotMatch(bar, /已确认|确认的唯一|精确到分钟|confirmed/); + assert.doesNotMatch(scale, /已确认|确认的唯一/); +}); + +test("an unparseable or absent minute yields no label rather than a guess", () => { + assert.equal(workingMinuteLabel(null, false), null); + assert.equal(workingMinuteLabel(undefined, false), null); + assert.equal(workingMinuteLabel("", false), null); + assert.equal(workingMinuteLabel("25:99", false), null); + const view = buildRectificationTimeline({ + searchWindow: ["04:45", "05:15"], + credibleRange: ["05:07", "05:09"], + candidateTimes: [], + stage: "minute", + }); + assert.equal(view?.workingLabel, null); + assert.equal(view?.answeredLabel, null); +}); + +test("the answered count is read off the projection, never counted here", () => { + // `inference_state.answered_probe_count` is the server's own figure. + const withCount = candidateSnapshot({ + decisionReceipt: { inference_state: { answered_probe_count: 6 } }, + }); + assert.equal(withCount?.answeredProbeCount, 6); + assert.equal(answeredProbeLabel(6), "已答 6 题"); + + // No count on the wire means no readout. A zero invented here would be a + // number the projection never said. + const withoutCount = candidateSnapshot(); + assert.equal(withoutCount?.answeredProbeCount, null); + assert.equal(answeredProbeLabel(null), null); + assert.equal(answeredProbeLabel(0), null); + assert.equal(answeredProbeLabel(1.5), null); + + // The label echoes its argument. Nothing in its body counts, adds or walks a + // transcript to arrive at the number. + const body = scale.slice( + scale.indexOf("export function answeredProbeLabel"), + scale.indexOf("export function answeredProbeLabel") + 400, + ); + assert.match(body, /return `已答 \$\{count\} 题`;/); + assert.doesNotMatch(body, /\.length|\+\+|answered_probes|\.filter\(|\.reduce\(/); +}); + +test("the bar shows no narrowing progress, because no projection carries it", () => { + // Concession 5 of TASK-cend-surfaces-claude-alignment-20260916: without an + // "已从 N 分钟收到 M 分钟" field, the bar shows the range and its width and + // stops. The opening window is on the wire, but comparing it here would both + // break VOICE.md rule 2 and misreport: the search window *widens* (BUG-572), + // so the subtraction would announce progress through a widening. + assert.doesNotMatch(bar, /已从|收到|最初|进度/); + assert.doesNotMatch(scale, /已从|收到|最初|进度|openingRange|openingWidth/); + const view = buildRectificationTimeline({ + searchWindow: ["04:45", "05:15"], + credibleRange: ["05:07", "05:09"], + candidateTimes: ["05:08"], + stage: "minute", + workingTime: "05:08", + answeredProbeCount: 6, + }); + assert.equal(view?.widthLabel, "3 分钟"); + assert.deepEqual( + Object.keys(view ?? {}).filter((key) => /progress|opening|initial/i.test(key)), + [], + ); +}); + +test("the bar renders all four readings on one line and stays a fixed height", () => { + const html = renderToStaticMarkup(createElement(RectificationTimeline, { + view: buildRectificationTimeline({ + searchWindow: ["04:45", "05:15"], + credibleRange: ["05:07", "05:09"], + candidateTimes: ["05:08"], + stage: "minute", + workingTime: "05:08", + answeredProbeCount: 6, + }), + })); + assert.match(html, /05:07–05:09/); + assert.match(html, /3 分钟/); + assert.match(html, /代表分钟 05:08/); + assert.match(html, /已答 6 题/); + // All four are in the single readout paragraph, and the assistive label for + // the axis says the same things in the same order. + assert.match(html, /rectification-timeline__readout[\s\S]{0,400}已答 6 题<\/span><\/p>/); + assert.match(html, /aria-label="[^"]*当前区间 05:07–05:09[^"]*代表分钟 05:08[^"]*已答 6 题"/); + + // Height is correctness here: the bar sits outside the scroll container, so a + // taller bar would move content with nothing observing it. The row grew wider + // and must not be allowed to wrap. + const readout = styles.slice( + styles.indexOf(".rectification-timeline__readout {"), + styles.indexOf(".rectification-timeline__range {"), + ); + assert.match(readout, /flex-wrap: nowrap/); + assert.match(readout, /white-space: nowrap/); + assert.match(styles, /--rectification-timeline-height: 64px;/); + assert.match(styles, /--rectification-timeline-height: 56px;/); +}); + +test("nothing hides the bar on a compact viewport — that is the whole point", () => { + // On a narrow screen the board becomes an overlay that is closed by default, + // so this bar is the only place the range and the working minute appear. The + // chat column keeps its three grid rows in the compact cell. + assert.match( + styles, + /\.rectification-workspace\.is-compact \.rectification-workspace__chat,\s*\n\.rectification-workspace\.is-compact \.rectification-board-overlay \{\s*\n\s*grid-area: 1 \/ 1;/, + ); + // No rule anywhere hides or collapses the bar under `.is-compact`. + for (const match of styles.matchAll(/\.is-compact[^{}]*\.rectification-timeline[^{}]*\{([^}]*)\}/g)) { + assert.doesNotMatch(match[1], /display:\s*none|visibility:\s*hidden|height:\s*0/); + } + // Nor does the timeline hide itself at any width. + const barRule = styles.slice( + styles.indexOf(".rectification-timeline {"), + styles.indexOf(".rectification-timeline__readout"), + ); + assert.doesNotMatch(barRule, /display:\s*none|visibility:\s*hidden/); +}); + +test("the board folds to two sections and loses none of the five blocks", () => { + // Exactly two top-level disclosures. + assert.equal(board.match(/aria-label="候选分钟"/g)?.length, 1); + assert.equal(board.match(/aria-label="参数与口径"/g)?.length, 1); + + const minutes = board.slice( + board.indexOf("{hasMinuteDetail && ("), + board.indexOf("{hasParameterDetail && ("), + ); + const parameters = board.slice(board.indexOf("{hasParameterDetail && (")); + + // 候选分钟 carries the transition minutes and the secondary candidates. + assert.match(minutes, /换升时刻/); + assert.match(minutes, /次级候选/); + assert.match(minutes, /scoringMinutes\.map/); + assert.match(minutes, /displayMinutes\.map/); + + // 参数与口径 carries the rest: house table, the confirmation gate, the + // technique audit, the two-lagna split, the ledger with its dual-track + // agreement line, and the nakshatra note. + assert.match(parameters, / { + assert.match(board, /working \? 当前时间<\/span>/); + assert.match(board, /changed \? "is-changed" : ""/); + assert.match(board, /working \? "is-working" : ""/); + assert.match( + board, + /changedMinutes\.has\(row\.at\) \? "rectification-board__minute is-changed" : "rectification-board__minute"/, + ); +}); + +test("both sections stay closed by default and every summary is still reachable", () => { + // Keyboard access must be no worse than before: nesting adds a level, so each + // disclosure has to remain a real , never a div with a click handler. + assert.doesNotMatch(board, /]*\sopen[\s>]/); + assert.doesNotMatch(board, /role="button"|onClick=\{[^}]*toggle/); + const summaries = board.match(//g)?.length ?? 0; + assert.ok(summaries >= 6, `expected every block to keep its summary, saw ${summaries}`); +});