# 任务书 · 客户端交互与 UI 收尾(2026-08-30) 基线:`origin/staging` @ `ea0fbd44`。 本轮四条任务全部来自一次对着代码实测的审计,每条都附了实测数据。**先读完「硬红线」再动手。** --- ## 硬红线 1. **不得改变任何元素的视觉尺寸。** 任务 1 是扩大热区,不是把按钮画大。改完截图对比,图标和留白必须和改前一模一样。 2. **不得手写 `useCallback` / `useMemo`。** 3. **不得修改既有测试断言** —— 除非该断言锁住的正是本轮要修的缺陷本身;那种情况下必须在断言上方写注释说明「原值是什么、为什么它是错的」,并在 PROGRESS 里单列。 4. 推 staging 前必须 `./node_modules/.bin/tsc --noEmit` 通过。**不要用 `npx tsc`**,本仓库环境下会装到空包 `tsc@2.0.4`。 5. 测试数不得低于基线,且 `fail=0`、`skipped=0`(在有 Docker 的环境里跑)。本机没有 Docker 时会有 23 条数据库/部署类失败 + 10 条 skipped,那是既有环境缺口,不是你引入的 —— 但**必须逐条比对失败清单,确认没有新增**。 6. **浅色和深色两套都要验。** 本仓库已有深色主题(`prefers-color-scheme` + `data-theme`),任何配色改动必须同时在两套下检查对比度。 7. 不得改 `.gitea/workflows/**`。不得在有未提交改动的工作树上切分支。不得自行把 staging 提升到 main。 让步顺序:功能与测试不回归 > 可验证的修复 > 视觉一致 > 代码整洁。 ## 开工前置 ```bash git fetch origin --prune git worktree add -b codex/interaction-20260830 \ ../.worktrees/interaction-20260830 origin/staging ``` 基线必须是 `origin/staging`,不是任何本地 ref。读 `pre_work_error_ledger.md`,跑 `scripts/pre_work_check.py`,读 `frontend/AGENTS.md`(Next.js 版本与训练数据不同,写代码前先看 `node_modules/next/dist/docs/`)。改前先在 `docs/BUG_HISTORY.md` 检索同类记录。 **下面所有行号只是线索,请按选择器定位**,`origin/staging` 上的行号可能有偏移。 --- ## 任务 0(P0)· 深色下两处对比度不达标 ### 事实 深色调色板的字色逐个对底色实测,两组低于 WCAG AA 4.5:1: | 组合 | 实测 | | --- | ---: | | `--color-ink-tertiary` `#928e84` on `--color-canvas-muted` `#30302d` | **4.05:1** ✗ | | `--color-action` `#d4785a` on `--color-canvas-muted` `#30302d` | **4.18:1** ✗ | `frontend/tests/dark-theme-contract.test.ts` 现有的对比度断言**只验了「字色 vs `--color-canvas`」**,漏掉了卡片底 `--color-canvas-muted`,所以这两组没被拦住。代码块的语言标签(`.markdown-code-language`)正是 `ink-tertiary` 落在 `.markdown-code` 的暖卡片底上,是已在线上的实例。 ### 做法 两个深色块(`@media (prefers-color-scheme: dark)` 内的 `:root:not([data-theme="light"])`,以及 `:root[data-theme="dark"]`)**各改一处**,共两处: ``` --color-ink-tertiary: #928e84 → #9c988e --color-action: #d4785a → #d78064 ``` `#d78064` 与原值**色相 15°、饱和度均不变**,只提亮 2.5% 明度 —— 观感仍是同一个黏土橙,不要顺手换色相。 `#d4785a` 在仓库里共 **15 处**,四个根边界页(`error.tsx` / `not-found.tsx` / `forbidden.tsx` / `global-error.tsx`,它们各自内联了一份深色 token)和 `DESIGN.md` 的深色对照表里都有,**必须一起换**,否则边界页的行动色会和主站不一致: ```bash grep -rn '#d4785a' frontend/src frontend/DESIGN.md ``` `--color-focus` 在深色块里跟随 `--color-action`,一并确认。 ### 测试 把 `dark-theme-contract.test.ts` 里那条对比度断言从「1 种底色」扩成 **8 种字色 × 4 种底色**: - 字色:`color-ink`、`color-ink-strong`、`color-ink-secondary`、`color-ink-tertiary`、`color-action`、`color-danger`、`color-success`、`color-warning` - 底色:`color-canvas`、`color-canvas-soft`、`color-canvas-muted`、`color-sidebar-solid` 全部 32 组必须 ≥ 4.5:1,失败信息里要打印出具体是哪一对、实际多少。**不要放宽阈值来让测试通过。** ### 验收 ```bash ./node_modules/.bin/tsx --test tests/dark-theme-contract.test.ts grep -rc '#d4785a' frontend/src frontend/DESIGN.md # 期望全 0 ``` 反向验证:把 `--color-ink-tertiary` 改回 `#928e84`,测试必须变红。 ### 建档 `docs/BUG_HISTORY.md` 新增一条(用户可见的可读性缺陷)。**编号前先确认远端最大号**,别抢号: ```bash git fetch origin --prune && git show origin/staging:docs/BUG_HISTORY.md \ | grep -o '^## BUG-[0-9]*' | sort -t- -k2 -n | tail -3 ``` 防复发条写清楚:新增字色/底色 token 时必须同时进这张 32 组矩阵,不得只验阅读面。 --- ## 任务 1(P0)· 触达尺寸普遍低于文档承诺的 44px ### 事实 `frontend/DESIGN.md:165` 写着 *"Touch targets are at least 44px"*。实测违反: | 选择器 | 实际 | 位置 | | --- | ---: | --- | | **`.message-actions button`**(globals.css:1253 附近) | **26×26px,`padding: 0`** | 每条回答下方的赞/踩/复制/重新生成 | | `.chart-nav-chip`(:732) | 32px | 侧栏星盘 chip | | `.auth-links button`(:1657) | 32px,`padding: 0` | 登录页链接按钮 | | `.select-item`(:2507) | 40px | 模型选择器每一项 | | `.birth-time-window-details .birth-time-skip-button`(:2450) | 40px | | | `.report-center-section-heading button` | 40px | | **26px 那条优先级最高** —— 它是全产品点得最勤的控件,出现在每一条回答下方,且完全没有 padding 撑开热区。 ### 做法 **扩大热区,不改视觉尺寸。** 用透明的伪元素把可点区域撑到 44px: ```css .message-actions button { position: relative; } .message-actions button::after { position: absolute; content: ""; inset: 50% 50% 50% 50%; translate: -50% -50%; width: 44px; height: 44px; } ``` (上面是示意,具体写法自己定,但必须满足:视觉尺寸不变、热区 ≥44px、不影响相邻元素布局、不遮挡兄弟元素的点击。) 对 40px 那几个,直接把 `min-height` 提到 44px 更简单 —— **但前提是不撑破所在容器**。`.select-item` 在下拉里有 `max-height` 限制,改高之后一屏能显示的条目变少,需要目视确认下拉不会变得难用;如果变差,改用伪元素方案。 `.chart-nav-chip` 是 inline-flex 的胶囊,直接提高会改变视觉;用伪元素。 ### 止损 - 如果某一处用伪元素会造成热区互相重叠(相邻按钮间距小于 44px 时必然发生),**不要硬撑到 44px**:把该处的实际间距和重叠范围写进 `BLOCKED.md`,只做到不重叠的最大值,并说明还差多少。宁可 36px 不重叠,也不要 44px 互相吃点击。 - `.message-actions` 一行有 4 个按钮,请先量一下它们的间距再决定。 ### 验收 - 改前改后各截一张图(浅色 + 深色),确认视觉零差异 - 用浏览器 devtools 量出每个按钮的实际命中矩形,列表给出 - 相邻按钮热区不得重叠 —— 给出实测间距 - 新增合同测试:断言这些选择器要么 `min-height ≥ 44px`,要么带扩大热区的伪元素规则 ### 建档 同任务 0,另建一条 BUG_HISTORY。防复发:新增可点元素必须满足 44px 命中区,视觉尺寸小于 44px 时用伪元素补。 --- ## 任务 2(P1)· 字数上限是静默的 ### 事实 | 位置 | 上限 | 反馈 | | --- | ---: | --- | | 主对话输入框(`page.tsx` 传给 `chat-composer.tsx`) | 500 | **无** | | `birth-time-guide-turn.tsx:34` | 500 | **无** | | `birth-time-choice-question.tsx:113` | 240 | **无** | | 姓名输入(`page.tsx:909`) | 80 | **无** | 只有 `maxLength` 属性。到顶之后继续打字,浏览器直接不接收,界面**毫无反应** —— 这是最难自我诊断的一类问题,用户只会觉得"键盘坏了"。 ### 做法 给这几处加接近上限时才出现的计数提示: - **平时不显示**,剩余字数低于某个阈值(建议 50,短输入按比例调)才出现,避免常态噪声 - 用 `--color-ink-tertiary`;到达上限时才转 `--color-danger` - 计数区必须 `aria-live="polite"` 且 `aria-atomic="true"`,但**只在阈值内挂载**,不要让读屏用户每敲一个字都被播报 - 输入框加 `aria-describedby` 指向计数区 主输入框的计数放哪需要判断:`.composer-footer` 已经存在,优先复用,不要新造一层容器把输入框顶高。 ### 止损 如果加计数会让移动端输入区高度变化导致布局跳动,**先只做主对话输入框**,其余三处登记 `BLOCKED.md`。布局跳动比没有计数更糟。 ### 验收 - 四处(或已做的那几处)各录一段:从阈值外打到上限,说明何时出现、何时变红 - 读屏验证:阈值外不播报,进入阈值后播报一次,不是每字一次 - 移动端确认输入区高度不跳 --- ## 任务 3(P1)· 三个输入框的发送键不一致,且无任何提示 ### 事实 | 位置 | 发送方式 | | --- | --- | | 主对话输入框(`page.tsx:3701`) | **Enter**(Shift+Enter 换行),已正确处理 `isComposing` | | 生时校正对话(`rectification-agentic-chat.tsx:1233`) | **Enter**,已处理 `isComposing` | | `birth-time-guide-turn.tsx:39` | **⌘/Ctrl+Enter** | 第三处不但与前两处相反,界面上**没有一个字**告诉用户。它有可见的「整理为经历草稿」按钮所以不会卡死,但用户在主对话养成的 Enter 习惯到这里只会换行。 ### 做法 **统一为 Enter 发送、Shift+Enter 换行**,与前两处一致。改 `birth-time-guide-turn.tsx`: - 必须同时加 `!event.nativeEvent.isComposing` 判断 —— 这是中文输入法的必要条件,前两处都有,第三处因为原本需要修饰键所以没加。**漏了这条会导致中文用户选词时误发送**,比现状严重得多。 - 保留「整理为经历草稿」按钮不动。 那是一个 `rows={3}` 的多行框,改成 Enter 发送后换行只能靠 Shift+Enter。如果你判断这个框的使用场景更需要自由换行(用户在描述一段经历),**可以反过来选择保留 ⌘+Enter 但补上可见提示**(放在既有的 `#birth-time-guide-hint` 里)。两种都可接受,但必须二选一并在 PROGRESS 里写明理由 —— 不允许维持"既不一致又无提示"的现状。 ### 验收 - 中文输入法下逐字验证:输入拼音 → 按 Enter 选词 → **不得发送**;选完词再按 Enter → 发送 - 三个输入框的行为写成一张对照表放进 PROGRESS - 新增合同测试:三处的 `onKeyDown` 必须都带 `isComposing` 判断 --- ## 不在本轮范围 - **会话列表虚拟化 / `page.tsx:1212` 的 `visibleSessions` 每次 render 重排**:与已修好的消息列表同类,但会话数通常只有几十。**没有渲染基准之前不得动手** —— 需要先按 `frontend/scripts/home-streaming-render-benchmark.mts` 的模式出一份会话列表基准,证明它是热点。红线也禁止手写 `useMemo`。 - **代码块语法高亮**:产品已明确不做。 - **会话搜索、编辑自己发出的消息**:不在本轮。 - **15 个无 CSS 规则的死类名**:见 `frontend/tests/class-name-definition-contract.test.ts` 的 `knownUnstyled` 白名单,都是有意的空修饰符,不要顺手清理。 --- ## 收尾 - PROGRESS 文件名写 `PROGRESS-frontend-interaction-20260830.md`。**不要写成 `PROGRESS.md`** —— 根目录已有受版本控制的 `progress.md`,大小写不敏感文件系统上会互相覆盖。 - 四条任务合成**一次** staging 推送(staging push 触发全量构建+部署,没有路径过滤,不要分多次推)。 - 推送后核对 `https://staging.jyotisha.chat/api/health` 的 `.deployment.gitCommit` 等于新 SHA。**流水线是 `validate` → `publish` → 才派发 `deploy`,全链路约 20 分钟**,不要在十几分钟内就断定部署失败。 - 不自行提升 main。 ## 交付物清单 1. 两个深色块各改两处色值,`#d4785a` 全仓 15 处清零 2. `dark-theme-contract.test.ts` 扩成 32 组对比度断言 + 反向验证证据 3. 触达尺寸改动,附改前改后截图(浅色+深色)证明视觉零差异 4. 每个可点元素的实测命中矩形与相邻间距 5. 字数计数提示,附阈值行为录制与读屏播报验证 6. 发送键统一(或补提示)+ 中文输入法选词不误发的逐字验证 7. 三个输入框行为对照表 8. 新增合同测试(触达尺寸、`isComposing`) 9. `docs/BUG_HISTORY.md` 两条新条目(编号已对远端确认) 10. `BLOCKED.md`:任何触发止损的项 11. `PROGRESS-frontend-interaction-20260830.md` 12. `tsc --noEmit` / `eslint`(0 error)/ `tsx --test`(失败清单与基线逐条比对)/ `next build` 四条命令的实际输出