From 1b508d5e9aae57c06760ee6be4495013cce9c496 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Thu, 17 Sep 2026 13:13:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20iPhone=20=E9=94=AE=E7=9B=98?= =?UTF-8?q?=E6=94=B6=E8=B5=B7=E5=90=8E=E9=A1=B6=E6=A0=8F=E5=A4=8D=E4=BD=8D?= =?UTF-8?q?=EF=BC=8C=E7=A7=AF=E5=88=86=E5=9D=97=E4=B8=8E=E7=9B=98=E9=9D=A2?= =?UTF-8?q?=E8=8A=AF=E7=89=87=E5=90=8C=E9=AB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 + docs/BUG_HISTORY.md | 32 +++++ ...SS-mobile-viewport-scroll-lock-20260917.md | 23 ++++ docs/tasks/README.md | 2 +- .../mobile-viewport-scroll-lock-20260917.md | 9 ++ frontend/DESIGN.md | 4 +- frontend/src/app/globals.css | 58 ++++++-- frontend/src/app/layout.tsx | 2 + .../src/components/viewport-scroll-lock.tsx | 12 ++ frontend/src/lib/viewport-scroll-lock.ts | 85 ++++++++++++ ...bile-viewport-scroll-lock-20260917.test.ts | 124 ++++++++++++++++++ 11 files changed, 345 insertions(+), 10 deletions(-) create mode 100644 docs/tasks/PROGRESS-mobile-viewport-scroll-lock-20260917.md create mode 100644 docs/testing/mobile-viewport-scroll-lock-20260917.md create mode 100644 frontend/src/components/viewport-scroll-lock.tsx create mode 100644 frontend/src/lib/viewport-scroll-lock.ts create mode 100644 frontend/tests/mobile-viewport-scroll-lock-20260917.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a6bc81c..b2b93466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-17 — 手机键盘收起后顶栏还在;积分块和「当前盘面」一样高 + +iPhone 上弹键盘再收起、或带着键盘刷新,整页会停在上移后的位置,顶栏点不到。现在键盘一收,页面回到顶,顶栏随时能点。顶栏右侧「当前盘面」和积分两枚芯片同一套尺寸,积分块不再又宽又扁。Skill 版本不变。 + ## 2026-09-17 — 不再问「你更接近哪一种相处方式」;灰掉的选项和「再说一件事」不会同屏 分盘风格题(「亲密关系里,你更接近哪一种相处方式?」这类)不再作为计分题出现。关系、事业、家人这些线只在引擎给得出带年份的事件时问「某年前后有没有…」,给不出就直接问下一条线或给结果,不再拿性格描述去淘汰候选时间。性格描述仍然出现在平局时的参考题和交付卡的性格列里。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6a7095bb..390a9233 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -11970,3 +11970,35 @@ - 相关记录:BUG-478(两个聊天面统一用 `useConversationScrollAnchor` 与 `JumpToLatestButton`,并把浮层改成居中)、7a4360d8(同一现象 2026-08-25 的旧修法是把胶囊右对齐,已被 BUG-478 的居中统一取代,本轮未推翻它)、BUG-918 - 复发自:无 - 修复版本:待发布 + +## BUG-920 | iPhone 键盘收起 / 刷新后整页上移,顶栏点不到 + +- 状态:resolved +- 首次发现:2026-09-17 +- 最近更新:2026-09-17 +- 影响面:`ViewportScrollLock`、`layout.tsx` +- 用户现象:刷新或收起键盘后顶栏滚出屏幕,底部留下空白,点不到 tab。 +- 触发条件:iPhone Safari 上聚焦输入框弹出键盘,随后收起或带着偏移刷新。 +- 根因:iOS 不支持 `interactive-widget=resizes-content`,键盘弹出时滚动 `window`。`html, body { overflow: hidden }` 挡住用户拉回,reload 又按 `scrollRestoration=auto` 还原 `scrollY`。代码里原先没有 window 级复位。 +- 修复:挂载时 `history.scrollRestoration = "manual"` 并 `scrollTo(0,0)`。`visualViewport` resize/scroll、pageshow、orientationchange、focusout(300ms)在键盘已收且 `scrollY > 0` 时复位。键盘打开期间不动。 +- 验证:`frontend/tests/mobile-viewport-scroll-lock-20260917.test.ts`。真机待核。 +- 防复发:不得靠用户自己滚回去;键盘打开时不得抢 Safari 的露出输入框滚动。 +- 相关记录:BUG-918、BUG-919、BUG-921 +- 复发自:无 +- 修复版本:待发布 + +## BUG-921 | 顶栏积分块比「当前盘面」扁 + +- 状态:resolved +- 首次发现:2026-09-17 +- 最近更新:2026-09-17 +- 影响面:`.credit-button`、`.chat-header-rectification .rectification-board-peek`、手机顶栏行高 +- 用户现象:手机顶栏右侧积分「✦ N」又宽又矮,和旁边「当前盘面」不成一套。 +- 触发条件:校正页顶栏同时出现盘面芯片与积分块。 +- 根因:`.chat-header-actions button` 把高度写成 32px,比 `.credit-button` 更具体,积分块被压到 32px 却仍强制 `min-width: 64px`、13px 字。盘面芯片用另一套 44px / 14px。两枚都塞在 46px 行里。 +- 修复:两枚共用 40px 高、14px tabular、同圆角同内边距;去掉积分 `min-width`。`::before` inset -2px 把热区扩到 44px。≤767px 顶栏行 `52px + safe-area`(原 64px 覆盖改为与任务书 52px 对齐)。 +- 验证:CSS 声明断言高度/字号/圆角一致、无 64px min-width;`touch-target-contract` 仍绿。 +- 防复发:顶栏两枚芯片必须走同一组声明,不得再各写一套高度。 +- 相关记录:BUG-695、BUG-920 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-mobile-viewport-scroll-lock-20260917.md b/docs/tasks/PROGRESS-mobile-viewport-scroll-lock-20260917.md new file mode 100644 index 00000000..d587666f --- /dev/null +++ b/docs/tasks/PROGRESS-mobile-viewport-scroll-lock-20260917.md @@ -0,0 +1,23 @@ +# 进度 · 手机顶栏可点 + 积分块不扁(2026-09-17) + +基线:`origin/staging` @ `77e3626a`。 +分支:`codex/mobile-viewport-scroll-lock-20260917`。 +BUG:920、921。Skill 未 bump。 + +## 做了什么 + +- **T1 / BUG-920**:`viewport-scroll-lock.ts` + `ViewportScrollLock` 挂在 `layout.tsx`,与 `StaleClientRecovery` 并列。`scrollRestoration=manual`,键盘收起后 `window.scrollTo(0,0)`。键盘打开(visualViewport 矮于 innerHeight−100)不动。`focusout` 按让步做 300ms 兜底。未改 `html, body overflow: hidden`,未动 `page.tsx` 状态。 +- **T3 / BUG-921**:`.chat-header-actions .credit-button` 与顶栏 `.rectification-board-peek` 共用 40px / 14px / 同圆角。积分去掉 `min-width: 64px`。`::before` −2px 扩到 44px 热区。≤767px 顶栏行从 `64px + safe-area` 收到 `52px + safe-area`(任务书按 46→52 写;手机原先已是 64px 覆盖,现与 52px 对齐)。 +- **T2**:BUG_HISTORY / CHANGELOG / DESIGN / 真机清单。 + +## 测试 + +| 项 | 结果 | +| --- | --- | +| `tsc --noEmit` | 0 错 | +| 改动文件 eslint | 0 error | +| 本单 + touch-target + 顶栏相关 | **84 passed / 0 fail**(含 BUG-695 触屏热区) | +| Python | 未改,不跑快速门 | +| `next build` | worktree junction,留给 Gitea | + +无 iOS / Chrome,真机清单留给产品。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index a060f9a2..f658788b 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -105,7 +105,7 @@ | `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)** | 已验收 | `5113d457`(BUG-915~917,产品拍板 (b) 风格题不计分)。Claude 独立验收:生产路径测试(receipt 不含自建探针)答题 applied / GET 出卡 / idle 不误伤;合并候选门禁 tsc 0 / lint 0 error / npm test 3427 条 31 红与基线逐条相同 / `/` Static / 首屏 gzip 620,107→620,193(+0.01%)/ pytest 63 绿 / 快速门 Python 798 绿。真机清单 `docs/testing/rectification-dead-d9-choice-fix-20260916.md` | | `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 已验收 | `e8e98bbd`(合入时重放为本分支提交,BUG-918/919)。时间轴相关 71 条 + 全量同上;手机上隐藏「已对照 N 件」符合 DESIGN §10;桌面是否也移除待产品拍板。真机清单 `docs/testing/rectification-mobile-timeline-readout-20260917.md` | -| `TASK-mobile-viewport-scroll-lock-20260917.md` | `PROGRESS-mobile-viewport-scroll-lock-20260917.md` | iPhone 上键盘收起 / 刷新后整页上移、顶栏点不到(BUG-920):iOS 不支持 `interactive-widget`,键盘弹出时 Safari 滚动 window,`html/body overflow: hidden` 让用户拉不回来,reload 又还原 `scrollY`;代码里无任何 window 级复位。补 `ViewportScrollLock`(`scrollRestoration=manual` + `visualViewport` 复位)。与今天两单无关,既有缺陷。**T3 顶栏积分块「有点扁」(BUG-921)**:两枚芯片 44px 塞在 46px 顶栏里、字号/内边距/圆角各一套、积分块强制 64px 最小宽度;统一尺寸并把手机顶栏放到 52px | 待领取 | — | +| `TASK-mobile-viewport-scroll-lock-20260917.md` | `PROGRESS-mobile-viewport-scroll-lock-20260917.md` | iPhone 上键盘收起 / 刷新后整页上移、顶栏点不到(BUG-920):iOS 不支持 `interactive-widget`,键盘弹出时 Safari 滚动 window,`html/body overflow: hidden` 让用户拉不回来,reload 又还原 `scrollY`;代码里无任何 window 级复位。补 `ViewportScrollLock`(`scrollRestoration=manual` + `visualViewport` 复位)。与今天两单无关,既有缺陷。**T3 顶栏积分块「有点扁」(BUG-921)**:两枚芯片 44px 塞在 46px 顶栏里、字号/内边距/圆角各一套、积分块强制 64px 最小宽度;统一尺寸并把手机顶栏放到 52px | 待验收 | `codex/mobile-viewport-scroll-lock-20260917` | ### 聊天主链路与首页 diff --git a/docs/testing/mobile-viewport-scroll-lock-20260917.md b/docs/testing/mobile-viewport-scroll-lock-20260917.md new file mode 100644 index 00000000..3514dcce --- /dev/null +++ b/docs/testing/mobile-viewport-scroll-lock-20260917.md @@ -0,0 +1,9 @@ +# 真机清单 · 手机顶栏可点、芯片不扁(2026-09-17) + +在 iPhone Safari 的 staging 上走。本机无 iOS,下列条目留给产品负责人。 + +1. 打开一段对话或生时校正,点输入框弹出键盘,再收起键盘:顶栏(侧栏按钮、标题、当前盘面、积分)仍在屏幕顶部,点得到。 +2. 键盘还开着时刷新同一 URL:顶栏仍在顶部,底部没有整页上移留下的空白带。 +3. 横屏再竖屏:没有空白带,顶栏可点。 +4. 顶栏右侧「当前盘面」与积分「✦ N」两枚芯片一样高、一样圆角,积分块不扁。 +5. Android Chrome:弹键盘、收起、刷新,顶栏行为与修之前一样,没有新的跳动。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index ee53c562..b8488795 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -319,9 +319,11 @@ The birth-time rectification session is the consultation transcript plus a house ### Chat header -- One line, 46px. Sidebar trigger, session title, and the credit pill. It was 68px and carried a second line reading `分析对象:{盘名}`. +- One line, 46px on desktop. Sidebar trigger, session title, and the credit pill. It was 68px and carried a second line reading `分析对象:{盘名}`. - The chart a session analyses is now a quiet `--color-ink-tertiary` chip beside the title (`.chat-header-chart`), and it drops out below 768px rather than wrapping the header to two rows. "分析对象" was internal vocabulary; the chart name alone carries the same fact. - With the composer strip gone as well, the two pieces of permanent chrome went from 68 + 148 to 46 + 124 px. +- On viewports ≤767px the header row is 52px plus the safe-area inset. The credit pill and the rectification 「当前盘面」 chip share one size: 40px visual height, 14px tabular type, the same radius and padding, no 64px min-width. The 44px touch target is the transparent `::before` ring. +- Window-level scroll is reset by `ViewportScrollLock` after the on-screen keyboard closes, so the header cannot be left above the screen. ### Navigation item diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index bb3c5793..508bc8f5 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -882,7 +882,51 @@ button:disabled { cursor: default; opacity: .45; } content: ""; } .chat-header-actions button > svg { width: 14px; height: 14px; } -.credit-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-3); cursor: pointer; font-size: var(--type-caption); font-variant-numeric: tabular-nums; transition: background-color 120ms ease-out, transform 120ms ease-out; min-width: 64px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); color: var(--color-ink-secondary); font-weight: 500; } +/* Header chips share one size. `.chat-header-actions button` is 32px and + more specific than `.credit-button`, which is why the credit pill looked + shorter than 「当前盘面」(BUG-921). */ +.chat-header-actions .credit-button, +.chat-header-rectification .rectification-board-peek { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + height: 40px; + min-height: 40px; + padding: 0 var(--space-3); + gap: var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-canvas-soft); + color: var(--color-ink-secondary); + font-size: var(--type-body-sm); + font-weight: 500; + font-variant-numeric: tabular-nums; + cursor: pointer; + transition: background-color 120ms ease-out, transform 120ms ease-out; +} +.chat-header-actions .credit-button::before, +.chat-header-rectification .rectification-board-peek::before { + position: absolute; + inset: -2px; + content: ""; +} +.credit-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: 0 var(--space-3); + cursor: pointer; + font-size: var(--type-body-sm); + font-variant-numeric: tabular-nums; + transition: background-color 120ms ease-out, transform 120ms ease-out; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-canvas-soft); + color: var(--color-ink-secondary); + font-weight: 500; +} .conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--composer-reserve); background: var(--color-canvas); } /* Empty state. The greeting is bottom-aligned inside the conversation row and @@ -1892,8 +1936,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class [data-sidebar="sidebar"][data-mobile-open="true"] { visibility: visible; transform: translateX(0); } .sidebar-scrim { position: fixed; z-index: 11; inset: 0; display: block; border: 0; background: var(--color-scrim); opacity: 1; visibility: visible; } [data-sidebar="rail"] { display: none; } - .chat-panel { grid-template-rows: calc(64px + env(safe-area-inset-top)) minmax(0, 1fr) auto; } - .chat-panel.is-rectification { grid-template-rows: calc(64px + env(safe-area-inset-top)) minmax(0, 1fr); } + .chat-panel { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto; } + .chat-panel.is-rectification { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr); } + .chat-panel:has(.starter-balance) { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto minmax(0, 1fr); } .chat-header { gap: var(--space-3); padding: max(0px, env(safe-area-inset-top)) var(--space-3) 0; } .conversation.is-empty { display: block; @@ -2962,15 +3007,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class display: none; } .chat-header-rectification .rectification-board-peek { - display: inline-flex; - align-items: center; justify-content: flex-end; width: auto; max-width: min(11.5rem, 42vw); - min-height: 44px; margin: 0; - padding: 0 var(--space-3); - gap: var(--space-2); } .chat-header-rectification .rectification-board-peek strong, .chat-header-rectification .rectification-board-peek span { @@ -2979,6 +3019,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: inherit; + font-variant-numeric: tabular-nums; } @media (max-width: 480px) { .chat-header-rectification .rectification-board-peek span { diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index a0a98de7..ecba06ae 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -3,6 +3,7 @@ import localFont from "next/font/local"; import Script from "next/script"; import { Toaster } from "@/components/ui/sonner"; import { StaleClientRecovery } from "@/components/stale-client-recovery"; +import { ViewportScrollLock } from "@/components/viewport-scroll-lock"; import { themePreferenceBootScript } from "@/lib/theme-preference"; // Vendored Inter latin variable (OFL). Do not switch back to next/font/google; @@ -53,6 +54,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac + {children} diff --git a/frontend/src/components/viewport-scroll-lock.tsx b/frontend/src/components/viewport-scroll-lock.tsx new file mode 100644 index 00000000..0770c242 --- /dev/null +++ b/frontend/src/components/viewport-scroll-lock.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { useEffect } from "react"; + +import { installViewportScrollLock } from "@/lib/viewport-scroll-lock"; + +export function ViewportScrollLock() { + useEffect(() => { + return installViewportScrollLock(window as unknown as Parameters[0]); + }, []); + return null; +} diff --git a/frontend/src/lib/viewport-scroll-lock.ts b/frontend/src/lib/viewport-scroll-lock.ts new file mode 100644 index 00000000..14077659 --- /dev/null +++ b/frontend/src/lib/viewport-scroll-lock.ts @@ -0,0 +1,85 @@ +/** + * iOS Safari scrolls `window` to keep a focused input in view. `html, body + * { overflow: hidden }` then blocks the user from dragging back, and reload + * restores that offset. Reset window scroll only after the keyboard is gone. + */ + +export const KEYBOARD_OPEN_GAP_PX = 100; +export const KEYBOARD_CLOSED_SLACK_PX = 1; + +export function isKeyboardOpen(visualHeight: number, innerHeight: number): boolean { + return visualHeight < innerHeight - KEYBOARD_OPEN_GAP_PX; +} + +export function shouldResetWindowScroll(input: { + scrollY: number; + innerHeight: number; + visualViewportHeight: number | null; +}): boolean { + if (input.scrollY <= 0) return false; + if (input.visualViewportHeight == null) return true; + if (isKeyboardOpen(input.visualViewportHeight, input.innerHeight)) return false; + return input.visualViewportHeight >= input.innerHeight - KEYBOARD_CLOSED_SLACK_PX; +} + +type ViewportLike = { + height: number; + addEventListener: (type: "resize" | "scroll", listener: () => void) => void; + removeEventListener: (type: "resize" | "scroll", listener: () => void) => void; +}; + +export type ViewportScrollLockHost = { + matchMedia?: (query: string) => { matches: boolean }; + visualViewport?: ViewportLike | null; + innerHeight: number; + scrollY: number; + scrollTo: (x: number, y: number) => void; + history: { scrollRestoration: string }; + addEventListener: (type: string, listener: () => void) => void; + removeEventListener: (type: string, listener: () => void) => void; + document: { + addEventListener: (type: string, listener: () => void) => void; + removeEventListener: (type: string, listener: () => void) => void; + }; + setTimeout?: (fn: () => void, ms: number) => unknown; +}; + +export function installViewportScrollLock(host: ViewportScrollLockHost): () => void { + const coarse = host.matchMedia?.("(pointer: coarse)").matches === true; + const viewport = host.visualViewport ?? null; + if (!coarse && !viewport) return () => undefined; + + host.history.scrollRestoration = "manual"; + host.scrollTo(0, 0); + + const resetIfSettled = () => { + if (!shouldResetWindowScroll({ + scrollY: host.scrollY, + innerHeight: host.innerHeight, + visualViewportHeight: viewport?.height ?? null, + })) return; + host.scrollTo(0, 0); + }; + + const onPageShow = () => { + host.scrollTo(0, 0); + }; + const onFocusOut = () => { + const later = host.setTimeout ?? ((fn: () => void) => fn()); + later(resetIfSettled, 300); + }; + + viewport?.addEventListener("resize", resetIfSettled); + viewport?.addEventListener("scroll", resetIfSettled); + host.addEventListener("pageshow", onPageShow); + host.addEventListener("orientationchange", resetIfSettled); + host.document.addEventListener("focusout", onFocusOut); + + return () => { + viewport?.removeEventListener("resize", resetIfSettled); + viewport?.removeEventListener("scroll", resetIfSettled); + host.removeEventListener("pageshow", onPageShow); + host.removeEventListener("orientationchange", resetIfSettled); + host.document.removeEventListener("focusout", onFocusOut); + }; +} diff --git a/frontend/tests/mobile-viewport-scroll-lock-20260917.test.ts b/frontend/tests/mobile-viewport-scroll-lock-20260917.test.ts new file mode 100644 index 00000000..f3a71c33 --- /dev/null +++ b/frontend/tests/mobile-viewport-scroll-lock-20260917.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { cssDeclarations } from "./css-contract-test-support.ts"; +import { + installViewportScrollLock, + isKeyboardOpen, + shouldResetWindowScroll, + type ViewportScrollLockHost, +} from "../src/lib/viewport-scroll-lock.ts"; + +const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8"); +const component = readFileSync(new URL("../src/components/viewport-scroll-lock.tsx", import.meta.url), "utf8"); +const lock = readFileSync(new URL("../src/lib/viewport-scroll-lock.ts", import.meta.url), "utf8"); +const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); + +test("ViewportScrollLock mounts beside StaleClientRecovery and uses the shared installer", () => { + assert.match(layout, /import \{ ViewportScrollLock \} from "@\/components\/viewport-scroll-lock"/); + assert.match(layout, /\s*/); + assert.match(component, /installViewportScrollLock/); + assert.match(lock, /history\.scrollRestoration = "manual"/); + assert.match(lock, /addEventListener\("resize"/); + assert.match(lock, /addEventListener\("scroll"/); + assert.match(lock, /addEventListener\("pageshow"/); + assert.match(lock, /addEventListener\("orientationchange"/); + assert.match(lock, /addEventListener\("focusout"/); + assert.match(lock, /KEYBOARD_OPEN_GAP_PX = 100/); + assert.doesNotMatch(lock, /setInterval/); +}); + +test("keyboard-open window offsets are left alone", () => { + assert.equal(isKeyboardOpen(680, 800), true); + assert.equal(isKeyboardOpen(799, 800), false); + assert.equal(shouldResetWindowScroll({ + scrollY: 260, + innerHeight: 800, + visualViewportHeight: 680, + }), false); + assert.equal(shouldResetWindowScroll({ + scrollY: 260, + innerHeight: 800, + visualViewportHeight: 800, + }), true); + assert.equal(shouldResetWindowScroll({ + scrollY: 0, + innerHeight: 800, + visualViewportHeight: 800, + }), false); +}); + +test("visualViewport resize resets window scroll once the keyboard has closed", () => { + const calls: Array<[number, number]> = []; + const viewportListeners = new Map void>(); + const host: ViewportScrollLockHost = { + innerHeight: 800, + scrollY: 260, + scrollTo(x, y) { + host.scrollY = y; + calls.push([x, y]); + }, + history: { scrollRestoration: "auto" }, + visualViewport: { + height: 800, + addEventListener(type, listener) { + viewportListeners.set(type, listener); + }, + removeEventListener(type) { + viewportListeners.delete(type); + }, + }, + matchMedia: () => ({ matches: true }), + addEventListener() {}, + removeEventListener() {}, + document: { + addEventListener() {}, + removeEventListener() {}, + }, + }; + const uninstall = installViewportScrollLock(host); + assert.equal(host.history.scrollRestoration, "manual"); + assert.deepEqual(calls, [[0, 0]]); + host.scrollY = 180; + host.visualViewport!.height = 680; + viewportListeners.get("resize")?.(); + assert.equal(calls.length, 1); + host.visualViewport!.height = 800; + viewportListeners.get("resize")?.(); + assert.deepEqual(calls.at(-1), [0, 0]); + assert.equal(calls.length, 2); + uninstall(); +}); + +test("header chips share height, type size, and radius, and drop the 64px min-width", () => { + const shared = cssDeclarations( + ".chat-header-actions .credit-button,\n.chat-header-rectification .rectification-board-peek", + styles, + ); + assert.match(shared, /height:\s*40px/); + assert.match(shared, /min-height:\s*40px/); + assert.match(shared, /font-size:\s*var\(--type-body-sm\)/); + assert.match(shared, /border-radius:\s*var\(--radius-md\)/); + assert.match(shared, /font-variant-numeric:\s*tabular-nums/); + assert.doesNotMatch(shared, /min-width:/); + const credit = cssDeclarations(".credit-button", styles); + assert.doesNotMatch(credit, /min-width:\s*64px/); + const hit = cssDeclarations( + ".chat-header-actions .credit-button::before,\n.chat-header-rectification .rectification-board-peek::before", + styles, + ); + assert.match(hit, /inset:\s*-2px/); +}); + +test("compact chat header is 52px plus the safe-area inset", () => { + assert.match( + styles, + /\.chat-panel \{ grid-template-rows: calc\(52px \+ env\(safe-area-inset-top\)\) minmax\(0, 1fr\) auto; \}/, + ); + assert.match( + styles, + /\.chat-panel\.is-rectification \{ grid-template-rows: calc\(52px \+ env\(safe-area-inset-top\)\) minmax\(0, 1fr\); \}/, + ); + assert.match(styles, /\.chat-panel \{[^}]*grid-template-rows:\s*46px minmax\(0,\s*1fr\) auto/); +});