# 任务书 · iPhone 上键盘收起 / 刷新后整页上移,顶栏点不到(2026-09-17) ## 0. 基线 - 基线 commit:`73d3c99c`(staging 已部署)。分支:`codex/mobile-viewport-scroll-lock-20260917`。 - 纯前端;不动 `page.tsx`(新逻辑放 `frontend/src/components/` 的一个客户端组件,从 `layout.tsx` 挂载)。 - BUG 段:**BUG-920 起**(基线最大 BUG-919)。 ## 1. 事故实证(产品负责人 2026-09-17 09:34 iPhone Safari 截图,staging `73d3c99c`) - 刷新校正页后,顶栏(侧栏按钮 / 「9月17日 · 生时校正」/ 当前盘面 / 点数)和时间轴都不在屏幕里,第一条可见内容是消息正文;输入框下面空出约 260px 的白底,浏览器工具栏之上什么都没有。产品原话:「点不到 tab 栏,没找到入口,整体上移了」。 - 空白高度 ≈ 整页上移的距离:整个应用壳被 `window` 级滚动推上去了,不是壳内某个容器的问题。 ## 2. 根因(代码可证部分 + iOS 行为推断,执行方需真机确认) 1. `globals.css:322` `html, body { height: 100%; overflow: hidden; }`,应用壳靠 `.chat-app` / `.chat-panel` 的 `height: 100%` 撑满;**没有任何代码处理 `window.scrollY`**:`git grep` `visualViewport` / `scrollRestoration` / `window.scrollTo` 在 `frontend/src` 0 命中(`use-billing-panel.ts:195` 的 `scrollIntoView` 是容器内滚动)。 2. iOS Safari 不支持 `viewport` 的 `interactive-widget=resizes-content`(`layout.tsx:26` 只对 Chrome Android 生效)。iOS 上键盘弹出时**布局视口不变、Safari 直接滚动 window** 让聚焦的输入框露出来;`overflow: hidden` 挡不住这次程序滚动,却挡住了用户手指把页面拉回来。键盘收起后 `window.scrollY` 留在原地 → 顶栏在屏幕上方、底部空白。 3. 刷新不能自愈:Safari 对同 URL 的 reload 默认恢复滚动位置(`history.scrollRestoration === "auto"`),`scrollY` 被原样还原,所以「刷新想测试」看到的还是上移后的页面。 4. 与今天上线的两单无关:BUG-919 只改了 `.message-list` 底部留白和 `.conversation` 的 `scroll-padding-block-end`,都在滚动容器内部;BUG-918 只改时间轴内边距。这条是既有缺陷,今天因为在手机上连续输入才被看到。 ## 3. 决策记录 - 产品:手机上任何时候顶栏都必须能点到;不接受「用户自己想办法滚回去」。 - 不改 `html, body` 的 `overflow: hidden`(桌面与滚动锚定依赖它),只补 window 级复位。 ## 4. 任务分解 ### T1 window 级滚动复位(BUG-920) - 新客户端组件 `frontend/src/components/viewport-scroll-lock.tsx`,在 `layout.tsx` 与 `StaleClientRecovery` 并列挂载,只在 `pointer: coarse` 或 `visualViewport` 存在时启用: - 挂载时 `history.scrollRestoration = "manual"`,并 `window.scrollTo(0, 0)`; - 监听 `window.visualViewport` 的 `resize` 与 `scroll`、`window` 的 `pageshow` / `orientationchange`、`document` 的 `focusout`:当 `visualViewport.height` 回到 ≥ `window.innerHeight - 1`(键盘已收)且 `window.scrollY > 0` 时 `window.scrollTo(0, 0)`;键盘打开期间(`visualViewport.height < innerHeight - 100`)不动,让 Safari 把输入框露出来。 - 卸载时移除监听。不用 `setInterval`。 - 验收:源码契约测试(挂载点、`scrollRestoration = "manual"`、监听事件集合、键盘打开期间不复位的判定);jsdom 下模拟 `visualViewport` 对象触发 resize 断言 `scrollTo(0,0)` 被调用一次;`tsc` / lint / `npm test` 失败清单同基线;`/` Static;gzip ±2%。 ### T3 顶栏右侧两枚芯片同一套尺寸(BUG-921,产品 2026-09-17「积分块有点扁」) - 实证:手机顶栏右侧「当前盘面」与积分「✦ 557」并排,积分块看起来又宽又矮。规则出处:`globals.css:885` `.credit-button` `min-height: 44px; min-width: 64px; padding: 0 var(--space-3); font-size: var(--type-caption)`(13px);`:2964-2973` 顶栏里的 `.rectification-board-peek` 同为 `min-height: 44px` 但 `padding: var(--space-2) var(--space-4)`、`strong` 用 `--type-body-sm`(14px)、圆角与边框另一套;两枚都塞在 `.chat-panel` 的 **46px** 顶栏行里(`:855-856`),上下各剩 1px,芯片没有呼吸空间,小字 + 强制 64px 最小宽度就显得扁。 - 修法:顶栏芯片抽一个共用类(或让 `.credit-button` 与顶栏里的 `.rectification-board-peek` 共用同一组声明):同高、同圆角、同边框、同字号(14px,数字 tabular)、同内边距,`min-width` 去掉;手机(`.is-compact` / ≤767px)顶栏行从 46px 放到 52px,芯片视觉高 40px、点击热区仍 ≥44px(用透明 `::before` 扩热区或外层 padding,BUG-695 的触屏命中区规则不得退)。桌面若同样受益可一并做,写进 `DESIGN.md` 顶栏一节。 - 验收:CSS 声明断言两枚芯片高度/字号/圆角一致、`.credit-button` 无 `min-width`;`chat-header` compact 高度断言;触屏热区契约测试(BUG-695 那条)仍绿;真机清单加一条「顶栏两枚芯片等高、不扁」。 ### T2 记录 - BUG-921 同上记录。 - BUG-920(状态按真机结果:本机无 iOS 只能写 `resolved` 附「真机待核」或 `investigating`,不得编)、CHANGELOG、`frontend/DESIGN.md` 移动端一节加一句「window 级滚动由 ViewportScrollLock 复位」、`docs/testing/mobile-viewport-scroll-lock-20260917.md`:iPhone Safari 上 (a) 点输入框弹键盘→收起→顶栏仍可点;(b) 弹键盘状态下刷新→顶栏在顶;(c) 横竖屏切换后无空白带;(d) Android Chrome 不受影响。 ## 5. 让步顺序 1. 若 `focusout` 时机早于键盘收起动画导致复位被 Safari 再次覆盖,改为在 `visualViewport.resize` 上做,`focusout` 只做兜底延时 300ms 一次。 2. T2 不可让步。 ## 6. 开工前置命令 ```bash git fetch origin --prune git worktree add -b codex/mobile-viewport-scroll-lock-20260917 .worktrees/mobile-viewport-scroll-lock-20260917 origin/staging cd .worktrees/mobile-viewport-scroll-lock-20260917/frontend && ./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -20 ``` ## 7. 环境缺口 - 本机无 iOS / Chrome;根因第 2、3 条是按 iOS Safari 已知行为推断,执行方与产品在真机上按 §4 T2 清单确认。产品临时绕过:键盘收起后把页面往下拉一下不会有效(overflow hidden),需**关掉标签页重新打开**(不是刷新)才能复位。