From 662672c1531159e3e5b06f96fec4751113382992 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 17 Sep 2026 00:32:23 +0000 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E6=89=8B=E6=9C=BA=E4=B8=8A?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E8=BD=B4=E8=AF=BB=E6=95=B0=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=A2=AB=E8=A3=81=EF=BC=8C=E3=80=8C=E8=B7=B3=E5=88=B0=E6=9C=80?= =?UTF-8?q?=E6=96=B0=E3=80=8D=E4=B8=8D=E5=86=8D=E5=8E=8B=E4=BD=8F=E9=80=89?= =?UTF-8?q?=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45 --- CHANGELOG.md | 3 + docs/BUG_HISTORY.md | 32 +++ ...cation-mobile-timeline-readout-20260917.md | 94 ++++++++ docs/tasks/README.md | 2 +- ...cation-mobile-timeline-readout-20260917.md | 18 ++ frontend/DESIGN.md | 10 +- frontend/src/app/globals.css | 57 ++++- .../src/components/rectification-timeline.tsx | 9 + ...-mobile-timeline-readout-20260917.test.tsx | 200 ++++++++++++++++++ .../rectification-timeline-20260909.test.ts | 7 +- 10 files changed, 422 insertions(+), 10 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-mobile-timeline-readout-20260917.md create mode 100644 docs/testing/rectification-mobile-timeline-readout-20260917.md create mode 100644 frontend/tests/rectification-mobile-timeline-readout-20260917.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 8277a78a..2a6bc81c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ 如果你 2026-09-16 到 09-17 之间在校正里遇到过风格题,那一次的答案可能计错了分,不要采用当时的区间;补一件带年月的事触发重算,或新开一段校正。 Skill 版本不变。 +## 2026-09-17 — 手机上时间轴读数不再被裁,「跳到最新」不再压住选项 + +窄屏(375 / 390 宽)打开生时校正时,上方时间轴那一行「范围 · 宽度 · 代表分钟 · 已答 N 题」四项完整显示,不再出现「已…」这种被裁一半的字。为腾出宽度,手机上去掉了这条与头像列对齐的 44px 缩进,并把「已对照 N 件」这项收回交付卡(桌面不变;读屏软件在两种宽度下读到的内容都一样)。「跳到最新」胶囊与对话末条之间留出空隙,不再压在选项卡最后一行上。Skill 版本不变。 ## 2026-09-16 — 正文念出的判别题必有卡;性格描述不再自相矛盾 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 37f5f3ad..6a7095bb 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 px(iPhone SE 2/3、13 mini)打开生时校正,Case 已有代表分钟、已答题数与已对照事件数(读数五项齐全)。 +- 根因:两条叠加。一是读数按「四项短文本」设计,`.rectification-timeline` 在 767px 以下仍取 `calc(var(--space-4) + var(--assistant-content-inset))`,即每侧 16 + 44 = 60px,375px 的可用内容宽只剩 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 > 255px;compact 段声明断言去 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 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-mobile-timeline-readout-20260917.md b/docs/tasks/PROGRESS-rectification-mobile-timeline-readout-20260917.md new file mode 100644 index 00000000..94def82d --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-mobile-timeline-readout-20260917.md @@ -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:48–05: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-16,BUG-740~743)把 `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 ≤ 343px(375)与 ≤ 358px(390);并反向断言旧口径 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` 未核对。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 273ba5d3..49f8ab51 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -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-908~911,Skill 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-915~917,Skill 未 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 项需 367px,375px 只有 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 已知边界) | ### 聊天主链路与首页 diff --git a/docs/testing/rectification-mobile-timeline-readout-20260917.md b/docs/testing/rectification-mobile-timeline-readout-20260917.md new file mode 100644 index 00000000..71a2937a --- /dev/null +++ b/docs/testing/rectification-mobile-timeline-readout-20260917.md @@ -0,0 +1,18 @@ +# 真机清单 · 校正页移动端时间轴读数与「跳到最新」(2026-09-17) + +在 staging 用受控账号、真机(不是桌面浏览器缩窗)走一遍生时校正。本机无 Chrome、无真机,以下全部做不了自动化,只能人工确认。不要把真实出生资料写进任何记录。 + +优先 375 CSS px(iPhone SE 2/3、13 mini),再复查 390(iPhone 12–16 基础款)。若手上只有 320 宽的老机(SE 1 代),第 2 条按「可接受被裁一点」看,见末尾说明。 + +1. **读数四项完整。** 进入校正、答到出现代表分钟之后,看上方那条时间轴第一行。应完整读出四项:`HH:MM–HH: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。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index c4a905f6..ee53c562 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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 `` 是独立设计系统,不在此表。 **二、条高固定,从首次渲染起就占位。** 这是正确性要求,不是视觉偏好:条在滚动容器外,高度一变就改 `clientHeight`,而没有任何观察者会因此重新跟随——贴底的读者会看着内容悄悄滑出视野底部且无法恢复。同理,Case 未就绪时渲染**等高的空骨架**(`data-state="pending"`),不得用 spinner、不得用「正在加载」文案、不得让条晚一步挂载。桌面 64px,767px 以下 56px;56px 等于既有的 `--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:07–05: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 间隙约 292px,375px 可用 343px、390px 可用 358px;320px 只余约 −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`。 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 4f03762f..bb3c5793 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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); diff --git a/frontend/src/components/rectification-timeline.tsx b/frontend/src/components/rectification-timeline.tsx index 2b884f2f..ae2f6d1f 100644 --- a/frontend/src/components/rectification-timeline.tsx +++ b/frontend/src/components/rectification-timeline.tsx @@ -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 ? ( {view.answeredLabel} ) : null} + {/* Hidden below 768px by globals.css, never omitted here: the label is + part of the accessible axis description either way. */} {view.datedEventLabel ? ( {view.datedEventLabel} ) : null} diff --git a/frontend/tests/rectification-mobile-timeline-readout-20260917.test.tsx b/frontend/tests/rectification-mobile-timeline-readout-20260917.test.tsx new file mode 100644 index 00000000..24c20292 --- /dev/null +++ b/frontend/tests/rectification-mobile-timeline-readout-20260917.test.tsx @@ -0,0 +1,200 @@ +/** + * 2026-09-17, iPhone on staging: the rectification timeline readout came through + * as `04:48–05: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(/

([\s\S]*?)<\/p>/); + assert.ok(readout, "readout paragraph not rendered"); + return [...readout[1].matchAll(/([^<]*)<\/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> = { + 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()); + assert.deepEqual(items, ["04:48–05: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(); + 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()); + // `.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 12–16 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;/); +}); diff --git a/frontend/tests/rectification-timeline-20260909.test.ts b/frontend/tests/rectification-timeline-20260909.test.ts index b76fb838..4a231739 100644 --- a/frontend/tests/rectification-timeline-20260909.test.ts +++ b/frontend/tests/rectification-timeline-20260909.test.ts @@ -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\)\);/); });