fix(web): 手机上时间轴读数不再被裁,「跳到最新」不再压住选项

375px 下读数第四项被 ellipsis 裁成「已…」、第五项整项不可见:compact
段仍加 44px 头像缩进(每侧 60px,可用内容只剩 255px),而 cfb41daf 又把
读数从四项变成五项,按 13px 逐字估宽需要 383px。compact 段改为只取
`space-4`、`column-gap` 收到 `space-2`、第五项 `已对照 N 件` 在 767px 以下
`display: none`(仍在 DOM 与轴 aria-label 里),四项约 292px / 可用 343px。
桌面段、条高 64/56px、滚动锚定逻辑均未动。

「跳到最新」浮层占 composer 上方 56px 带,而 `.message-list` 底部留白恰好
也是 56px,末条选项贴在浮层按钮正下方、正中间点不动。留白改为
`calc(clearance + space-3)`,静止时末条下方 84px = 56px 带 + 28px 空气;
`.conversation` 追加同高 `scroll-padding-block-end`。浮层位置、居中与可见
条件未动。

顺带记一条待产品裁决的口径冲突:DESIGN §10 禁列表写明「已对照 N 件经历」
不上条、仍归交付卡,`cfb41daf` 却把它加上且未改 DESIGN;本轮只在手机上把
它移出可见行,桌面保留原状。

BUG-918、BUG-919

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-17 00:53:21 +00:00
co-authored by Claude Fable 5.1
parent 5113d457b7
commit 662672c153
10 changed files with 422 additions and 10 deletions
+3
View File
@@ -9,6 +9,9 @@
如果你 2026-09-16 到 09-17 之间在校正里遇到过风格题,那一次的答案可能计错了分,不要采用当时的区间;补一件带年月的事触发重算,或新开一段校正。
Skill 版本不变。
## 2026-09-17 — 手机上时间轴读数不再被裁,「跳到最新」不再压住选项
窄屏(375 / 390 宽)打开生时校正时,上方时间轴那一行「范围 · 宽度 · 代表分钟 · 已答 N 题」四项完整显示,不再出现「已…」这种被裁一半的字。为腾出宽度,手机上去掉了这条与头像列对齐的 44px 缩进,并把「已对照 N 件」这项收回交付卡(桌面不变;读屏软件在两种宽度下读到的内容都一样)。「跳到最新」胶囊与对话末条之间留出空隙,不再压在选项卡最后一行上。Skill 版本不变。
## 2026-09-16 — 正文念出的判别题必有卡;性格描述不再自相矛盾
+32
View File
@@ -11938,3 +11938,35 @@
- 相关记录:BUG-915、BUG-675、BUG-674
- 复发自:无
- 修复版本:待发布
## BUG-918 | 校正时间轴读数在 375px 手机上被裁成「已…」
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`frontend/src/app/globals.css` `.rectification-timeline` / `.rectification-timeline__readout``@media (max-width: 767px)` 段、`frontend/src/components/rectification-timeline.tsx`
- 用户现象:iPhone 上常驻时间轴一行读作「04:48–05:07 20 分钟 代表分钟 04:53 已…」,第四项被省略号裁断,第五项完全不显示。
- 触发条件:视口宽 375 CSS pxiPhone SE 2/3、13 mini)打开生时校正,Case 已有代表分钟、已答题数与已对照事件数(读数五项齐全)。
- 根因:两条叠加。一是读数按「四项短文本」设计,`.rectification-timeline` 在 767px 以下仍取 `calc(var(--space-4) + var(--assistant-content-inset))`,即每侧 16 + 44 = 60px375px 的可用内容宽只剩 255px;条不在消息列里,这 44px 头像沟槽在手机上买不到任何可见对齐。二是 cfb41daf 又给读数追加了第五项 `已对照 N 件`,把四项预算变成五项。按 13px 逐字保守估宽,五项加四个 12px 间隙需要约 383px,超出 255px 约 128px,于是 `__answered` 命中自身的 `text-overflow: ellipsis` 显示为「已…」,`__dated` 被条的 `overflow: hidden` 整项裁掉。
- 修复:compact 段(`@media (max-width: 767px)`,与 `.is-compact` 的 768px 断点同源)内 `.rectification-timeline``padding-inline` 改为 `var(--space-4)` 单项;读数 `column-gap` 收到 `var(--space-2)``.rectification-timeline__dated` 在该段 `display: none`。桌面规则与条高(64 / 56px)一字未动,滚动锚定逻辑未动。组件仍无条件渲染全部五个 span,轴的 `aria-label` 仍由五项文本拼成,被隐藏的一项对辅助技术不丢失。
- 验证:`frontend/tests/rectification-mobile-timeline-readout-20260917.test.tsx` 6 条(渲染断言五项文本与 `aria-label` 一致;按保守逐字宽度断言 375 与 390 下四项 + 3×8px 间隙约 292px ≤ 343px / 358px,并断言旧口径 383px > 255pxcompact 段声明断言去 inset、收 gap、隐藏第五项、未放开 nowrap 与条高;桌面段断言仍带 inset 与 13px)。`frontend/tests/rectification-timeline-20260909.test.ts` 的移动端 `padding-inline` 断言同步改写并附原值 / 新值 / 原因。全套 `npm test` 3420 tests / 31 fail,失败清单与基线(3414 / 31,无 Docker 集)逐条相同;`tsc --noEmit` 0 错、`npm run lint` 0 error、`next build --webpack``/``○ Static`、首屏 gzip 620106 → 620128 字节(+0.004%)。
- 防复发:新增测试把「compact 段不得含 `--assistant-content-inset`」「第五项在 compact 下隐藏」与宽度预算写成断言;`globals.css` 与组件注释都改掉了原来「四项短文本在 375px 下还有余量」的错误说法,并写明再加一项必须重算预算而不是直接追加 span。
- 相关记录:BUG-478(跳到最新与滚动锚定归属)、BUG-919(同一次真机走查的另一条)。另记一条口径冲突待产品裁决:`frontend/DESIGN.md` §10 的禁列表明确写「已对照 N 件经历」不上条、仍归交付卡,而 cfb41daf 把它加到了条上且未改 DESIGN;本轮只在手机上把它移出可见行,桌面上是否也撤掉未擅自决定。
- 复发自:无
- 修复版本:待发布
## BUG-919 | 「跳到最新」浮层压住选项卡最后一个可点选项
- 状态:resolved
- 首次发现:2026-09-17
- 最近更新:2026-09-17
- 影响面:`frontend/src/app/globals.css` `.rectification-workspace__chat .conversation` / `.message-list` 的底部留白
- 用户现象:iPhone 上「跳到最新」胶囊浮在选项卡最后一行上,选项被遮掉一半,正中间不好点。
- 触发条件:校正对话滚到尾部附近、浮层可见(距底 > 96px)时,末条内容是一张多行选项卡。
- 根因:浮层 `.jump-to-latest``position: absolute; bottom: 100%` 挂在 composer 顶边上,占据 composer 上方 44px 触控高 + 自身 `space-3` = 56px 的带。而 `.message-list` 的底部留白恰好等于 `--rectification-jump-clearance`(同样 56px),与浮层带严丝合缝,末条内容顶到浮层正下方;容器 `pointer-events: none` 只放行 44px 按钮本体,按钮正好落在选项行中央,于是「遮一半 + 中间点不动」。
- 修复:`.message-list` 底部留白改为 `calc(var(--rectification-jump-clearance) + var(--space-3))``.conversation` 追加 `scroll-padding-block-end: var(--rectification-jump-clearance)`,让任何 scroll-into-view 也落在带之上。连同 `.conversation` 自身的 `space-4`,静止时末条下方共 84px(56px 浮层带 + 28px 空气)。浮层的位置、居中对齐与可见条件(BUG-478 的归属)一字未动,`--rectification-jump-clearance` 的取值不变(时间轴 56px 条高仍与它同源),`use-conversation-scroll-anchor` 未动。
- 验证:`frontend/tests/rectification-mobile-timeline-readout-20260917.test.tsx` 两条声明断言(留白表达式与 `scroll-padding-block-end`;浮层 `absolute` / `bottom: 100%` / `justify-content: center` / `pointer-events: none` / 44px 与 `conversationAnchorThreshold = 96` 未变)。全套门禁数字同 BUG-918。真机走查(375 / 390 宽)留在 `docs/testing/rectification-mobile-timeline-readout-20260917.md`:本机无 Chrome、无真机。
- 防复发:测试锁住「留白 = 浮层带 + space-3」这条表达式,任何把它改回等于浮层带的提交会红。CSS 注释写明了为什么不做到「任何滚动位置都不重叠」——那需要留白 ≥ `conversationAnchorThreshold`(96px) + 56px = 152px,在 667px 高的手机上等于凭空吃掉一整行选项,而浮层只在读者已经向上滚过该阈值后才出现。
- 相关记录:BUG-478(两个聊天面统一用 `useConversationScrollAnchor``JumpToLatestButton`,并把浮层改成居中)、7a4360d8(同一现象 2026-08-25 的旧修法是把胶囊右对齐,已被 BUG-478 的居中统一取代,本轮未推翻它)、BUG-918
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,94 @@
# 进度记录 · 校正页移动端:时间轴读数被裁、「跳到最新」压住选项(2026-09-17)
任务书:`docs/tasks/TASK-rectification-mobile-timeline-readout-20260917.md`
分支:`codex/rectification-mobile-timeline-readout-20260917`,基线 `origin/staging` = `c32f81e7`(任务书写的 `8d7dfbf0` 已被 staging 超车,按指令取 `c32f81e7`)。
BUG:918、919。纯前端样式,未跑 Python 预检(AGENTS §9 允许)。
未触碰(并行会话所有):`frontend/src/components/rectification-agentic-chat.tsx``frontend/src/lib/rectification-agentic/**`、Python、Skill。也未动时间轴固定高度(64 / 56px)与 `use-conversation-scroll-anchor`
## 1. 做了什么
| 任务 | 文件 | 改动 |
| --- | --- | --- |
| T1 BUG-918 | `frontend/src/app/globals.css` | `@media (max-width: 767px)` 段:`.rectification-timeline``padding-inline``calc(var(--space-4) + var(--assistant-content-inset))` 改为 `var(--space-4)``.rectification-timeline__readout { column-gap: var(--space-2) }``.rectification-timeline__dated { display: none }`。桌面段未动。同时改掉基线注释里「四项短文本在 375px 下还有余量」这句被实证推翻的话。 |
| T1 BUG-918 | `frontend/src/components/rectification-timeline.tsx` | 只加注释:五个 span 永远无条件渲染,由 CSS 决定窄屏显示几项;轴 `aria-label` 仍由五项拼成;再加第六项必须重算宽度预算。无行为改动。 |
| T2 BUG-919 | `frontend/src/app/globals.css` | `.rectification-workspace__chat .message-list` 底部留白由 `var(--rectification-jump-clearance)` 改为 `calc(var(--rectification-jump-clearance) + var(--space-3))``.conversation` 追加 `scroll-padding-block-end: var(--rectification-jump-clearance)``--rectification-jump-clearance` 取值未变(时间轴 56px 条高仍与它同源,既有断言不受影响);浮层的位置、居中、可见条件未动。 |
| T3 | `docs/BUG_HISTORY.md``CHANGELOG.md``frontend/DESIGN.md``docs/testing/…`、本文件 | 记录。 |
## 2. 宽度预算(T1 的算术)
jsdom / happy-dom 不在本套件里,`scrollWidth``clientWidth` 都量不到,所以这是**按逐字保守估宽的算术**,不是测量。每字宽度一律向上取整(数字取 0.6em,而 -apple-system / Helvetica 的 tabular 数字约 0.556em),font-size 13px。测试里 `readoutWidthPx` 就是这套系数。
单项估宽(px):`04:4805:07` 77.5、`20 分钟` 45.2、`代表分钟 04:53` 91.1、`已答 6 题` 54.1、`已对照 3 件` 67.1。
| | 改前 | 改后 |
| --- | --- | --- |
| compact 每侧内边距 | 16 + 44 = 60px | 16px |
| 375px 可用内容宽 | 255px | 343px |
| 可见项数 / 列间距 | 5 项 / 12px | 4 项 / 8px |
| 读数需要宽度 | 383.0px | 291.9px |
| 375px 余量 | **128.0px(被裁)** | **+51.1px** |
| 390px 余量 | 113.0px | +66.1px |
| 320px 余量 | 143.0px | 3.9px |
375 与 390 两个目标宽度都放得下。**320px 差约 4px**:按系统实际字宽(数字 0.556em)重算是 283.3px ≤ 288px,能放下;按上面的保守系数差一点点。真机 320px 若尾部被裁一点属已知边界,已写进 `docs/testing/` 清单第「已知边界」段,范围和代表分钟在任何情况下都不会被裁。
让步顺序:没有用到。任务书让步 1(只做去 inset + 缩 gap)算下来是 5 项 335.0 + 4×8 = 367.0px > 343px,仍然放不下,所以第五项必须离开可见行;采用了任务书 T1 里的「只保留一项」分支,而不是合并成「N 题 · N 件」——合并需要原始计数,而计数只以 `已答 6 题` / `已对照 3 件` 这种成品字符串到达组件,要么去改 `frontend/src/lib/rectification-timeline-scale.ts`(超出本单文件范围),要么在组件里正则抠数字(脆弱)。隐藏这一项另有 DESIGN 依据,见第 4 节。
## 3. 「跳到最新」的几何(T2
浮层占 composer 上方 56px 的带:按钮 44px + 浮层自身 `padding-bottom: var(--space-3)`。基线 `.message-list` 底部留白恰好也是 56px,与带严丝合缝,末条内容顶到按钮正下方;容器 `pointer-events: none` 只放行 44px 按钮本体,而按钮居中,正落在选项行中央 → 「遮一半 + 中间点不动」。
改后:`.message-list` 68px + `.conversation` 16px = 静止时末条下方 84px = 56px 带 + 28px 空气。
**做不到、也没有假装做到的部分**:把「任何滚动位置都不重叠」做成保证,需要留白 ≥ `conversationAnchorThreshold`(96px) + 56px = 152px。推导:设末条内容底为 E、尾部留白 P、距底 d,浮层带覆盖内容坐标 `[E+P-d-56, E+P-d]`,与真实内容重叠 ⟺ `d > P - 56`;而浮层可见 ⟺ `d > 96`。所以要让「浮层刚出现的那一刻」不压内容就得 `P - 56 ≥ 96`。152px 在 667px 高的手机上等于凭空吃掉一整行选项,且浮层只在读者已经主动向上滚过 96px 后才出现,所以按 CSS 注释记录理由后不做。本轮保证的是:**贴底静止时(浮层隐藏)末条选项整行可点且与输入框有 28px 空隙**;浮层可见时它浮在已滚上去的内容上,与两个聊天面一致,点一下即回到底。
未改浮层可见条件(任务书要求),也未把胶囊改回右对齐——`tests/rectification-agentic-entry.test.ts` 锁着 `justify-content: center`(BUG-478 的统一决策),右对齐是 2026-08-25 `7a4360d8` 的旧修法,已被取代,不擅自推翻。
## 4. 顺带发现的口径冲突(未擅自裁决,需产品拍板)
`frontend/DESIGN.md` §10 的「以下一律不得加回来」表里明确写着「已对照 N 件经历」不上条、仍归交付卡,理由是它和「已答 N 题」不是同一个量。但 `cfb41daf`2026-09-16BUG-740743)把 `datedEventLabel` 加到了读数上,且没有改 DESIGN。也就是说这第五项从进条那天起就与 DESIGN 的红线矛盾,而它正是压垮 375px 宽度预算的那一项。
本轮**只在 767px 以下把它移出可见行**(任务书授权范围内),桌面保留原状,并把这处冲突写进 BUG-918 的「相关记录」、DESIGN §10 和真机清单,等产品决定桌面是否也撤掉。没有单方面改桌面。
## 5. 测试
新增 `frontend/tests/rectification-mobile-timeline-readout-20260917.test.tsx`6 条):
1. 渲染断言:五项文本按序全在 DOM,且轴 `aria-label` 含全部五项。
2. 宽度预算断言:compact 可见四项 + 3×8px ≤ 343px375)与 ≤ 358px390);并反向断言旧口径 383px > 255px,把 Bug 的形状钉住。
3. compact 段声明断言:`padding-inline: var(--space-4)` 且段内不含 `--assistant-content-inset``column-gap: var(--space-2)``__dated` `display: none`;段内不得出现 `__range` / `__width` / `__working`(这两个数字任何宽度都不许隐藏或缩);不得放开 `flex-wrap` / `white-space`;条高仍 56px。段的切取是按大括号配平取出那一个 `@media` 块,不是 `slice` 到文件尾。
4. 桌面段断言:仍是 `calc(var(--space-8) + var(--assistant-content-inset))`,读数仍 `nowrap` + 13px。
5. T2 断言:留白表达式、`scroll-padding-block-end`
6. 浮层未动断言:`absolute` / `bottom: 100%` / `justify-content: center` / `pointer-events: none` / 按钮 44px,以及 `conversationAnchorThreshold = 96` 与 anchor hook 里不出现 scroll-padding。
文件头写明了「本套件没有 DOM 实现,所以几何断言是算术不是测量,真机走查必做」。
改写的既有断言 1 条 —— `frontend/tests/rectification-timeline-20260909.test.ts`「the mobile bar stays within the 44px touch rhythm」:
- 原值:`assert.match(mobile, /padding-inline: calc\(var\(--space-4\) \+ var\(--assistant-content-inset\)\);/)`
- 新值:`assert.match(mobile, /padding-inline: var\(--space-4\);/)`
- 原因:BUG-918。这条断言锁的正是造成裁切的那 44px 头像沟槽。条不在消息列里,手机上对齐头像列没有可见收益;同一测试里桌面那条 inset 断言未动,`--rectification-jump-clearance` 断言未动。
没有弱化其它任何断言,没有删测试。
## 6. 门禁数字
| 项 | 基线(`c32f81e7` | 改后 |
| --- | --- | --- |
| `./node_modules/.bin/tsc --noEmit` | — | **0 错** |
| `npm run lint` | — | **0 error**116 warning,与基线同为既有 `no-unused-vars`,未顺手修) |
| `npm test` | 3414 tests / 31 fail | **3420 tests / 31 fail**+6 新测试全绿) |
| 失败清单 | 31 条无 Docker 集 | **逐条相同**(排序后 `diff` 无输出) |
| `next build --webpack` | exit 0 | **exit 0** |
| `/` 路由 | `○ Static` | **`○ Static`** |
| 首屏 gzip | 620,106 B / 25 文件 | **620,128 B / 25 文件(+22 B, +0.004%** |
- 失败清单 31 条全部是既有无 Docker / 无凭据集(database-*、staging sync、billing、ephemeris 401 等),与 `BLOCKED.md` 一致。
- gzip 口径:把 `.next/server/app/index.html` 里引用到的每个 `/_next/static/*.js|css` 单独 `gzip -9` 后求和。唯一变化是首屏 CSS 分块 36,510 → 36,532 B。
- Turbopack 拒绝软链 `node_modules`,按环境说明用 `--webpack`
## 7. 环境缺口
- **无 Chrome、无真机**:375 / 390 宽下「读数四项完整」「胶囊不压选项」只能人工确认,清单在 `docs/testing/rectification-mobile-timeline-readout-20260917.md`(7 条 + 已知边界)。第 2 节的宽度是算术,不是真机测量;第 3 节的 28px 空隙同样没有真机截图。
- **未部署**:本分支只推自己的分支,未推 staging,`deployment.gitCommit` 未核对。
+1 -1
View File
@@ -104,7 +104,7 @@
| `TASK-rectification-year-focus-overlay-20260916.md` | `PROGRESS-rectification-year-focus-overlay-20260916.md` | 真机:定向健康题选 A 后打字给年月,助手追问「是你本人做的吗」,追问句下面挂着录入卡(`set-focus` 同号覆盖年月焦点题干、`event_date_entry` 仍在,BUG-908);事件入账后年月焦点没关(BUG-909,回放定 resolve 还是计划层);答短句撞「本轮达到步骤上限」(BUG-910,host 前置接无年月短句,先 investigating);persisted 问题块无 `--assistant-content-inset`(BUG-911)。**产品追加拍板:「哪一类事 + 发生年月」录入卡整个删掉,年月阶段直接打字(推翻 precision-gate 单 T3 与 SKILL.md:84**。主体默认本人不追问,Skill 10.0.29 | 已验收 | `6aabbe38`BUG-908911Skill 10.0.29):tsc 0 / lint 0 error / npm test 3414 条 31 红与基线 05cf20fb 逐条相同 / `/` Static / 首屏 gzip 620,502→620,107(−0.06%/ 快速门 Python 段 798 绿。两条小项(host 前置排在会话校验之前、`answer.host_year_entry` 未登记)并入 dead-d9 修复单 T2 |
| `TASK-rectification-dead-d9-choice-20260916.md` | `PROGRESS-rectification-dead-d9-choice-20260916.md` | 真机(6aabbe38):删卡流程已通,但**P0** D9 感情题没有自己的探针,`stampChoiceSchemaWithProbe` 退到最高增益探针,盖上了 **D24 学业六星座对照探针**BUG-375 (1) 复发):答「没有」按 D24 计分,04:48–04:59 收窄不可信;GET 投影又匹配不上 → 无卡(BUG-912);打字答「没有」后平局直接出交付卡待回放定性(BUG-913);`NAKSHATRA_TRAITS` 整对塞进一个 option,卡头把相反两极列成共同点(BUG-914)。**串行在 year-focus-overlay 验收之后** | **验收未通过(P0** | `227a7571`+`8d7dfbf0`:不借探针、一列一句、BUG-913 定性均过;但自建探针只在盖戳时注入内存 state,生产答题路径读持久化 state → `stale_probe` 报错、GET 无卡、idle 立刻 superseded(生产路径回放实证)。修复单见下一行 |
| `TASK-rectification-dead-d9-choice-fix-20260916.md` | `PROGRESS-rectification-dead-d9-choice-fix-20260916.md` | **验收修复单**:自建分盘探针照 `withNakshatraBoundaryProbe` 模式在每个读 state 的地方(盖戳 / 答题 / `previousInferenceFromReceipt` / GET 投影 / idle 过期判定 / 计划承接)从 receipt 确定性重建(BUG-915);测试改走生产路径、删手工塞探针的 fixture;顺带 host 前置挪到会话校验之后、登记 phase(BUG-916);死卡不得配 `collect_waiting` 占位(BUG-917);**产品决策 1c**:风格题默认不作全额计分(沿用 09-09 ±1 拍板),无事件探针就走下一条线。**staging 现状:风格题答不了,先跳过**。§1d 已记 **产品决策 (b)** | 已实现待验收 | `codex/rectification-dead-d9-choice-fix-20260916`BUG-915917Skill 未 bump):`withOwnedDistinguishProbes` 五处重建(盖戳 / 点选答题 / 打字答题 / GET 投影 / idle 过期;未在 `previousInferenceFromReceipt` 出口统一注入——会把自建探针喂进 contrastPacket 与 eventProbes,按让步 1 逐点包装 + 源码契约测试);按 (b) 删掉风格计分题入口,分层判别题只在有带年份事件探针时出题;死卡改走 `unavailable` 修复出口。tsc 0 / lint 0 error 116 warning / npm test 3421 条 31 红与基线 `c32f81e7` 逐条相同 / `/` `○ Static` / 产物 JS gzip 1,496,339→1,496,408+0.005%/ pytest 63 绿、快速门 Python 段 798 绿 |
| `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 待领取 | — |
| `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 已实现待验收 | `codex/rectification-mobile-timeline-readout-20260917`:纯 CSS + 注释 + 测试。T1 compact 段去掉 44px 头像缩进、`column-gap` 收到 `space-2`、第五项 `已对照 N 件` 在 767px 以下 `display: none`(仍在 DOM 与轴 `aria-label` 里)——**让步 1 算下来仍放不下**(去 inset + 缩 gap 后 5 项需 367px375px 只有 343px 可用),故走 T1 的「只保留一项」分支;桌面段、条高 64/56px、滚动锚定全未动。T2 `.message-list` 底部留白改 `calc(clearance + space-3)`(静止时 84px = 56px 浮层带 + 28px 空气)+ `.conversation` `scroll-padding-block-end`;浮层位置/居中/可见条件未动。tsc 0 / lint 0 error / `npm test` 3420 条 31 红与基线 `c32f81e7`3414 / 31)逐条相同 / `/``○ Static` / 首屏 gzip 620,106 → 620,128 B+0.004%)。**顺带发现口径冲突待产品拍板**:DESIGN §10 禁列表写明「已对照 N 件经历」不上条,`cfb41daf` 却把它加上了且没改 DESIGN,本轮只在手机上移出可见行。无 Chrome / 无真机,375 与 390 宽的读数完整性与浮层不遮选项留 `docs/testing/rectification-mobile-timeline-readout-20260917.md`7 条 + 320px 已知边界) |
### 聊天主链路与首页
@@ -0,0 +1,18 @@
# 真机清单 · 校正页移动端时间轴读数与「跳到最新」(2026-09-17)
在 staging 用受控账号、真机(不是桌面浏览器缩窗)走一遍生时校正。本机无 Chrome、无真机,以下全部做不了自动化,只能人工确认。不要把真实出生资料写进任何记录。
优先 375 CSS pxiPhone SE 2/3、13 mini),再复查 390iPhone 12–16 基础款)。若手上只有 320 宽的老机(SE 1 代),第 2 条按「可接受被裁一点」看,见末尾说明。
1. **读数四项完整。** 进入校正、答到出现代表分钟之后,看上方那条时间轴第一行。应完整读出四项:`HH:MMHH:MM``N 分钟``代表分钟 HH:MM`(或 `已采用 HH:MM`)、`已答 N 题`。**任何一项都不得出现省略号或半个字**,尤其不得再看到「已…」。
2. **读数不换行、条不变高。** 这一行必须是一行。条的高度在整个流程里不得变化(56px),末尾不得出现第二行或把下面的刻度挤出去。横屏、放大字号(iOS 设置 → 显示与亮度 → 文字大小最大档)各看一次:这两种情况下允许尾部被裁,但**范围和代表分钟必须仍然完整可见**。
3. **左边距与页面同源。** 读数和刻度的左起点应与页面左边距一致(约 16px),不再比正文多缩进一个头像位。桌面(宽 ≥ 768px)打开同一条校正,读数应仍然与助手正文左边线对齐,并且多出第五项「已对照 N 件」——手机上没有这一项是预期的。
4. **读屏软件仍读到五项。** 打开 VoiceOver,聚焦到时间轴的轴本体(`role="img"`),朗读内容应包含范围、宽度、代表分钟、已答题数**和已对照件数**,即手机上看不见的那一项在朗读里仍然存在。
5. **「跳到最新」不压选项。** 出现一张多行选项卡(判别题 A/B/C/D 或交付卡三列)之后,向上轻扫一段让「跳到最新」胶囊出现,再滚回底部。滚到底时:胶囊应消失,选项卡最后一行与输入框之间应有明显空隙(约 28px),最后一个选项**整行可点,中间也能点中**。
6. **胶囊出现时仍能操作。** 胶囊可见(已向上滚过一段)时点它,应直接回到最新内容,且回到底之后立刻能点最后一个选项。胶囊本身仍是居中的胶囊、44px 高、有阴影——位置和出现条件本轮没有改,不应有变化。
7. **回归面:普通对话不受影响。** 打开一段普通咨询对话(不是校正),向上滚出「跳到最新」,确认它的位置、外观和底部留白与改动前一致(本轮只改了校正面的留白)。
## 已知边界
- **320px 宽(iPhone SE 1 代 / iPhone 5s 一档)**:按逐字保守估宽,四项 + 间隙约 292px,可用约 288px,差约 4px。按系统实际字宽算是放得下的,但如果真机上「已答 N 题」尾部被裁一点,属已知边界而非回归——范围和代表分钟仍必须完整。看到就记进进度记录,不要当新 Bug 开单。
- **「已对照 N 件」是否也该从桌面撤掉**:`frontend/DESIGN.md` §10 的禁列表说这一项归交付卡、不上条,而代码后来把它加到了条上。本轮只在手机上把它移出可见行,桌面保留原状,等产品裁决。见 `docs/BUG_HISTORY.md` BUG-918。
+7 -3
View File
@@ -226,7 +226,7 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
### Jump to latest
- **Structure:** one pill button (`JumpToLatestButton`) with a down arrow and the label “跳到最新”, shared by both chat surfaces.
- **Placement:** hangs off the composer wrap's top edge (`.jump-to-latest`, absolute, `bottom: 100%`) so it never reflows the transcript or the composer.
- **Placement:** hangs off the composer wrap's top edge (`.jump-to-latest`, absolute, `bottom: 100%`) so it never reflows the transcript or the composer. It occupies a 56px band above the composer — the 44px target plus the `space-3` the overlay holds under itself — and the transcript has to reserve that band **plus** one more `space-3` below its last item, or the last tappable row reads as covered and its centre is where the chip's only `pointer-events: auto` area sits (BUG-919). On the rectification surface that reservation is `.message-list { padding-bottom: calc(var(--rectification-jump-clearance) + var(--space-3)) }`, and `.conversation` carries the matching `scroll-padding-block-end`. Full non-overlap at every scroll position would cost `conversationAnchorThreshold` (96px) on top of the band, i.e. 152px of trailing void; that was rejected — the chip only appears once the reader has scrolled up past that threshold.
- **Visibility:** shown when the reader is not anchored — more than 96px from the bottom after scrolling up — and hidden again once they return or press it. Sending a question re-anchors.
- **Surface:** canvas fill, hairline border, `--shadow-elevated`; hover uses the warm card surface. No utility-class shadows.
- **Accessibility:** a real button in document order with a visible label matching its accessible name, 44px target, and the focus ring; the icon is decorative.
@@ -635,14 +635,18 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
**二、条高固定,从首次渲染起就占位。** 这是正确性要求,不是视觉偏好:条在滚动容器外,高度一变就改 `clientHeight`,而没有任何观察者会因此重新跟随——贴底的读者会看着内容悄悄滑出视野底部且无法恢复。同理,Case 未就绪时渲染**等高的空骨架**(`data-state="pending"`),不得用 spinner、不得用「正在加载」文案、不得让条晚一步挂载。桌面 64px,767px 以下 56px56px 等于既有的 `--rectification-jump-clearance`44px 触控尺寸 + `space-3`),不新造常数。
读数和轴的水平内边距对齐助手列:桌面 `space-8 + --assistant-content-inset`767px 以下 `space-4 + --assistant-content-inset``.message-list` 沟槽和头像让位相同。底边发丝线和背景仍拉满聊天列,不把条做成第二条滚动容器。
读数和轴的水平内边距在桌面对齐助手列:`space-8 + --assistant-content-inset`,与 `.message-list` 沟槽和头像让位相同。**767px 以下只取 `space-4`,不加头像缩进**(BUG-918):条不在消息列里,手机上对齐头像列买不到任何可见收益,而那 44px 每侧要吃掉 375px 里的 88px,读数因此被裁。底边发丝线和背景仍拉满聊天列,不把条做成第二条滚动容器。
**三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与一行读数。读数四项、一行排完:
**三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与一行读数。手机上读数四项、一行排完:
`05:0705:09 · 3 分钟 · 代表分钟 05:08 · 已答 6 题`
**读数只增宽,不增高。** 条高仍是硬规定二的 64 / 56px,`.rectification-timeline__readout` 因此是 `flex-wrap: nowrap` + `white-space: nowrap`;真放不下时由条本身的 `overflow: hidden` 裁掉尾部,四项里「已答 N 题」排最后、最先被裁——它是背景信息,区间和代表分钟不是。
**一行是有宽度预算的,767px 以下必须按 375px 算,不是「应该放得下」。** BUG-918 的实证:13px 下四项 + 头像缩进已超 375px,第四项被自身 `text-overflow: ellipsis` 显示成「已…」。因此 compact 段(`@media (max-width: 767px)`,与 `.is-compact` 的 768px 断点同源)做三件事:`padding-inline` 去掉头像缩进、读数 `column-gap` 收到 `space-2``.rectification-timeline__dated` `display: none`。按逐字保守估宽,手机上四项 + 3×8px 间隙约 292px375px 可用 343px、390px 可用 358px320px 只余约 4px,靠条自身 `overflow: hidden` 兜底。**再往条上加一项,先重算这笔预算,不是直接追加一个 span。** 组件永远无条件渲染全部 span,隐藏只在 CSS 里发生,轴的 `aria-label` 仍由全部标签拼成,读屏软件不因视口宽度丢内容。
**「已对照 N 件」在手机上不在可见行里,桌面上目前仍在——这是一处未裁决的口径冲突。** 下面的禁列表写明这一项归交付卡、不上条,而它后来(cfb41daf)被加到了条上且没有改本文件。BUG-918 只把它在手机上移出可见行;桌面是否也撤掉需要产品裁决,见 `docs/BUG_HISTORY.md` BUG-918。
**代表分钟上条是 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`
+52 -5
View File
@@ -2538,9 +2538,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
/* 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. */
a box nothing is observing. The bar's own `overflow: hidden` clips rather
than reflows if the row ever does not fit.
This used to claim "four short items clear 375px with room to spare". They
did not even at four, and a fifth was added later (BUG-918). At 13px, with
the avatar-column inset, a 375px phone left 255px of content box: the four
items needed about 304px, the five about 383px. So `已答 N ` came through
as `` and `已对照 N ` never appeared at all. The compact block below is
what makes the row fit it drops the inset and the fifth item there, for
about 292px against 343px. Anything added here has to be re-measured against
that budget, not assumed to fit. */
.rectification-timeline__readout {
margin: 0;
display: flex;
@@ -2656,17 +2664,56 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.rectification-workspace__chat {
--rectification-timeline-height: 56px;
}
/* No `--assistant-content-inset` here (BUG-918). The bar is not in the
message list, so on a phone the 44px avatar gutter buys no alignment the
reader can see; it cost 88px of the 375px the readout has to fit into and
the row got clipped instead. The page margin is the honest edge on a
narrow viewport, and the axis reads wider for it. */
.rectification-timeline {
padding-inline: calc(var(--space-4) + var(--assistant-content-inset));
padding-inline: var(--space-4);
}
/* Tighter column rhythm buys another 12px across three gaps; the row is a
list of short figures, not prose, so space-2 still separates them. */
.rectification-timeline__readout {
column-gap: var(--space-2);
}
/* `已对照 N ` is the one item DESIGN.md §10 never wanted on the bar it
belongs to the delivery card, and it is a different quantity from
`已答 N ` (events matched, not questions answered). It is the fifth item
in a row budgeted for four, so on a phone it leaves the visual row. It
stays in the DOM and in the axis `aria-label`, so assistive tech still
reads it, and the range plus the working minute the two figures the
product requires to be visible at all times never shorten. */
.rectification-timeline__dated {
display: none;
}
}
/* `--rectification-jump-clearance` is the band the floating 跳到最新 chip
occupies above the composer: its 44px touch target plus the `space-3` the
overlay holds under itself. The transcript reserves that band **plus another
`space-3`** (BUG-919). Reserving exactly the band left the last tappable
option flush against the chip, so a choice card's final row read as covered
and its centre the only part of the row the chip's `pointer-events: auto`
button actually intercepts was hard to hit.
With `.conversation`'s own `space-4` the tail now keeps 84px of clear space:
56px of chip band and 28px of air. Eliminating overlap in *every* scroll
position would need `conversationAnchorThreshold` (96px) on top of the band,
i.e. 152px of trailing void under the last message at rest a whole option
row of dead space on a 667px phone, and the chip only shows once the reader
has scrolled up past that threshold anyway. The chip's own placement and
visibility rule are unchanged (BUG-478 owns them). */
.rectification-workspace__chat .conversation {
--rectification-jump-clearance: calc(44px + var(--space-3));
padding-bottom: var(--space-4);
/* Anything scrolled into view lands above the chip band too. This is inert
for `use-conversation-scroll-anchor`, which assigns `scrollTop` directly
rather than calling a scroll-into-view API. */
scroll-padding-block-end: var(--rectification-jump-clearance);
}
.rectification-workspace__chat .message-list {
width: 100%;
padding-bottom: var(--rectification-jump-clearance);
padding-bottom: calc(var(--rectification-jump-clearance) + var(--space-3));
}
.conversation.is-rectification .message-actions {
margin-inline-start: var(--assistant-content-inset);
@@ -24,6 +24,13 @@
* 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.
*
* One line has a width budget, and on a 375px phone it is spent by the fourth
* item (BUG-918). Every span below is always rendered; globals.css decides how
* many of them a narrow viewport shows, and it hides `__dated` there. The axis
* `aria-label` below is assembled from all five labels regardless, so nothing a
* screen reader gets depends on the viewport. Adding a sixth item means
* re-measuring that budget, not appending a span.
*/
"use client";
@@ -46,6 +53,8 @@ export function RectificationTimeline({ view }: { view: RectificationTimelineVie
{view.answeredLabel ? (
<span className="rectification-timeline__answered">{view.answeredLabel}</span>
) : null}
{/* Hidden below 768px by globals.css, never omitted here: the label is
part of the accessible axis description either way. */}
{view.datedEventLabel ? (
<span className="rectification-timeline__dated">{view.datedEventLabel}</span>
) : null}
@@ -0,0 +1,200 @@
/**
* 2026-09-17, iPhone on staging: the rectification timeline readout came through
* as `04:4805:07 20 分钟 代表分钟 04:53 已…` (BUG-918), and the floating
* chip sat on top of the last tappable option of a choice card
* (BUG-919).
*
* What this file can and cannot check, stated plainly: there is no jsdom or
* happy-dom in this suite, so `renderToStaticMarkup` is the only render path and
* `scrollWidth` / `clientWidth` do not exist to compare. Every geometric claim
* below is therefore **arithmetic over the rendered text**, using deliberately
* generous per-glyph widths (see `readoutWidthPx`), plus CSS declaration
* assertions of the kind `chart-page-view.test.tsx` uses. A real-device pass at
* 375 and 390 CSS px is still required and lives in
* `docs/testing/rectification-mobile-timeline-readout-20260917.md`.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import test from "node:test";
import { RectificationTimeline } from "../src/components/rectification-timeline.tsx";
import { buildRectificationTimeline } from "../src/lib/rectification-timeline-scale.ts";
Object.assign(globalThis, { React });
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
/** Comments carry prose that would otherwise satisfy declaration assertions. */
const declarations = styles.replace(/\/\*[\s\S]*?\*\//g, " ");
/** The `@media (max-width: 767px)` block that owns the timeline, and only it. */
function compactTimelineBlock(source: string): string {
const start = source.indexOf("@media (max-width: 767px) {\n .rectification-workspace__chat {\n --rectification-timeline-height: 56px;");
assert.notEqual(start, -1, "compact timeline media block not found");
let depth = 0;
for (let at = start; at < source.length; at += 1) {
if (source[at] === "{") depth += 1;
else if (source[at] === "}") {
depth -= 1;
if (depth === 0) return source.slice(start, at + 1);
}
}
assert.fail("compact timeline media block is unbalanced");
}
function ruleBody(source: string, selector: string): string {
const at = source.indexOf(`${selector} {`);
assert.notEqual(at, -1, `missing rule: ${selector}`);
const end = source.indexOf("}", at);
assert.notEqual(end, -1, `unterminated rule: ${selector}`);
return source.slice(at + selector.length + 2, end);
}
/** The readout's five spans in document order, text only. */
function readoutItems(markup: string): string[] {
const readout = markup.match(/<p class="rectification-timeline__readout">([\s\S]*?)<\/p>/);
assert.ok(readout, "readout paragraph not rendered");
return [...readout[1].matchAll(/<span class="rectification-timeline__[a-z-]+">([^<]*)<\/span>/g)]
.map((match) => match[1]);
}
/**
* Upper bound on a readout item's rendered width at 13px, the size the bar is
* pinned to. Every figure here is rounded **up** from the system sans stacks the
* app ships (digits measure about 0.556em in -apple-system / Helvetica and the
* readout asks for tabular-nums, so 0.6em is slack, not a guess). An assertion
* that passes against these numbers passes against the real font.
*/
const GLYPH_EM: Readonly<Record<string, number>> = {
digit: 0.6,
colon: 0.33,
space: 0.28,
dash: 0.5,
cjk: 1,
other: 0.6,
};
function readoutWidthPx(text: string, fontSizePx = 13): number {
let em = 0;
for (const character of text) {
if (/\d/.test(character)) em += GLYPH_EM.digit;
else if (character === ":") em += GLYPH_EM.colon;
else if (character === " ") em += GLYPH_EM.space;
else if (character === "" || character === "-") em += GLYPH_EM.dash;
else if (/[ -鿿豈-﫿]/.test(character)) em += GLYPH_EM.cjk;
else em += GLYPH_EM.other;
}
return em * fontSizePx;
}
/** The worst realistic readout: every optional item present, widest labels. */
const fullView = buildRectificationTimeline({
searchWindow: ["04:30", "05:30"],
credibleRange: ["04:48", "05:07"],
candidateTimes: ["04:48", "04:53", "05:07"],
stage: "minute",
workingTime: "04:53",
answeredProbeCount: 6,
datedEventCount: 3,
});
test("the timeline renders every readout figure the projection carries", () => {
assert.ok(fullView, "the fixture window has to produce a view");
const items = readoutItems(renderToStaticMarkup(<RectificationTimeline view={fullView} />));
assert.deepEqual(items, ["04:4805:07", "20 分钟", "代表分钟 04:53", "已答 6 题", "已对照 3 件"]);
// BUG-918's fix is CSS, not omission: the compact viewport hides one item, it
// never drops it from the markup, so the accessible axis description below
// reads the same on every viewport.
const markup = renderToStaticMarkup(<RectificationTimeline view={fullView} />);
const axisLabel = markup.match(/aria-label="([^"]*)"/)?.[1] ?? "";
for (const item of items) {
assert.ok(axisLabel.includes(item), `axis label is missing ${item}`);
}
});
test("the four items a 375px phone shows fit the compact content box", () => {
assert.ok(fullView);
const items = readoutItems(renderToStaticMarkup(<RectificationTimeline view={fullView} />));
// `.rectification-timeline__dated` is display:none below 768px, so the visible
// row is the first four.
const visible = items.slice(0, 4);
const columnGapPx = 8; // --space-2, the compact column-gap
const required = visible.reduce((total, item) => total + readoutWidthPx(item), 0)
+ columnGapPx * (visible.length - 1);
// 375 CSS px (iPhone SE 2/3, 13 mini) minus the compact padding-inline,
// var(--space-4) on each side. The 44px avatar inset that used to be added
// here is what pushed the row over (BUG-918).
const available375 = 375 - 2 * 16;
assert.ok(
required <= available375,
`readout needs ${required.toFixed(0)}px, ${available375}px available at 375px`,
);
// 390 CSS px (iPhone 1216 base) has to hold too.
assert.ok(required <= 390 - 2 * 16);
// And the shape of the old bug: with the inset and all five items it did not
// fit, which is why the fourth item arrived as 「已…」.
const oldRequired = items.reduce((total, item) => total + readoutWidthPx(item), 0) + 12 * (items.length - 1);
assert.ok(oldRequired > 375 - 2 * (16 + 44));
});
test("the compact timeline drops the avatar inset and the fifth figure", () => {
const compact = compactTimelineBlock(declarations);
// Page margin only — no --assistant-content-inset on a phone.
assert.match(ruleBody(compact, ".rectification-timeline"), /padding-inline: var\(--space-4\);/);
assert.doesNotMatch(ruleBody(compact, ".rectification-timeline"), /--assistant-content-inset/);
assert.match(ruleBody(compact, ".rectification-timeline__readout"), /column-gap: var\(--space-2\);/);
assert.match(ruleBody(compact, ".rectification-timeline__dated"), /display: none;/);
// The range and the working minute are the two figures the product requires
// to stay visible at every width, so neither may be hidden or shrunk here.
assert.doesNotMatch(compact, /\.rectification-timeline__(range|width|working)\b/);
// Still one line, still a fixed-height bar: the compact block must not undo
// either, or the bar would resize outside anything observing it.
assert.doesNotMatch(compact, /flex-wrap: wrap|white-space: normal/);
assert.match(compact, /--rectification-timeline-height: 56px;/);
});
test("the desktop readout keeps all five figures and the assistant-column inset", () => {
const desktop = ruleBody(declarations, ".rectification-timeline");
assert.match(desktop, /padding: 0 calc\(var\(--space-8\) \+ var\(--assistant-content-inset\)\);/);
assert.match(declarations, /\.rectification-timeline__readout \{[^}]*white-space: nowrap;/);
assert.match(declarations, /\.rectification-timeline__readout \{[^}]*flex-wrap: nowrap;/);
assert.match(declarations, /\.rectification-timeline__readout \{[^}]*font-size: 13px;/);
});
test("the transcript reserves the jump-to-latest band plus air below it", () => {
// BUG-919: reserving exactly the chip band left the last option flush under
// the chip. The band is the chip's 44px target plus the space-3 it holds
// under itself; the list now reserves that plus one more space-3.
assert.match(
declarations,
/\.rectification-workspace__chat \.conversation \{[^}]*--rectification-jump-clearance: calc\(44px \+ var\(--space-3\)\);/,
);
assert.match(
declarations,
/\.rectification-workspace__chat \.message-list \{[^}]*padding-bottom: calc\(var\(--rectification-jump-clearance\) \+ var\(--space-3\)\);/,
);
// Anything scrolled into view lands above the band too.
assert.match(
declarations,
/\.rectification-workspace__chat \.conversation \{[^}]*scroll-padding-block-end: var\(--rectification-jump-clearance\);/,
);
});
test("the jump-to-latest overlay itself is untouched by the clearance change", () => {
// Its placement and visibility rule belong to BUG-478; this round only widened
// the transcript's reservation.
assert.match(declarations, /\.jump-to-latest \{[^}]*position: absolute;/);
assert.match(declarations, /\.jump-to-latest \{[^}]*bottom: 100%;/);
assert.match(declarations, /\.jump-to-latest \{[^}]*justify-content: center;/);
assert.match(declarations, /\.jump-to-latest \{[^}]*padding-bottom: var\(--space-3\);/);
assert.match(declarations, /\.jump-to-latest \{[^}]*pointer-events: none;/);
assert.match(declarations, /\.jump-to-latest__button \{[^}]*min-height: 44px;/);
// No second scroll-follow and no reflowing layout for the chip.
const anchor = readFileSync(new URL("../src/hooks/use-conversation-scroll-anchor.ts", import.meta.url), "utf8");
assert.doesNotMatch(anchor, /scroll-padding|rectification-jump-clearance/);
assert.match(anchor, /export const conversationAnchorThreshold = 96;/);
});
@@ -418,7 +418,12 @@ test("the chat grid has three rows and the timeline row is a fixed height", () =
test("the mobile bar stays within the 44px touch rhythm", () => {
const mobile = styles.slice(styles.indexOf("@media (max-width: 767px) {\n .rectification-workspace__chat {"));
assert.match(mobile, /--rectification-timeline-height: 56px;/);
assert.match(mobile, /padding-inline: calc\(var\(--space-4\) \+ var\(--assistant-content-inset\)\);/);
// 原值: padding-inline: calc(var(--space-4) + var(--assistant-content-inset));
// 新值: padding-inline: var(--space-4);
// 原因: BUG-918。真机 375px 下这 44px 头像沟槽让读数放不下,第四项被
// text-overflow 裁成「已…」。条不在消息列里,手机上对齐头像列没有
// 可见收益,页面边距才是诚实的边界。桌面那条规则不变(上一条测试)。
assert.match(mobile, /padding-inline: var\(--space-4\);/);
// 56px is the existing jump-clearance constant, not a new number.
assert.match(styles, /--rectification-jump-clearance: calc\(44px \+ var\(--space-3\)\);/);
});