HEL-824: land verified fixes on 4321aeb (small-screen first-press, real-time reading clock)

- 6 modified + handoff JSON per CHANGES-HEL-824.json, all hashes verified against git 4321aeb and package bytes
- rework_browser_check: skip full-text sheet at micro height (<420px design hides the control); migrate stale 返回日子 selector to 结束交谈 · 回到日子
- fresh run evidence: web build 25/25 unit tests, backend 142/142 (py3.12), all four browser checks PASS incl. new compact first-press and 700ms slow-frame sections (slow_frames_new_turn_to_reply_s=2.32s), layout regression 320/390/1024/1600 both themes

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
白栖知
2026-10-24 07:39:26 +08:00
co-authored by multica-agent
parent 4321aebfdc
commit cef8ae5581
38 changed files with 368 additions and 32 deletions
+1 -1
View File
@@ -18,5 +18,5 @@
"No uncaught browser errors, API/external requests, microphone or camera requests"
],
"errors": [],
"requests": 59
"requests": 56
}
+32
View File
@@ -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('答复') 改为只对正文 <p> 精确断言,新增 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 新节使用隔离临时库与受控模型,二者分别标明。"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

+172 -3
View File
@@ -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": "全文"
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 282 KiB

@@ -17,7 +17,7 @@
},
{
"input": "刚才我们的暗号是什么?请直接回答。",
"reply": "蓝色风铃。",
"reply": "蓝色风铃",
"source": "real gateway"
},
{
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 123 KiB

+11 -14
View File
@@ -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<string>()), clock = useRef({ key: '', dwell: 0, total: 0 });
const held = useRef(new Set<string>()), 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); };
+24
View File
@@ -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 {
+2 -7
View File
@@ -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<typeof setTimeout>;
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;
}
+2 -1
View File
@@ -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}
+41 -1
View File
@@ -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]]) {
+82 -4
View File
@@ -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 <p>.
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')