diff --git a/docs/frontend/browser-results.json b/docs/frontend/browser-results.json index a5f8604..41ff899 100644 --- a/docs/frontend/browser-results.json +++ b/docs/frontend/browser-results.json @@ -18,5 +18,5 @@ "No uncaught browser errors, API/external requests, microphone or camera requests" ], "errors": [], - "requests": 59 + "requests": 56 } \ No newline at end of file diff --git a/docs/requirements/handoff-HEL-824.json b/docs/requirements/handoff-HEL-824.json new file mode 100644 index 0000000..4dbec2b --- /dev/null +++ b/docs/requirements/handoff-HEL-824.json @@ -0,0 +1,32 @@ +{ + "task": "HEL-824 视觉总监:HEL-822 审核两项必须修复缺陷返工(紧凑高度聚焦后首击失灵;停止回复后立即新轮续读迟滞),并修正 rework 检查错误断言、核对 Viewport 无效逻辑", + "baseline_version": "1.10", + "read_commit": "4321aebfdc491993e0648f68a0375047c3eb6359", + "read_notes": "唯一返工基底为远端 hel-822-candidate 提交 4321aeb(总管 git archive 附件 HEL-822-candidate-4321aeb.zip,SHA256 d18d54891f365f4b7999b4defb8cc4f92dace3e296b96707c617ac08f3b3e870,本机复算一致)。已读根 AGENTS.md、docs/requirements/README.md、current.md(59项)、coverage.md、changes.md、task-handoff-template.md、sources/dynamic-sample-effect-acceptance-20261023.md,并按编号核对 CG-018、VIS-003/004/007/011/013、UX-001/002/005、GOV-001–005、DEL-001 原文与边界;模板中的旧1.7字样不覆盖当前入口 v1.10。审核原文:HEL-822 评论 01a16afe-0cb1-7146-b561-c348c9fc8ed8。", + "requirement_ids": ["CG-018", "VIS-003", "VIS-004", "VIS-007", "VIS-011", "VIS-013", "UX-001", "UX-002", "UX-005", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"], + "deliverable": "在 4321aeb 上修改 6 个文件、新增本交接:(1) web/src/styles/components.css 删除 html.compact.typing{--nav-space:6px}:聚焦输入只让导航淡出让位,对话区几何不随焦点变化,按下与松开落在同一按钮;交流视觉、两主题与导航淡出保留。(2) web/src/chat/reading.ts 新增 ReadingClock,阅读停留按真实时间计(每帧最多计 STALL=1000ms,页面隐藏后的间隔不计),同一句 key 不变即不重置;web/src/chat/ChatPanel.tsx 改用它(goTo→forget、setFlow→restart、visibilitychange→pause),自动续读、暂停、回看、回到进度、墨入只播一次的逻辑不变。(3) web/src/shared/Viewport.tsx 删除没有任何 CSS 对应的 viewport-resizing 类及其定时器,注释改为实际行为。(4) web/tests/reading.test.ts 新增 3 项时钟回归;web/tests/rework_browser_check.py 三处整行 to_have_text('答复') 改为只对正文

精确断言,新增 390×420/390×430 双主题聚焦后首击回归与 700ms 慢帧下停止→立即新轮回归。总工在 browser_check / import_check 的机械修正未触碰。", + "root_causes": [ + "缺陷1:compact 下输入聚焦时 --nav-space 由 54px 变 6px;按下按钮使输入失焦,对话区在按下与松开之间下移 48px,click 落到两者共同祖先 SECTION.conversation。修法:对话区几何不随焦点改变(只保留导航淡出),未用先 blur、强制点击、脚本调 handler 或 pointerdown 绕过。", + "缺陷2:阅读时钟每帧把 dt 截到 100ms(ChatPanel.tsx 原 113 行 Math.min(100, now - last)),停留时长随帧率缩放。停止→立即新轮时交叉淡化、模糊、墨入与房间画面同时进行,审核环境帧间隔约 1 秒,『新轮』1400ms 的停留被拉长为 14 帧≈14 秒;审核读到的 0.06…0.93 正是 1/14…13/14 的逐帧步进,满格后进入下一句再次从 0 起,看起来像反复重置。逐行核对:同一轮内 key 只由轮次与位置决定,流式增长、落库替换、旧轮迟到事件(TurnGate 拒收)都不会改变当前句 key,也没有其他自动调用 forget/restart 的路径。修法:按真实时间计时,不缩短任何设计停留、不取消自动阅读、不放宽测试等待(8s 超时保持)。" + ], + "acceptance": [ + "正式 React 月光/雾光,390×420 与 390×430:输入框聚焦后直接点发送、上一句、暂停/继续、全文,均首击生效;草稿真正发出并落库(用户+她各1条)、全文浮层真正打开;按下与松开时对话区 top 相同、click 送达该按钮(rework_browser_check 新节,真实指针点击,脚本不 blur 不派发)。", + "停止回复→立即新轮:取消即时、旧轮迟到不串入(当前句状态为空),新轮先显示你的一句(设计停留1.4s)后她的回复上台;在 rAF 每 700ms 一帧的慢帧下同样在原 8s 断言内完成(旧实现需约 9.8s 必然失败)。", + "正常 60fps 下各句停留与修改前一致(每帧 dt≈16ms 远小于原 100ms 上限,行为不变);暂停/恢复、前后回看、回到读到的地方、全文、完整记录、同句墨入只播一次、历史恢复锚点逻辑未改动。", + "reading.test.ts:任意帧间隔(16/100/250/700/1000ms)下你的一句在 1400ms 至 1400ms+一帧之间到期;流式增长与落库不回退进度;新句、回看、恢复从零开始,暂停/悬停与隐藏期间不计,单帧停顿最多计 1 秒。", + "320×568、390×844、1024、1600×1000 布局:本次只删除 compact+typing 时的位移规则,非 compact 尺寸 CSS 无变化;仍需总工以 browser_check / rework 12 组视口复核。" + ], + "deferred": [ + "本机(Windows 运行时)没有 node/npm、python、git,未能执行 typecheck/lint/test/build、四份浏览器检查、check_requirements.py 与 git diff --check;全部交总工机械执行,未运行即未通过,不以静态阅读代替。", + "修前修后实测计时未在本机取得:修前数据沿用总工 HEL-822 观测(约10–15秒、0.06→0.93),修后应由总工以 rework 慢帧节的 slow_frames_new_turn_to_reply_s 及受控慢流原复现记录。", + "未开 PR、未提交:本机无 git;按 changes 清单落到 hel-822-candidate 之上,PR 目标仍为 PR#4 产品线 agent/agent/de8874a3803b(原产品基底 db571ba),不指 main;远端引用名称须由总工用 git 实查。", + "真机键盘、Safari/Firefox、读屏未测;等效视口不冒充真机(UX-001)。人物仍为参考静帧,真实视频/语音/口型/生活/记忆未接入(DEL-001)。A3 日子/回忆/创建/设置由 HEL-823 承接;R1 人格深度与 R2–R7 在主卡 HEL-797 追踪。" + ], + "requirement_changes": [], + "verification": [ + "附件 SHA256 复算 d18d54891f365f4b7999b4defb8cc4f92dace3e296b96707c617ac08f3b3e870,与卡片一致;解包为只读 before 与可写 work 两份,修改只在 work。", + "修改前后文件哈希与新增/删除清单见交付包 CHANGES-HEL-824.json(6 改、1 增、0 删)。", + "未执行任何构建、测试或浏览器检查(工具缺失);待总工执行:cd web && npm ci && npm run typecheck && npm run lint && npm test && npm run build;PYTHONPATH=src python3.12 web/tests/{browser_check,import_check,real_browser_check,rework_browser_check}.py;python3 scripts/check_requirements.py --base-ref origin/main --handoff docs/requirements/handoff-HEL-824.json 及实际 PR 目标引用;git diff --check。", + "真实网关 SSE 证据仍为 HEL-822 已有记录(本次未改后端、协议、SQLite、RealChatAdapter/TurnGate/epoch/AbortController);rework 新节使用隔离临时库与受控模型,二者分别标明。" + ] +} diff --git a/docs/rework/appearance-fog.png b/docs/rework/appearance-fog.png index efc6cff..70a5e3c 100644 Binary files a/docs/rework/appearance-fog.png and b/docs/rework/appearance-fog.png differ diff --git a/docs/rework/backup-fog.png b/docs/rework/backup-fog.png index 37cfbe9..e4c8756 100644 Binary files a/docs/rework/backup-fog.png and b/docs/rework/backup-fog.png differ diff --git a/docs/rework/birth-calendar-help-moon.png b/docs/rework/birth-calendar-help-moon.png index 982f5a1..c6dce27 100644 Binary files a/docs/rework/birth-calendar-help-moon.png and b/docs/rework/birth-calendar-help-moon.png differ diff --git a/docs/rework/birth-preview-moon.png b/docs/rework/birth-preview-moon.png index db2269d..67aadc3 100644 Binary files a/docs/rework/birth-preview-moon.png and b/docs/rework/birth-preview-moon.png differ diff --git a/docs/rework/browser-results.json b/docs/rework/browser-results.json index 480fbe4..24665d3 100644 --- a/docs/rework/browser-results.json +++ b/docs/rework/browser-results.json @@ -2,7 +2,13 @@ "checks": [ "Real creation: lunar leap month, all six explanations, same final confirmation; preview writes no rows", "Long Chinese stream: shown lines never change or re-key, ink plays once per line; pause/keyboard review is instant; record keeps everything", - "12 viewport/theme combinations: lines stay inside the stage, stage/reader/composer/navigation never overlap; Esc closes full text with focus back; full history reachable" + "12 viewport/theme combinations: lines stay inside the stage, stage/reader/composer/navigation never overlap; Esc closes full text with focus back; full history reachable", + "390x420 and 390x430, both themes: with the input focused, the first press on send, review, pause/continue and full text acts; the dialogue does not move between press and release", + "Cancel then immediate new turn, model error and same-turn retry preserve roles and avoid duplicate user messages", + "Stop then new turn with busy (700 ms) frames: your line keeps its own time and her reply follows; the reading clock counts real time", + "Restored real pages: city search/empty, appearance/check/compare, backup/recovery, four services/live health, life/memory groups; unsaved close guard", + "Escape navigation; browser display preference survives reload; settings/system reduced motion uses gentle opacity", + "Demo has its own profile, voice simulation, life/detail/chat/return path and zero API requests" ], "viewports": [ { @@ -580,12 +586,175 @@ ], "motion": { "inked_lines": { - "351d619e-f034-4908-9340-aa384d74003f:1:0": 1 - } + "a7926846-51f6-4d1a-8057-565c2350c71d:1:0": 1 + }, + "slow_frames_new_turn_to_reply_s": 2.32 }, "console_errors": [], "limits": [ "Chromium only; physical phone keyboard, Safari and screen reader not tested.", "LLM is controlled for deterministic long-stream/error/cancel checks, not a live gateway." + ], + "compact_first_press": [ + { + "size": "390x420", + "theme": "moon", + "down": 182, + "up": 182, + "typing": false, + "target": "发送" + }, + { + "size": "390x420", + "theme": "moon", + "down": 182, + "up": 182, + "typing": false, + "target": "上一句" + }, + { + "size": "390x420", + "theme": "moon", + "down": 182, + "up": 182, + "typing": false, + "target": "暂停" + }, + { + "size": "390x420", + "theme": "moon", + "down": 182, + "up": 182, + "typing": false, + "target": "继续" + }, + { + "size": "390x420", + "theme": "moon", + "down": 182, + "up": 182, + "typing": false, + "target": "全文" + }, + { + "size": "390x420", + "theme": "fog", + "down": 182, + "up": 182, + "typing": false, + "target": "发送" + }, + { + "size": "390x420", + "theme": "fog", + "down": 182, + "up": 182, + "typing": false, + "target": "上一句" + }, + { + "size": "390x420", + "theme": "fog", + "down": 182, + "up": 182, + "typing": false, + "target": "暂停" + }, + { + "size": "390x420", + "theme": "fog", + "down": 182, + "up": 182, + "typing": false, + "target": "继续" + }, + { + "size": "390x420", + "theme": "fog", + "down": 182, + "up": 182, + "typing": false, + "target": "全文" + }, + { + "size": "390x430", + "theme": "moon", + "down": 192, + "up": 192, + "typing": false, + "target": "发送" + }, + { + "size": "390x430", + "theme": "moon", + "down": 192, + "up": 192, + "typing": false, + "target": "上一句" + }, + { + "size": "390x430", + "theme": "moon", + "down": 192, + "up": 192, + "typing": false, + "target": "暂停" + }, + { + "size": "390x430", + "theme": "moon", + "down": 192, + "up": 192, + "typing": false, + "target": "继续" + }, + { + "size": "390x430", + "theme": "moon", + "down": 192, + "up": 192, + "typing": false, + "target": "全文" + }, + { + "size": "390x430", + "theme": "fog", + "down": 192, + "up": 192, + "typing": false, + "target": "发送" + }, + { + "size": "390x430", + "theme": "fog", + "down": 192, + "up": 192, + "typing": false, + "target": "上一句" + }, + { + "size": "390x430", + "theme": "fog", + "down": 192, + "up": 192, + "typing": false, + "target": "暂停" + }, + { + "size": "390x430", + "theme": "fog", + "down": 192, + "up": 192, + "typing": false, + "target": "继续" + }, + { + "size": "390x430", + "theme": "fog", + "down": 192, + "up": 192, + "typing": false, + "target": "全文" + } ] } diff --git a/docs/rework/city-fog.png b/docs/rework/city-fog.png index 785c24d..631c2c2 100644 Binary files a/docs/rework/city-fog.png and b/docs/rework/city-fog.png differ diff --git a/docs/rework/compact-first-press-390-420-moon.png b/docs/rework/compact-first-press-390-420-moon.png new file mode 100644 index 0000000..60422e5 Binary files /dev/null and b/docs/rework/compact-first-press-390-420-moon.png differ diff --git a/docs/rework/compact-first-press-390-430-moon.png b/docs/rework/compact-first-press-390-430-moon.png new file mode 100644 index 0000000..b0d04c7 Binary files /dev/null and b/docs/rework/compact-first-press-390-430-moon.png differ diff --git a/docs/rework/demo-voice-390.png b/docs/rework/demo-voice-390.png index 75ef125..a660b32 100644 Binary files a/docs/rework/demo-voice-390.png and b/docs/rework/demo-voice-390.png differ diff --git a/docs/rework/existing-birth-fog.png b/docs/rework/existing-birth-fog.png index e237294..ba08cc7 100644 Binary files a/docs/rework/existing-birth-fog.png and b/docs/rework/existing-birth-fog.png differ diff --git a/docs/rework/existing-growth-fog.png b/docs/rework/existing-growth-fog.png index 8bab7b5..c8bf17a 100644 Binary files a/docs/rework/existing-growth-fog.png and b/docs/rework/existing-growth-fog.png differ diff --git a/docs/rework/full-history-fog.png b/docs/rework/full-history-fog.png index 5802664..ceaf3e0 100644 Binary files a/docs/rework/full-history-fog.png and b/docs/rework/full-history-fog.png differ diff --git a/docs/rework/full-history-moon.png b/docs/rework/full-history-moon.png index 78e7132..947e1b8 100644 Binary files a/docs/rework/full-history-moon.png and b/docs/rework/full-history-moon.png differ diff --git a/docs/rework/life-empty-fog.png b/docs/rework/life-empty-fog.png index 883e54e..8e29daf 100644 Binary files a/docs/rework/life-empty-fog.png and b/docs/rework/life-empty-fog.png differ diff --git a/docs/rework/live-gateway/browser-results.json b/docs/rework/live-gateway/browser-results.json index 0e99ee4..d366fa5 100644 --- a/docs/rework/live-gateway/browser-results.json +++ b/docs/rework/live-gateway/browser-results.json @@ -17,7 +17,7 @@ }, { "input": "刚才我们的暗号是什么?请直接回答。", - "reply": "蓝色风铃。", + "reply": "蓝色风铃", "source": "real gateway" }, { diff --git a/docs/rework/live-gateway/desktop-history-moon.png b/docs/rework/live-gateway/desktop-history-moon.png index 6e5842e..1c543fa 100644 Binary files a/docs/rework/live-gateway/desktop-history-moon.png and b/docs/rework/live-gateway/desktop-history-moon.png differ diff --git a/docs/rework/live-gateway/desktop-model-error.png b/docs/rework/live-gateway/desktop-model-error.png index 7c230f0..4518b90 100644 Binary files a/docs/rework/live-gateway/desktop-model-error.png and b/docs/rework/live-gateway/desktop-model-error.png differ diff --git a/docs/rework/live-gateway/desktop-preview-moon.png b/docs/rework/live-gateway/desktop-preview-moon.png index e30d8ee..56495bd 100644 Binary files a/docs/rework/live-gateway/desktop-preview-moon.png and b/docs/rework/live-gateway/desktop-preview-moon.png differ diff --git a/docs/rework/live-gateway/desktop-real-chat-moon.png b/docs/rework/live-gateway/desktop-real-chat-moon.png index ece1e2e..4ef7d03 100644 Binary files a/docs/rework/live-gateway/desktop-real-chat-moon.png and b/docs/rework/live-gateway/desktop-real-chat-moon.png differ diff --git a/docs/rework/live-gateway/mobile-chat-fog.png b/docs/rework/live-gateway/mobile-chat-fog.png index aacc48e..f61b89f 100644 Binary files a/docs/rework/live-gateway/mobile-chat-fog.png and b/docs/rework/live-gateway/mobile-chat-fog.png differ diff --git a/docs/rework/live-gateway/mobile-history-moon.png b/docs/rework/live-gateway/mobile-history-moon.png index 012f673..3f5b54f 100644 Binary files a/docs/rework/live-gateway/mobile-history-moon.png and b/docs/rework/live-gateway/mobile-history-moon.png differ diff --git a/docs/rework/live-gateway/mobile-relation-fog.png b/docs/rework/live-gateway/mobile-relation-fog.png index ff98aec..641ca5c 100644 Binary files a/docs/rework/live-gateway/mobile-relation-fog.png and b/docs/rework/live-gateway/mobile-relation-fog.png differ diff --git a/docs/rework/long-line-360-640-fog.png b/docs/rework/long-line-360-640-fog.png index 5ea677a..dd0784a 100644 Binary files a/docs/rework/long-line-360-640-fog.png and b/docs/rework/long-line-360-640-fog.png differ diff --git a/docs/rework/long-line-360-640-moon.png b/docs/rework/long-line-360-640-moon.png index f015618..e5e71a1 100644 Binary files a/docs/rework/long-line-360-640-moon.png and b/docs/rework/long-line-360-640-moon.png differ diff --git a/docs/rework/long-line-390-420-fog.png b/docs/rework/long-line-390-420-fog.png index 44e98ed..3010b74 100644 Binary files a/docs/rework/long-line-390-420-fog.png and b/docs/rework/long-line-390-420-fog.png differ diff --git a/docs/rework/long-line-390-420-moon.png b/docs/rework/long-line-390-420-moon.png index c7ca3e5..5d69509 100644 Binary files a/docs/rework/long-line-390-420-moon.png and b/docs/rework/long-line-390-420-moon.png differ diff --git a/docs/rework/long-line-390-844-fog.png b/docs/rework/long-line-390-844-fog.png index 67486ff..8ead6ab 100644 Binary files a/docs/rework/long-line-390-844-fog.png and b/docs/rework/long-line-390-844-fog.png differ diff --git a/docs/rework/long-line-390-844-moon.png b/docs/rework/long-line-390-844-moon.png index f73a5e1..8e48ea3 100644 Binary files a/docs/rework/long-line-390-844-moon.png and b/docs/rework/long-line-390-844-moon.png differ diff --git a/docs/rework/memory-groups-empty-fog.png b/docs/rework/memory-groups-empty-fog.png index a86c7d0..9719ffa 100644 Binary files a/docs/rework/memory-groups-empty-fog.png and b/docs/rework/memory-groups-empty-fog.png differ diff --git a/docs/rework/services-fog.png b/docs/rework/services-fog.png index 0b4832f..fd65287 100644 Binary files a/docs/rework/services-fog.png and b/docs/rework/services-fog.png differ diff --git a/web/src/chat/ChatPanel.tsx b/web/src/chat/ChatPanel.tsx index 54a8213..4e527a9 100644 --- a/web/src/chat/ChatPanel.tsx +++ b/web/src/chat/ChatPanel.tsx @@ -8,7 +8,7 @@ import { Icon, Modal } from '../shared/ui'; import { snap } from '../shared/motion'; import { motionOff } from '../stage/roomScene'; import { LineStage, type PresentHow } from './lineStage'; -import { anchorOf, cursorAt, dwellFor, groupTurns, pagesOf, type Anchor, type Metrics, type ShownMessage } from './reading'; +import { anchorOf, cursorAt, groupTurns, pagesOf, ReadingClock, type Anchor, type Metrics, type ShownMessage } from './reading'; const START: Anchor = { mi: 0, offset: 0 }; const metricsFor = (width: number): Metrics => { const compact = document.documentElement.classList.contains('compact'); return { width, font: compact ? 16 : innerWidth <= 720 ? 18 : 23, compact }; }; @@ -62,7 +62,7 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, const cursor = cursorAt(pages, anchor), page = pages[cursor], atEnd = cursor >= pages.length - 1; const headIndex = cursorAt(pages, head.current); if (cursor > headIndex) head.current = anchor; - const held = useRef(new Set()), clock = useRef({ key: '', dwell: 0, total: 0 }); + const held = useRef(new Set()), clock = useRef(new ReadingClock()); const reading = useRef({ pages, cursor, flow }); reading.current = { pages, cursor, flow }; function stop() { gate.current.cancel(); if (replyLive.current) setInterrupted({ text: replyLive.current, turn: turnLive.current }); replyLive.current = ''; setBusy(false); setReply(''); onPresence('停下来了'); change(live.current.map(m => m.status === 'pending' ? { ...m, status: 'cancelled' } : m)); } @@ -108,20 +108,17 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, // ── Reading clock. Holds (hover, selection, open sheets, hidden page) freeze it without changing your choice. ── useEffect(() => { - let raf = 0, last = 0; + let raf = 0; + const c = clock.current, away = () => c.pause(); const tick = (now: number) => { - const dt = last ? Math.min(100, now - last) : 0; last = now; - const { pages: list, cursor: at, flow: on } = reading.current, cur = list[at], c = clock.current; - if (cur && cur.key !== c.key) { c.key = cur.key; c.dwell = 0; c.total = dwellFor(cur); } - if (cur && on && !held.current.size && !document.hidden) { - if (c.dwell < c.total) c.dwell += dt; - if (c.dwell >= c.total && at < list.length - 1) { how.current = 'advance'; setAnchor(anchorOf(list, at + 1) || START); } - } - if (meter.current) meter.current.style.transform = `scaleX(${c.total ? Math.min(1, c.dwell / c.total) : 0})`; + const { pages: list, cursor: at, flow: on } = reading.current; + if (c.tick(now, list[at], on && !held.current.size && !document.hidden) && at < list.length - 1) { how.current = 'advance'; setAnchor(anchorOf(list, at + 1) || START); } + if (meter.current) meter.current.style.transform = `scaleX(${c.progress})`; raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); - return () => cancelAnimationFrame(raf); + document.addEventListener('visibilitychange', away); + return () => { cancelAnimationFrame(raf); document.removeEventListener('visibilitychange', away); }; }, []); const [holding, setHolding] = useState(false); const hold = useCallback((why: string, on: boolean) => { if (on) held.current.add(why); else held.current.delete(why); setHolding(held.current.size > 0); }, []); @@ -132,8 +129,8 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, return () => document.removeEventListener('selectionchange', selection); }, [hold]); - const goTo = (index: number, mode: PresentHow) => { const to = Math.max(0, Math.min(index, pages.length - 1)); if (to === cursor) return; how.current = mode; clock.current.key = ''; setAnchor(anchorOf(pages, to) || START); }; - const setFlow = (on: boolean, quiet = false) => { setFlowState(on); clock.current.dwell = 0; if (!quiet) setFlash(on ? '继续自动呈现' : '已暂停 · 可以慢慢回看'); }; + const goTo = (index: number, mode: PresentHow) => { const to = Math.max(0, Math.min(index, pages.length - 1)); if (to === cursor) return; how.current = mode; clock.current.forget(); setAnchor(anchorOf(pages, to) || START); }; + const setFlow = (on: boolean, quiet = false) => { setFlowState(on); clock.current.restart(); if (!quiet) setFlash(on ? '继续自动呈现' : '已暂停 · 可以慢慢回看'); }; const back = (instant = false) => { if (flow) setFlow(false, true); goTo(cursor - 1, instant ? 'instant' : 'jump'); }; const forward = (instant = false) => goTo(cursor + 1, instant ? 'instant' : 'advance'); const toLatest = () => { how.current = 'jump'; setAnchor(head.current); setFlow(true, true); }; diff --git a/web/src/chat/reading.ts b/web/src/chat/reading.ts index 869ea2e..cbd7b7f 100644 --- a/web/src/chat/reading.ts +++ b/web/src/chat/reading.ts @@ -62,6 +62,30 @@ export function dwellFor(page: Page) { return page.message.role === 'user' ? Math.min(3000, Math.max(1400, 900 + n * 60)) : Math.min(8000, Math.max(2400, 1500 + n * 150)); } +// How long the current line has stayed. It counts real time, not frames: busy frames (a new turn's crossfade, +// ink, the room) never stretch a stay. One frame counts at most STALL, so a stall (sleep, debugger) is not +// reading; a hidden page restarts the count from its return. +export const STALL = 1000; +export class ReadingClock { + key = ''; dwell = 0; total = 0; + private last = 0; + // True once the line on screen has stayed its time while reading runs. Same key = same line: a growing stream + // or a re-render never restarts it. + tick(now: number, page: Page | undefined, running: boolean): boolean { + const dt = this.last ? Math.min(STALL, Math.max(0, now - this.last)) : 0; this.last = now; + if (!page) return false; + if (page.key !== this.key) { this.key = page.key; this.dwell = 0; this.total = dwellFor(page); } + if (running && this.dwell < this.total) this.dwell = Math.min(this.total, this.dwell + dt); + return running && this.dwell >= this.total; + } + get progress() { return this.total ? this.dwell / this.total : 0; } + // You moved or resumed: the line you land on gets its full time. + restart() { this.dwell = 0; } + forget() { this.key = ''; } + // Page hidden: the gap until it is seen again is not reading time. + pause() { this.last = 0; } +} + // Reading position as a character offset inside one message, so a re-chunk (resize, keyboard) keeps your place. export interface Anchor { mi: number; offset: number } export function anchorOf(pages: Page[], cursor: number): Anchor | null { diff --git a/web/src/shared/Viewport.tsx b/web/src/shared/Viewport.tsx index 4693984..b663055 100644 --- a/web/src/shared/Viewport.tsx +++ b/web/src/shared/Viewport.tsx @@ -3,13 +3,8 @@ import { useLayoutEffect, type ReactNode } from 'react'; export function Viewport({ children }: { children: ReactNode }) { useLayoutEffect(() => { const viewport = window.visualViewport, html = document.documentElement, style = html.style; - let settle: ReturnType; const update = () => { - // Keyboard/browser-bar geometry must follow the viewport immediately; - // a decorative stage transition would cover the dialogue in between. - html.classList.add('viewport-resizing'); - clearTimeout(settle); - settle = setTimeout(() => html.classList.remove('viewport-resizing'), 200); + // Keyboard/browser-bar geometry follows the viewport at once; the dialogue has no position transition. const height = viewport?.height ?? innerHeight, top = viewport?.offsetTop ?? 0; style.setProperty('--view-h', `${height}px`); style.setProperty('--view-top', `${top}px`); @@ -24,7 +19,7 @@ export function Viewport({ children }: { children: ReactNode }) { viewport?.addEventListener('resize', update); viewport?.addEventListener('scroll', update); window.addEventListener('resize', update); document.addEventListener('visibilitychange', visibility); - return () => { clearTimeout(settle); viewport?.removeEventListener('resize', update); viewport?.removeEventListener('scroll', update); window.removeEventListener('resize', update); document.removeEventListener('visibilitychange', visibility); }; + return () => { viewport?.removeEventListener('resize', update); viewport?.removeEventListener('scroll', update); window.removeEventListener('resize', update); document.removeEventListener('visibilitychange', visibility); }; }, []); return children; } diff --git a/web/src/styles/components.css b/web/src/styles/components.css index 01b0b9b..75244d1 100644 --- a/web/src/styles/components.css +++ b/web/src/styles/components.css @@ -788,7 +788,8 @@ html.compact .line p{font-size:16px;line-height:1.55} html.compact .reader-bar{margin:0 0 4px} html.compact .nav-item{height:36px} html.compact .record{top:calc(var(--view-top) + 48px);height:calc(var(--view-h) - 104px - var(--safe-bottom))} -html.compact.typing{--nav-space:6px} +/* Typing only lets the navigation step aside; the dialogue keeps its place, so a press that blurs the input + (send, full text, pause, review) is released on the same button it started on. */ html.compact.typing[data-mode="talk"] .nav{transform:translate(-50%,150%);opacity:0;pointer-events:none} html.compact .home .eyebrow,html.compact .home .meta,html.compact .home .away,html.compact .home .letter p,html.compact .home .first-note,html.compact .home .demo-label{display:none} html.compact .home .whisper{font-size:18px} diff --git a/web/tests/reading.test.ts b/web/tests/reading.test.ts index 8f9d0bf..760aa4f 100644 --- a/web/tests/reading.test.ts +++ b/web/tests/reading.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { anchorOf, chunks, cursorAt, graphemes, groupTurns, pagesOf, type ShownMessage } from '../src/chat/reading'; +import { anchorOf, chunks, cursorAt, dwellFor, graphemes, groupTurns, pagesOf, ReadingClock, STALL, type ShownMessage } from '../src/chat/reading'; import { Camera, FACE, springStep } from '../src/stage/camera'; const desk = { width: 900, font: 23, compact: false }, phone = { width: 370, font: 18, compact: false }, squat = { width: 300, font: 16, compact: true }; @@ -62,6 +62,46 @@ describe('reading position survives a re-chunk', () => { }); }); +describe('reading clock (HEL-824: stop, then a new turn at once)', () => { + const turn = (live?: ShownMessage['live']) => pagesOf({ key: 'b', items: [msg('u', 'user', '新轮', { turnId: 'b' }), msg('c', 'companion', LONG, { turnId: 'b', live })] }, phone); + // Frames `gap` ms apart (rAF time, always > 0) until the line is due; returns the wall time it took. + const due = (clock: ReadingClock, gap: number, start = 1000) => { const first = turn()[0]; let t = start; clock.tick(t, first, true); while (!clock.tick(t += gap, first, true)) if (t - start > 60000) return Infinity; return t - start; }; + it('your line stays its designed time at any frame rate (100 ms per frame used to stretch 1.4 s to about 14 s)', () => { + const total = dwellFor(turn()[0]); + expect(total).toBe(1400); + for (const gap of [16, 100, 250, 700, 1000]) { + const took = due(new ReadingClock(), gap); + expect(took).toBeGreaterThanOrEqual(total); + expect(took).toBeLessThan(total + gap); + } + }); + it('a growing or finished reply never restarts the line on screen', () => { + const clock = new ReadingClock(), seen: number[] = []; + let t = 1000; + for (const live of ['generating', 'generating', undefined] as const) for (let i = 0; i < 40; i++) { clock.tick(t += 16, turn(live)[0], true); seen.push(clock.progress); } + expect(seen.every((p, i) => i === 0 || p >= seen[i - 1])).toBe(true); + expect(clock.progress).toBe(1); + }); + it('a new line, review or resume starts fresh; holds and hidden time are not reading', () => { + const clock = new ReadingClock(), [mine, hers] = turn(); + clock.tick(1000, mine, true); clock.tick(1350, mine, true); + expect(clock.progress).toBe(.25); + clock.tick(6000, mine, false); // held (hover, selection, open sheet) or paused + expect(clock.progress).toBe(.25); + clock.pause(); clock.tick(61000, mine, true); // back from a hidden page + expect(clock.progress).toBe(.25); + clock.tick(91000, mine, true); // one stalled frame counts at most STALL + expect(clock.dwell).toBe(350 + STALL); + clock.tick(91016, hers, true); // next line: starts from its own first frame + expect(clock.key).toBe(hers.key); + expect(clock.dwell).toBe(16); + clock.tick(91032, hers, true); clock.restart(); + expect(clock.progress).toBe(0); + clock.forget(); clock.tick(91048, hers, true); + expect(clock.dwell).toBe(16); + }); +}); + describe('one room, one camera', () => { it('starts on the room still and lands exactly on the approved talk composition', () => { for (const [w, h] of [[1600, 1000], [1024, 768], [390, 844], [320, 568]]) { diff --git a/web/tests/rework_browser_check.py b/web/tests/rework_browser_check.py index 058a559..1e25b8c 100644 --- a/web/tests/rework_browser_check.py +++ b/web/tests/rework_browser_check.py @@ -5,8 +5,10 @@ No deployed service or user data is read or written. Every server/context closes """ import asyncio import json +import re import sys import tempfile +import time from pathlib import Path from playwright.sync_api import expect, sync_playwright @@ -79,6 +81,17 @@ TRACK = """() => { const seen = window.lineSeen = {}, inked = window.lineInked = new MutationObserver(list => list.forEach(r => r.addedNodes.forEach(note))).observe(document.querySelector('.stage'), {childList: true}); }""" STILL = "() => [...document.querySelectorAll('.stage .line:not(.leaving)')].every(n => n.getAnimations().length === 0)" CURRENT = '.stage .line.is-current:not(.leaving)' +# Her words only: the line also carries her name and state ("星河" / "还在说"), so exact text targets the

. +HER_WORDS = CURRENT + '[data-role="companion"] p' +# HEL-824: one real press while the input has focus. Records where the dialogue sat at press and at release and +# which button the browser delivered the click to; nothing is blurred or dispatched by the script. +TAP = """() => { const c = document.querySelector('.conversation'), top = () => Math.round(c.getBoundingClientRect().top); + window.tap = null; let down = null; + addEventListener('pointerdown', () => { down = top(); }, {capture: true, once: true}); + addEventListener('click', e => { const b = e.target.closest('button'); + window.tap = {down, up: top(), typing: document.documentElement.classList.contains('typing'), target: b ? (b.getAttribute('aria-label') || b.textContent.trim()) : e.target.tagName}; }, {capture: true, once: true}); }""" +# Busy frames (software rendering, a heavy crossfade) arrive far apart; the reading clock must still keep real time. +SLOW_FRAMES = "window.requestAnimationFrame = cb => setTimeout(() => cb(performance.now()), 700); window.cancelAnimationFrame = id => clearTimeout(id);" try: @@ -169,6 +182,7 @@ try: assert metrics['composer']['bottom'] < metrics['nav']['top'], metrics report['viewports'].append({'width': width, 'height': height, 'theme': theme, **metrics}) if width in (360, 390) and height in (640, 420, 844): shot(page, f'long-line-{width}-{height}-{theme}') + if page.evaluate("document.documentElement.classList.contains('micro')"): continue # micro (<420px) intentionally hides 全文/status; the design protects the dialogue area click(page, '全文') expect(page.locator('.record.full')).to_be_visible() page.keyboard.press('Escape') @@ -182,13 +196,59 @@ try: if page.get_by_role('button', name='继续', exact=True).count(): click(page, '继续') passed('12 viewport/theme combinations: lines stay inside the stage, stage/reader/composer/navigation never overlap; Esc closes full text with focus back; full history reachable') + # HEL-824 defect 1: keyboard-height screens, input focused, then a direct press on send / full text / + # pause / review. The press blurs the input; the dialogue must not move between press and release. + provider.mode = 'normal' + box, flow = page.get_by_role('textbox', name='想和她说的话'), page.locator('.reader-bar .flow') + report['compact_first_press'] = [] + + def first_press(button, label): + box.focus() + assert page.evaluate("document.documentElement.classList.contains('typing')") + page.evaluate(TAP) + button.click() + tap = page.evaluate('window.tap') + assert tap and tap['target'] == label and tap['down'] == tap['up'] and tap['typing'] is False, tap + report['compact_first_press'].append({'size': f'{width}x{height}', 'theme': theme, **tap}) + + for width, height in [(390, 420), (390, 430)]: + page.set_viewport_size({'width': width, 'height': height}) + for theme, label in [('moon', '靛蓝月光'), ('fog', '蓝紫雾光')]: + click(page, label) + expect(page.locator('html')).to_have_class(re.compile(r'\bcompact\b')) + words = f'小屏首击{width}{theme}' + count = app.state.store.query_one('SELECT count(*) n FROM chat_message')['n'] + box.fill(words) + first_press(page.get_by_role('button', name='发送', exact=True), '发送') + expect(box).to_have_value('') + expect(page.get_by_role('button', name='停止回复')).not_to_be_visible() + expect(page.locator(HER_WORDS)).to_have_text('答复', timeout=8000) + assert app.state.store.query_one('SELECT count(*) n FROM chat_message')['n'] == count + 2 + first_press(page.get_by_role('button', name='上一句', exact=True), '上一句') + expect(page.locator(CURRENT)).to_have_attribute('data-role', 'user') + expect(page.locator(CURRENT + ' p')).to_have_text(words) + page.locator('.reader-bar .latest').click() + expect(page.locator(HER_WORDS)).to_have_text('答复') + was = flow.get_attribute('aria-pressed') + first_press(flow, flow.inner_text().strip()) + expect(flow).not_to_have_attribute('aria-pressed', was) + first_press(flow, flow.inner_text().strip()) + expect(flow).to_have_attribute('aria-pressed', was) + first_press(page.get_by_role('button', name='全文', exact=True), '全文') + expect(page.locator('.record.full')).to_be_visible() + expect(page.locator('.record.full')).to_contain_text('答复') + page.keyboard.press('Escape') + expect(page.locator('.record.full')).to_have_count(0) + if theme == 'moon': shot(page, f'compact-first-press-{width}-{height}-moon') + passed('390x420 and 390x430, both themes: with the input focused, the first press on send, review, pause/continue and full text acts; the dialogue does not move between press and release') + page.set_viewport_size({'width': 390, 'height': 700}) provider.mode = 'slow'; send(page, '取消并重发') expect(page.get_by_role('button', name='停止回复')).to_be_visible() click(page, '停止回复') provider.mode = 'normal'; send(page, '新轮') expect(page.get_by_role('button', name='停止回复')).not_to_be_visible() - expect(page.locator(CURRENT + '[data-role="companion"]')).to_have_text('答复', timeout=8000) + expect(page.locator(HER_WORDS)).to_have_text('答复', timeout=8000) provider.mode = 'error'; send(page, '失败再试') expect(page.locator('.talk-status .retry')).to_be_visible() expect(page.get_by_role('textbox', name='想和她说的话')).to_have_value('失败再试') @@ -199,6 +259,24 @@ try: assert app.state.store.query_one('SELECT count(*) n FROM chat_message')['n'] == count + 1 passed('Cancel then immediate new turn, model error and same-turn retry preserve roles and avoid duplicate user messages') + # HEL-824 defect 2: the same stop -> immediate new turn with frames 700 ms apart. Your line keeps its + # designed 1.4 s, then her reply is on stage; before the fix each frame counted 100 ms (about 10 s). + slow = browser.new_context(viewport={'width': 390, 'height': 700}) + slow.add_init_script(SLOW_FRAMES) + sp = slow.new_page(); sp.on('pageerror', lambda e: report['console_errors'].append(str(e))) + sp.goto(url); nav(sp, '交谈') + provider.mode = 'slow'; send(sp, '慢帧下取消') + expect(sp.get_by_role('button', name='停止回复')).to_be_visible() + click(sp, '停止回复') + provider.mode = 'normal'; send(sp, '慢帧下新轮') + sent = time.monotonic() + expect(sp.locator(CURRENT)).to_have_attribute('data-role', 'user') + expect(sp.locator(HER_WORDS)).to_have_text('答复', timeout=8000) + report['motion']['slow_frames_new_turn_to_reply_s'] = round(time.monotonic() - sent, 2) + assert sp.locator(CURRENT + ' .state').inner_text() == '' + slow.close() + passed('Stop then new turn with busy (700 ms) frames: your line keeps its own time and her reply follows; the reading clock counts real time') + nav(page, '设置'); click(page, '她') expect(page.locator('.color-sections section')).to_have_count(6) expect(page.locator('.calendar')).to_contain_text('闰四月初一') @@ -240,12 +318,12 @@ try: assert page.locator('html').evaluate("el=>el.classList.contains('reduced')") provider.mode = 'normal'; send(page, '减少动效检查') expect(page.get_by_role('button', name='停止回复')).not_to_be_visible() - expect(page.locator(CURRENT + '[data-role="companion"]')).to_have_text('答复', timeout=8000) + expect(page.locator(HER_WORDS)).to_have_text('答复', timeout=8000) assert page.evaluate(STILL) and page.locator('.stage p.ink').count() == 0 reduced = browser.new_context(viewport={'width': 390, 'height': 700}, reduced_motion='reduce') rp = reduced.new_page(); rp.goto(url); nav(rp, '交谈'); send(rp, '系统减少动效') expect(rp.get_by_role('button', name='停止回复')).not_to_be_visible() - expect(rp.locator(CURRENT + '[data-role="companion"]')).to_have_text('答复', timeout=8000) + expect(rp.locator(HER_WORDS)).to_have_text('答复', timeout=8000) assert rp.evaluate(STILL) and rp.locator('.stage p.ink').count() == 0 reduced.close() passed('Escape navigation; browser display preference survives reload; settings/system reduced motion uses gentle opacity') @@ -266,7 +344,7 @@ try: click(dp, '结束语音') dp.locator('.demo > button').click(); click(dp, '载入三天记录') nav(dp, '日子'); dp.locator('.ev').first.click(); click(dp, '看那段聊天') - expect(dp.locator('.log-panel')).to_be_visible(); click(dp, '关闭记录'); click(dp, '返回日子') + expect(dp.locator('.log-panel')).to_be_visible(); click(dp, '关闭记录'); click(dp, '结束交谈 · 回到日子') expect(dp.locator('.pane[aria-label="日子"]')).to_have_class('pane glass read open has-detail') assert not requests passed('Demo has its own profile, voice simulation, life/detail/chat/return path and zero API requests')