diff --git a/prototypes/hel-793/README.md b/prototypes/hel-793/README.md index 9ce4d06..fc49b2c 100644 --- a/prototypes/hel-793/README.md +++ b/prototypes/hel-793/README.md @@ -62,11 +62,16 @@ | 动效 | 触发 | 时长 | 能否打断 | | --- | --- | --- | --- | | 按钮反馈 | 按下 | 160ms,缩放 .97 | 是 | -| 新句进入 | 自动或手动前进 | 680ms,透明度 + 位移 16px + 模糊 8→0;逐字错开 14ms,最多 48 字 | 是(跳转改为交叉淡化) | -| 旧句上移/离开 | 同上 | 620ms/520ms | 是 | -| 人物窗展开或收回 | 进入、离开交流或浮层 | 720ms,过程中保持实际几何 | 是,从当前位置继续 | +| 新句进入 | 自动或点击前进 | 680ms,透明度 + 位移 16px + 模糊 8→0;逐字错开 14ms,最多 48 字 | 是(跳转改为交叉淡化) | +| 当前句退为上一句 | 同上 | 两份固定字号的副本交叉:大字副本 520ms 上移、按字号比缩小(transform)并淡出;小字副本 620ms 在上一句位置淡入。不对 font-size 做连续插值 | 是 | +| 最早一句离开 | 同上 | 520ms | 是 | +| 人物窗展开或收回 | 点击进入、离开交流或浮层 | 720ms,过程中保持实际几何;档 2 改为 320ms 淡入 | 是,从当前位置继续 | | 镜头深度 | 模式切换:相处 0、交流 1、记录 .7、浮层 −.6 | 弹簧,约 0.8s 停稳 | 是 | | 浮层 | 打开/关闭 | 460ms/240ms,从下方升起并由模糊变清晰 | 是 | +| 导航亮块 | 切换页面 | 460ms,只移动 transform;四个入口同宽,宽度直接设定,不做动画 | 是 | +| **键盘快捷操作** | Esc 退出交流或关闭浮层;← / → 回看、前进 | **0ms**:直接显示目标状态(人物窗、交谈层、镜头深度、文字),不重播逐字;进行中的有限动画直接跳到终点 | — | + +键盘快捷操作往往连续、快速地按,所以不做动画。点击、自动续读和环境氛围的柔和进出保持不变。 | 主题 | 切换 | 空间约 1s 连续混色,界面 0.6s | 是 | | 环境循环 | 一直运行 | 月光呼吸 9s;云影约 60s 掠过;雾层每秒 5–28px;窗灯约 1.5s 淡入淡出;塔灯 2.6s;人物窗推移 28s;灯光呼吸 9s | 页面隐藏时暂停 | @@ -84,6 +89,7 @@ - 远处城市只在尺寸变化时预渲染,柔光和雾在 1/5 分辨率的画布上绘制后再放大。 - 系统「减少动态效果」或设置里的「减少动效」开启时,空间几乎静止,镜头直接切换,只保留淡入淡出;文字不再逐字出现。 - 页面在后台时,绘制和动画都会停止。 +- 人物窗几何动画(HEL-795 评估,`evidence/portal-ab.json`、`evidence/motion-probe.json`):每帧重新布局约 0.1ms,因为人物窗是绝对定位,只影响自身很小的子树。真正的成本在重绘:无头 Edge 软件光栅下,电脑 1440×900 每次展开或收回比“去掉几何、其余相同”多出约 10 帧超过 25ms 的长帧;手机 390×844 没有差别。用缩放(scale)代替几何会把拱形圆角和照片拉歪;用 clip-path 裁剪需要按两种尺寸裁切照片,结束时会跳变,所以保留实际几何。另试过让灯光层改用合成器缩放,软件光栅下没有改善,已撤回。降级:Canvas 降到档 2 时,人物窗改为 320ms 淡入,不再逐帧改几何。真实 GPU 帧率未测。 ## 复现 @@ -97,6 +103,8 @@ python3 prototypes/hel-793/build.py # 或 Windows:powershell -File pr - `record.ps1`:用 CDP 屏幕录制,再在浏览器中编码为 WebM;录屏中的白色圆圈只是点击位置标记。 - `contact.ps1`:解码成品视频并抽帧拼图。 - `verify.ps1`:行为检查,结果写入 `evidence/verification.json`。 +- `motion-probe.ps1`(HEL-795):记录键盘、点击、自动续读后 80ms 时仍在运行的动画,以及布局次数和帧间隔,结果写入 `evidence/motion-probe.json`。PR#8 原版的对照结果在 `evidence/motion-probe-before-pr8.json`。 +- `portal-ab.ps1`(HEL-795):比较人物窗保留几何和去掉几何时的帧间隔,结果写入 `evidence/portal-ab.json`。 ## 验证范围与未验证项 diff --git a/prototypes/hel-793/evidence/desktop-long-end.png b/prototypes/hel-793/evidence/desktop-long-end.png index aef7153..5497f77 100644 Binary files a/prototypes/hel-793/evidence/desktop-long-end.png and b/prototypes/hel-793/evidence/desktop-long-end.png differ diff --git a/prototypes/hel-793/evidence/desktop-review.png b/prototypes/hel-793/evidence/desktop-review.png index 0402c3f..60dd47d 100644 Binary files a/prototypes/hel-793/evidence/desktop-review.png and b/prototypes/hel-793/evidence/desktop-review.png differ diff --git a/prototypes/hel-793/evidence/desktop-tier2-fog.png b/prototypes/hel-793/evidence/desktop-tier2-fog.png index c9336d4..06c8ba6 100644 Binary files a/prototypes/hel-793/evidence/desktop-tier2-fog.png and b/prototypes/hel-793/evidence/desktop-tier2-fog.png differ diff --git a/prototypes/hel-793/evidence/handoff-frames-after.png b/prototypes/hel-793/evidence/handoff-frames-after.png new file mode 100644 index 0000000..ac46f43 Binary files /dev/null and b/prototypes/hel-793/evidence/handoff-frames-after.png differ diff --git a/prototypes/hel-793/evidence/integration-keyboard-chromium.json b/prototypes/hel-793/evidence/integration-keyboard-chromium.json new file mode 100644 index 0000000..75d627b --- /dev/null +++ b/prototypes/hel-793/evidence/integration-keyboard-chromium.json @@ -0,0 +1,113 @@ +{ + "escape": { + "anims": [], + "mode": "home", + "z": 0 + }, + "click_open": [ + { + "target": "depth-veil", + "duration": 720, + "prop": "opacity" + }, + { + "target": "home-copy", + "duration": 420, + "prop": "filter" + }, + { + "target": "home-copy", + "duration": 420, + "prop": "opacity" + }, + { + "target": "home-copy", + "duration": 520, + "prop": "transform" + }, + { + "target": "portal-aura", + "duration": 720, + "prop": "transform" + }, + { + "target": "room-photo", + "duration": 720, + "prop": "filter" + }, + { + "target": "room-photo", + "duration": 720, + "prop": "opacity" + }, + { + "target": "room-photo", + "duration": 720, + "prop": "transform" + }, + { + "target": "talk-photo", + "duration": 720, + "prop": "opacity" + }, + { + "target": "talk-photo", + "duration": 720, + "prop": "transform" + }, + { + "target": "portal-top", + "duration": 280, + "prop": "opacity" + }, + { + "target": "conversation", + "duration": 420, + "prop": "opacity" + }, + { + "target": "conversation", + "duration": 520, + "prop": "transform" + }, + { + "target": "nav-glow", + "duration": 460, + "prop": "transform" + }, + { + "target": "", + "duration": 280, + "prop": "color" + }, + { + "target": "", + "duration": 280, + "prop": "color" + }, + { + "target": "portal", + "duration": 720, + "prop": "waapi" + } + ], + "right": { + "anims": [], + "ink": 0 + }, + "left_rapid": { + "anims": [], + "flow": false, + "return_visible": true + }, + "sheet_escape": { + "anims": [], + "open": false, + "mode": "talk" + }, + "auto_props": [ + "opacity", + "transform" + ], + "errors": [] +} diff --git a/prototypes/hel-793/evidence/keyboard-80ms-before-after.png b/prototypes/hel-793/evidence/keyboard-80ms-before-after.png new file mode 100644 index 0000000..54293d9 Binary files /dev/null and b/prototypes/hel-793/evidence/keyboard-80ms-before-after.png differ diff --git a/prototypes/hel-793/evidence/mobile-320.png b/prototypes/hel-793/evidence/mobile-320.png index b0aa535..37db02e 100644 Binary files a/prototypes/hel-793/evidence/mobile-320.png and b/prototypes/hel-793/evidence/mobile-320.png differ diff --git a/prototypes/hel-793/evidence/mobile-keyboard-430.png b/prototypes/hel-793/evidence/mobile-keyboard-430.png index 0a6bf70..65456ef 100644 Binary files a/prototypes/hel-793/evidence/mobile-keyboard-430.png and b/prototypes/hel-793/evidence/mobile-keyboard-430.png differ diff --git a/prototypes/hel-793/evidence/mobile-long-name.png b/prototypes/hel-793/evidence/mobile-long-name.png index 1b6fe9a..7f8d58f 100644 Binary files a/prototypes/hel-793/evidence/mobile-long-name.png and b/prototypes/hel-793/evidence/mobile-long-name.png differ diff --git a/prototypes/hel-793/evidence/motion-probe-before-pr8.json b/prototypes/hel-793/evidence/motion-probe-before-pr8.json new file mode 100644 index 0000000..5ec768f --- /dev/null +++ b/prototypes/hel-793/evidence/motion-probe-before-pr8.json @@ -0,0 +1,295 @@ +[ + { + "probe": "click-open-talk +80ms", + "data": { + "depth-veil | opacity | 720ms": 1, + "home-copy | filter | 420ms": 1, + "home-copy | opacity | 420ms": 1, + "home-copy | transform | 520ms": 1, + "portal-aura | transform | 720ms": 1, + "room-photo | filter | 720ms": 1, + "room-photo | opacity | 720ms": 1, + "room-photo | transform | 720ms": 1, + "talk-photo | opacity | 720ms": 1, + "talk-photo | transform | 720ms": 1, + "portal-top | opacity | 280ms": 1, + "conversation | opacity | 420ms": 1, + "conversation | transform | 520ms": 1, + "nav-glow | transform | 460ms": 1, + "BUTTON | color | 280ms": 2, + "portal | script | 720ms": 1 + } + }, + { + "probe": "keyboard Escape +80ms", + "data": { + "depth-veil | opacity | 720ms": 1, + "home-copy | filter | 420ms": 1, + "home-copy | opacity | 420ms": 1, + "home-copy | transform | 520ms": 1, + "portal-aura | transform | 720ms": 1, + "room-photo | filter | 720ms": 1, + "room-photo | opacity | 720ms": 1, + "room-photo | transform | 720ms": 1, + "talk-photo | opacity | 720ms": 1, + "talk-photo | transform | 720ms": 1, + "portal-top | opacity | 280ms": 1, + "conversation | opacity | 420ms": 1, + "conversation | transform | 520ms": 1, + "line | opacity | 520ms": 1, + "P | font-size | 520ms": 1, + "nav-glow | transform | 460ms": 1, + "BUTTON | color | 280ms": 2, + "SPAN | ink | 460ms": 7, + "line | script | 620ms": 1, + "line | script | 680ms": 1, + "portal | script | 720ms": 1 + } + }, + { + "probe": "keyboard Escape: mode/portal", + "data": { + "mode": "home", + "z": 0.791, + "portalW": 992 + } + }, + { + "probe": "click close-talk +80ms", + "data": { + "depth-veil | opacity | 720ms": 1, + "home-copy | filter | 420ms": 1, + "home-copy | opacity | 420ms": 1, + "home-copy | transform | 520ms": 1, + "portal-aura | transform | 720ms": 1, + "room-photo | filter | 720ms": 1, + "room-photo | opacity | 720ms": 1, + "room-photo | transform | 720ms": 1, + "talk-photo | opacity | 720ms": 1, + "talk-photo | transform | 720ms": 1, + "portal-top | opacity | 280ms": 1, + "conversation | opacity | 420ms": 1, + "conversation | transform | 520ms": 1, + "nav-glow | transform | 460ms": 1, + "BUTTON | color | 280ms": 2, + "portal | script | 720ms": 1 + } + }, + { + "probe": "keyboard ArrowRight +80ms", + "data": { + "line | opacity | 520ms": 1, + "P | font-size | 520ms": 1, + "SPAN | ink | 460ms": 26, + "line | script | 520ms": 1, + "line | script | 620ms": 1, + "line | script | 680ms": 1 + } + }, + { + "probe": "keyboard ArrowRight: ink spans", + "data": { + "ink": 26, + "lines": 2, + "cursor": 3 + } + }, + { + "probe": "keyboard ArrowLeft +80ms", + "data": { + "toggle-flow | box-shadow | 280ms": 1, + "toggle-flow | color | 280ms": 1, + "SPAN | ink | 460ms": 48, + "line | script | 620ms": 1, + "line | script | 680ms": 1, + "line | script | 240ms": 2, + "line | script | 520ms": 2 + } + }, + { + "probe": "keyboard ArrowLeft: state", + "data": { + "flow": false, + "cursor": 2, + "latestHidden": false + } + }, + { + "probe": "keyboard ArrowLeft x3 rapid +80ms", + "data": { + "previous | opacity | 160ms": 1, + "SPAN | ink | 460ms": 48, + "line | script | 240ms": 4, + "line | script | 520ms": 3 + } + }, + { + "probe": "click next +80ms", + "data": { + "P | font-size | 520ms": 1, + "previous | opacity | 160ms": 1, + "SPAN | ink | 460ms": 26, + "line | script | 520ms": 1, + "line | script | 620ms": 1, + "line | script | 680ms": 1 + } + }, + { + "probe": "auto advance +80ms", + "data": { + "anims": { + "line | opacity | 520ms": 1, + "P | font-size | 520ms": 1, + "SPAN | ink | 460ms": 29, + "line | script | 520ms": 1, + "line | script | 620ms": 1, + "line | script | 680ms": 1 + }, + "props": [ + "opacity", + "font-size" + ] + } + }, + { + "probe": "css transitions seen over 9s of auto flow", + "data": { + "value": [ + "opacity", + "font-size" + ], + "Count": 2 + } + }, + { + "probe": "layout cost: auto flow 9s", + "data": { + "layouts": 64, + "layoutMs": 31.44, + "styleRecalcs": 979, + "styleMs": 101.40, + "windowMs": 9000 + } + }, + { + "probe": "nav click +80ms transitions", + "data": { + "value": [ + "transform" + ], + "Count": 1 + } + }, + { + "probe": "layout cost: idle 1s (baseline)", + "data": { + "layouts": 0, + "layoutMs": 0.00, + "styleRecalcs": 63, + "styleMs": 10.16, + "windowMs": 1000 + } + }, + { + "probe": "layout cost: click open talk 1s", + "data": { + "layouts": 30, + "layoutMs": 3.44, + "styleRecalcs": 90, + "styleMs": 17.08, + "windowMs": 1000 + } + }, + { + "probe": "layout cost: keyboard Escape 1s", + "data": { + "layouts": 30, + "layoutMs": 3.79, + "styleRecalcs": 50, + "styleMs": 15.92, + "windowMs": 1000 + } + }, + { + "probe": "layout cost: click open + close-talk 2s", + "data": { + "layouts": 62, + "layoutMs": 7.18, + "styleRecalcs": 143, + "styleMs": 34.07, + "windowMs": 1000 + } + }, + { + "probe": "frames: idle home 900ms", + "data": { + "frames": 55, + "p50": 16.7, + "p95": 16.8, + "max": 16.8 + } + }, + { + "probe": "frames: click open talk 900ms", + "data": { + "frames": 37, + "p50": 33.3, + "p95": 33.4, + "max": 33.4 + } + }, + { + "probe": "frames: click close talk 900ms", + "data": { + "frames": 39, + "p50": 16.7, + "p95": 33.4, + "max": 66.6 + } + }, + { + "probe": "keyboard Escape in sheet +80ms", + "data": { + "sheet | script | 240ms": 1 + } + }, + { + "probe": "keyboard Escape in sheet: state", + "data": { + "open": true, + "mode": "record" + } + }, + { + "probe": "tier2 click open talk +80ms", + "data": { + "depth-veil | opacity | 720ms": 1, + "home-copy | filter | 420ms": 1, + "home-copy | opacity | 420ms": 1, + "home-copy | transform | 520ms": 1, + "portal-aura | transform | 720ms": 1, + "room-photo | filter | 720ms": 1, + "room-photo | opacity | 720ms": 1, + "room-photo | transform | 720ms": 1, + "talk-photo | opacity | 720ms": 1, + "talk-photo | transform | 720ms": 1, + "portal-top | opacity | 280ms": 1, + "conversation | opacity | 420ms": 1, + "conversation | transform | 520ms": 1, + "nav-glow | transform | 460ms": 1, + "BUTTON | color | 280ms": 2, + "portal | script | 720ms": 1 + } + }, + { + "probe": "atmos", + "data": { + "tier": 2, + "scale": 0.5, + "z": 0.188, + "theme": 0, + "motes": 18, + "drops": 59 + } + } +] diff --git a/prototypes/hel-793/evidence/motion-probe.json b/prototypes/hel-793/evidence/motion-probe.json new file mode 100644 index 0000000..1e72b30 --- /dev/null +++ b/prototypes/hel-793/evidence/motion-probe.json @@ -0,0 +1,256 @@ +[ + { + "probe": "click-open-talk +80ms", + "data": { + "depth-veil | opacity | 720ms": 1, + "home-copy | filter | 420ms": 1, + "home-copy | opacity | 420ms": 1, + "home-copy | transform | 520ms": 1, + "portal-aura | transform | 720ms": 1, + "room-photo | filter | 720ms": 1, + "room-photo | opacity | 720ms": 1, + "room-photo | transform | 720ms": 1, + "talk-photo | opacity | 720ms": 1, + "talk-photo | transform | 720ms": 1, + "portal-top | opacity | 280ms": 1, + "conversation | opacity | 420ms": 1, + "conversation | transform | 520ms": 1, + "nav-glow | transform | 460ms": 1, + "BUTTON | color | 280ms": 2, + "portal | script | 720ms": 1 + } + }, + { + "probe": "keyboard Escape +80ms", + "data": { + + } + }, + { + "probe": "keyboard Escape: mode/portal", + "data": { + "mode": "home", + "z": 0, + "portalW": 389 + } + }, + { + "probe": "click close-talk +80ms", + "data": { + "depth-veil | opacity | 720ms": 1, + "home-copy | filter | 420ms": 1, + "home-copy | opacity | 420ms": 1, + "home-copy | transform | 520ms": 1, + "portal-aura | transform | 720ms": 1, + "room-photo | filter | 720ms": 1, + "room-photo | opacity | 720ms": 1, + "room-photo | transform | 720ms": 1, + "talk-photo | opacity | 720ms": 1, + "talk-photo | transform | 720ms": 1, + "portal-top | opacity | 280ms": 1, + "conversation | opacity | 420ms": 1, + "conversation | transform | 520ms": 1, + "nav-glow | transform | 460ms": 1, + "BUTTON | color | 280ms": 2, + "portal | script | 720ms": 1 + } + }, + { + "probe": "keyboard ArrowRight +80ms", + "data": { + + } + }, + { + "probe": "keyboard ArrowRight: ink spans", + "data": { + "ink": 0, + "lines": 2, + "cursor": 3 + } + }, + { + "probe": "keyboard ArrowLeft +80ms", + "data": { + + } + }, + { + "probe": "keyboard ArrowLeft: state", + "data": { + "flow": false, + "cursor": 2, + "latestHidden": false + } + }, + { + "probe": "keyboard ArrowLeft x3 rapid +80ms", + "data": { + + } + }, + { + "probe": "click next +80ms", + "data": { + "previous | opacity | 160ms": 1, + "SPAN | ink | 460ms": 26, + "line | script | 520ms": 1, + "line | script | 620ms": 1, + "line | script | 680ms": 1 + } + }, + { + "probe": "auto advance +80ms", + "data": { + "anims": { + "SPAN | ink | 460ms": 29, + "line | script | 520ms": 2, + "line | script | 620ms": 1, + "line | script | 680ms": 1 + }, + "props": [ + + ] + } + }, + { + "probe": "css transitions seen over 9s of auto flow", + "data": { + "value": [ + + ], + "Count": 0 + } + }, + { + "probe": "layout cost: auto flow 9s", + "data": { + "layouts": 10, + "layoutMs": 5.56, + "styleRecalcs": 984, + "styleMs": 105.48, + "windowMs": 9000 + } + }, + { + "probe": "nav click +80ms transitions", + "data": { + "value": [ + "transform" + ], + "Count": 1 + } + }, + { + "probe": "layout cost: idle 1s (baseline)", + "data": { + "layouts": 0, + "layoutMs": 0.00, + "styleRecalcs": 61, + "styleMs": 8.92, + "windowMs": 1000 + } + }, + { + "probe": "layout cost: click open talk 1s", + "data": { + "layouts": 31, + "layoutMs": 3.47, + "styleRecalcs": 88, + "styleMs": 16.69, + "windowMs": 1000 + } + }, + { + "probe": "layout cost: keyboard Escape 1s", + "data": { + "layouts": 1, + "layoutMs": 0.16, + "styleRecalcs": 105, + "styleMs": 14.92, + "windowMs": 1000 + } + }, + { + "probe": "layout cost: click open + close-talk 2s", + "data": { + "layouts": 62, + "layoutMs": 7.52, + "styleRecalcs": 136, + "styleMs": 31.74, + "windowMs": 1000 + } + }, + { + "probe": "frames: idle home 900ms", + "data": { + "frames": 55, + "p50": 16.7, + "p95": 16.7, + "max": 16.7 + } + }, + { + "probe": "frames: click open talk 900ms", + "data": { + "frames": 37, + "p50": 16.7, + "p95": 50, + "max": 50 + } + }, + { + "probe": "frames: click close talk 900ms", + "data": { + "frames": 39, + "p50": 16.7, + "p95": 50, + "max": 50 + } + }, + { + "probe": "keyboard Escape in sheet +80ms", + "data": { + + } + }, + { + "probe": "keyboard Escape in sheet: state", + "data": { + "open": false, + "mode": "talk" + } + }, + { + "probe": "tier2 click open talk +80ms", + "data": { + "depth-veil | opacity | 720ms": 1, + "home-copy | filter | 420ms": 1, + "home-copy | opacity | 420ms": 1, + "home-copy | transform | 520ms": 1, + "portal-aura | transform | 720ms": 1, + "room-photo | filter | 720ms": 1, + "room-photo | opacity | 720ms": 1, + "room-photo | transform | 720ms": 1, + "talk-photo | opacity | 720ms": 1, + "talk-photo | transform | 720ms": 1, + "portal-top | opacity | 280ms": 1, + "conversation | opacity | 420ms": 1, + "conversation | transform | 520ms": 1, + "nav-glow | transform | 460ms": 1, + "BUTTON | color | 280ms": 2, + "portal | script | 320ms": 1 + } + }, + { + "probe": "atmos", + "data": { + "tier": 2, + "scale": 0.5, + "z": 0.188, + "theme": 0, + "motes": 18, + "drops": 59 + } + } +] diff --git a/prototypes/hel-793/evidence/portal-ab.json b/prototypes/hel-793/evidence/portal-ab.json new file mode 100644 index 0000000..840b192 --- /dev/null +++ b/prototypes/hel-793/evidence/portal-ab.json @@ -0,0 +1,26 @@ +{ + "1440x900 geometry": { + "transitions": 12, + "avgFrames": 39.3, + "avgLongFrames": 15.75, + "worstFrameMs": 50.1 + }, + "1440x900 no-geometry": { + "transitions": 12, + "avgFrames": 50.2, + "avgLongFrames": 5.17, + "worstFrameMs": 50.1 + }, + "390x844 geometry": { + "transitions": 12, + "avgFrames": 55.2, + "avgLongFrames": 0, + "worstFrameMs": 16.8 + }, + "390x844 no-geometry": { + "transitions": 12, + "avgFrames": 55, + "avgLongFrames": 0, + "worstFrameMs": 16.8 + } +} diff --git a/prototypes/hel-793/evidence/verification.json b/prototypes/hel-793/evidence/verification.json index db6b4ba..000722b 100644 --- a/prototypes/hel-793/evidence/verification.json +++ b/prototypes/hel-793/evidence/verification.json @@ -4,7 +4,7 @@ "pass": true, "detail": { "seconds": 52, - "trail": "1/2 2/4 2/5 2/7 2/8 2/9 3/11 3/12 3/12 3/12 3/12 3/12 3/12 4/12 4/12 4/12 4/12 4/12 5/12 5/12 5/12 5/12 5/12 6/12 6/12 6/12 6/12 7/12 7/12 7/12 7/12 7/12 8/12 8/12 8/12 8/12 8/12 8/12 9/12 9/12 9/12 9/12 10/12 10/12 10/12 10/12 10/12 11/12 11/12 11/12 11/12 12/12", + "trail": "1/2 2/4 2/5 2/7 2/8 2/9 3/11 3/12 3/12 3/12 3/12 3/12 3/12 4/12 4/12 4/12 4/12 4/12 5/12 5/12 5/12 5/12 5/12 5/12 6/12 6/12 6/12 7/12 7/12 7/12 7/12 7/12 8/12 8/12 8/12 8/12 8/12 8/12 9/12 9/12 9/12 9/12 10/12 10/12 10/12 10/12 10/12 11/12 11/12 11/12 11/12 12/12", "final": { "c": 11, "n": 12, @@ -82,7 +82,7 @@ "pass": true, "detail": { "status": "cancelled", - "len": 86, + "len": 115, "busy": false } }, diff --git a/prototypes/hel-793/handoff-hel-795.json b/prototypes/hel-793/handoff-hel-795.json new file mode 100644 index 0000000..e7022bc --- /dev/null +++ b/prototypes/hel-793/handoff-hel-795.json @@ -0,0 +1,37 @@ +{ + "task": "HEL-795 视觉总监修正键盘回看/退出与换句时的动效拖慢(基于 PR#8 原作品小修)", + "baseline_version": "1.8", + "read_commit": "3a986726d8593e0ed2274f79db6cc5d807d2ebf0", + "read_notes": [ + "完整读取 main 3a986726d8593e0ed2274f79db6cc5d807d2ebf0(v1.8,含 PR#7)的 AGENTS.md、docs/requirements/README.md、current.md、coverage.md、task-handoff-template.md、scripts/check_requirements.py。", + "修改基础:PR#8 分支 agent/agent/cd47da943500 head a576785535224189742725ade2e3b06656e6ee4f 的 prototypes/hel-793(经 Gitea API 核对 PR#8 仍为 open、未合并、base=main)。未带入产品 PR#4 或旧稿 PR#6。", + "已读 HEL-793 原交付线程 01a16406-e51d-7052-b62d-d8558450c453 及其入库补充回复。" + ], + "requirement_ids": ["CG-004", "CG-018", "CG-024", "CG-031", "VIS-001", "VIS-002", "VIS-003", "VIS-004", "VIS-005", "VIS-006", "VIS-007", "VIS-008", "VIS-009", "VIS-010", "VIS-011", "VIS-012", "VIS-013", "UX-001", "UX-002", "UX-003", "UX-005", "UX-006", "AM-001", "AM-002", "AM-003", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"], + "deliverable": "prototypes/hel-793 的定点修正(视觉总监亲自编写):src/space.js、src/space.css、src/atmos.js、重建后的 index.html、README.md 动效表与性能说明、新增 tools/motion-probe.ps1 与 tools/portal-ab.ps1、evidence 下的新检查结果和对照图。阅读节奏、配色、人物素材、功能范围与 window.__proto 测试钩子均未改。", + "acceptance": [ + "键盘 Esc(交流中):80ms 后仍在运行的有限动画由 21 组(人物窗 720ms 几何、交谈层 520ms、换句 620/680ms、逐字 460ms 等)降为 0;人物窗、镜头深度(z=0)直接到位。浮层中按 Esc 同样立即关闭并回到交流(evidence/motion-probe.json)。", + "键盘 → / ←:80ms 后有限动画由 6 组(含 26 个逐字动画)降为 0,当前句不再重播逐字;← 仍自动暂停并出现「回到读到的地方」;连按三次 ← 也没有残留动画。", + "鼠标点击进入/离开交流的动画与 PR#8 完全相同;点击下一句和自动续读保留新句进入、逐字和柔和退场,只把退场里的 font-size 插值换成固定字号交叉。没有全局关闭动效(同一份 probe 的 click-* 与 auto advance 项)。", + "换句时不再出现 font-size 过渡:自动续读 9 秒内的 CSS 过渡属性由 [opacity, font-size] 变为无;同时段布局次数由 64 次(31.4ms)降为 10 次(5.6ms)。柔和退场改为两份固定字号副本交叉,只动 transform/opacity,最终排版与原来一致(evidence/handoff-frames-after.png)。", + "导航亮块不再有 width 过渡,只移动 transform;四个入口改为同宽(桌面 68px、手机 64px,与原实际宽度一致)。", + "人物窗几何:保留实际几何(缩放会拉歪圆角和照片,clip-path 需要两种裁切且结束时跳变)。实测布局约 0.1ms/帧;重绘是主要成本:电脑 1440×900 软件光栅下,每次展开或收回比去掉几何多约 10 帧超过 25ms 的长帧,手机 390×844 无差别(evidence/portal-ab.json)。新增降级:Canvas 降到档 2 时,人物窗改为 320ms 淡入。", + "默认自动长回复不点击走到最后(12 句约 52 秒,与 PR#8 相同);暂停、回看、回到进度、全文、记录、取消保留内容、新消息恢复、双方身份、两主题、手机矮屏与键盘等效视口 19 项行为检查全部通过(evidence/verification.json)。" + ], + "deferred": [ + "用户体验验收:修正版尚未经白栖知体验;认可前不替换正式界面(8310/8300),不合并 PR#8 或 PR#4。", + "入库:本次运行仍在没有 git、仓库 SSH 凭据和 Python 的 Windows 运行时上,无法推送到 PR#8 分支;源码改动以附件交付,需总管安排有写权限的环境更新 PR#8。", + "需求检查:未能运行 python3 scripts/check_requirements.py --base-ref origin/main、--handoff,也未能运行 git diff --check;只用 PowerShell 按脚本的 --handoff 规则核对了本 JSON,并检查了改动文件的行尾空白。须在入库环境补跑。", + "真实 GPU 帧率、真机软键盘、iOS/macOS Safari、Firefox 未验证;本次帧率数据来自无头 Edge 软件光栅,只能做相对比较。", + "emil-skills 在本运行时不可用,未能查阅 review-animations 与 STANDARDS;按任务卡摘录的规则执行(键盘快捷操作不做动画、可打断、避免 font-size/width 等布局动画、优先 transform/opacity、尊重减少动效)。", + "完整生活/回忆/创建/管理状态、R1 个性成长、R2–R7 真实接入及口型仍由主卡 HEL-697 承接。" + ], + "requirement_changes": [], + "verification": [ + "evidence/motion-probe.json 与 evidence/motion-probe-before-pr8.json:同一脚本分别在修正版和 PR#8 原版上运行(无头 Edge,CDP 真实按键事件)。", + "evidence/portal-ab.json:人物窗保留几何与去掉几何的帧间隔 A/B,电脑与手机各 12 次过渡。", + "evidence/keyboard-80ms-before-after.png:按 Esc、→ 后第 80ms 冻结画面,左为 PR#8,右为修正版。", + "evidence/handoff-frames-after.png:自动续读换句时 0–700ms 六个时刻的冻结画面,已实际查看,柔和退场保留、最终排版不变。", + "evidence/verification.json 与 evidence 中由 verify.ps1 重新生成的截图(desktop-long-end、desktop-review、mobile-long-name、mobile-keyboard-430、mobile-320、desktop-tier2-fog):19 项全部通过,截图已查看。其余旧截图和录屏未重录,因为本次没有改变静止画面。" + ] +} diff --git a/prototypes/hel-793/index.html b/prototypes/hel-793/index.html index 4d5c165..8059fb2 100644 --- a/prototypes/hel-793/index.html +++ b/prototypes/hel-793/index.html @@ -117,7 +117,8 @@ body[data-mode="talk"] .conversation{opacity:1;transform:translate(-50%,0);point .line p{font-size:21px;font-weight:350;line-height:1.72;overflow-wrap:anywhere;text-shadow:0 2px 20px #030719,0 0 2px #03071980;color:var(--text-1)} .line[data-role="user"] p{color:var(--user);font-size:19px} .line.is-prev{opacity:.42;transition:opacity 520ms var(--ease-out)} -.line.is-prev p{font-size:15px;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden;transition:font-size 520ms var(--ease-out)} +/* Fixed size: the hand-off from current → previous is a crossfade of two copies (space.js), never a font-size tween. */ +.line.is-prev p{font-size:15px;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden} .line.is-prev .who{margin-bottom:0;font-size:11px}.line.is-prev .dwell{display:none} .line .dwell{display:block;width:56px;height:2px;margin:9px auto 0;border-radius:2px;background:var(--accent-soft);overflow:hidden} .line .dwell i{display:block;height:100%;background:var(--accent);box-shadow:0 0 8px var(--accent);transform-origin:left;transform:scaleX(0)} @@ -148,8 +149,8 @@ body.reading-paused .line .dwell{opacity:.25}body.reading-held .line .dwell i{op /* ── Navigation ── */ .navigation{position:absolute;left:50%;bottom:calc(18px + var(--safe-bottom));transform:translateX(-50%);display:flex;padding:5px;border-radius:40px;z-index:5;transition:transform 420ms var(--ease-out),opacity 280ms var(--ease-out)} -.nav-glow{position:absolute;left:0;top:5px;bottom:5px;border-radius:32px;background:var(--accent-soft);box-shadow:0 0 22px var(--accent-glow),inset 0 0 12px var(--accent-soft);transition:transform 460ms var(--ease-out),width 460ms var(--ease-out),opacity 280ms;pointer-events:none} -.navigation button{position:relative;min-width:68px;min-height:51px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;padding:4px 15px;color:var(--text-2);border-radius:32px;font-size:12px} +.nav-glow{position:absolute;left:0;top:5px;bottom:5px;border-radius:32px;background:var(--accent-soft);box-shadow:0 0 22px var(--accent-glow),inset 0 0 12px var(--accent-soft);transition:transform 460ms var(--ease-out),opacity 280ms;pointer-events:none} +.navigation button{position:relative;width:68px;min-width:68px;min-height:51px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;padding:4px 15px;color:var(--text-2);border-radius:32px;font-size:12px} .navigation svg{width:18px;height:18px}.navigation [aria-current="page"]{color:var(--accent)} .toast{position:absolute;left:50%;top:84px;transform:translate(-50%,-8px);padding:8px 16px;border-radius:20px;font-size:12px;color:var(--text-1);background:var(--glass-read);border:1px solid var(--glass-line);backdrop-filter:blur(14px);opacity:0;pointer-events:none;transition:opacity 280ms var(--ease-out),transform 280ms var(--ease-out);z-index:6;white-space:nowrap;max-width:calc(100vw - 32px);overflow:hidden;text-overflow:ellipsis} .toast.show{opacity:1;transform:translate(-50%,0)} @@ -217,7 +218,7 @@ body.reading-paused .line .dwell{opacity:.25}body.reading-held .line .dwell i{op .stage{padding:0 8px;gap:6px}.line p{font-size:17px;line-height:1.75}.line[data-role="user"] p{font-size:16px}.line.is-prev p{font-size:13px} .reader-bar{gap:2px;margin:6px 0 8px;font-size:11px}.reader-bar>button{padding:0 8px}.reader-bar .count{min-width:44px} .composer{padding:5px;gap:3px}.composer .round,.voice-button{width:40px;height:40px}.composer textarea{font-size:16px;padding:7px 3px;height:40px} - .navigation{bottom:calc(14px + var(--safe-bottom));padding:4px}.nav-glow{top:4px;bottom:4px}.navigation button{min-width:64px;min-height:46px;padding:3px 14px;font-size:11px}.navigation svg{width:17px;height:17px} + .navigation{bottom:calc(14px + var(--safe-bottom));padding:4px}.nav-glow{top:4px;bottom:4px}.navigation button{width:64px;min-width:64px;min-height:46px;padding:3px 14px;font-size:11px}.navigation svg{width:17px;height:17px} .toast{top:62px;font-size:11px} .sheet,.sheet[data-kind="history"],.sheet[data-kind="full"]{left:10px;right:10px;width:calc(100% - 20px);top:calc(var(--view-top) + 62px);height:calc(var(--view-h) - 146px - var(--safe-bottom));border-radius:24px} .sheet-header{padding:16px 18px;height:84px}.sheet-content{padding:18px 20px 28px;height:calc(100% - 84px)}h2{font-size:20px}.quote{font-size:18px} @@ -249,6 +250,8 @@ body.micro .reader-bar .count,body.micro #read-all{display:none} /* ── Motion preferences and fallbacks ── */ html.no-motion *,html.no-motion *::before,html.no-motion *::after{animation:none!important;transition-duration:1ms!important} body.suspended *{animation-play-state:paused!important} +/* Keyboard shortcuts (space.js snap): land on the target state with no transition; ambient loops keep running. */ +body.snap *,body.snap *::before,body.snap *::after{transition:none!important} @media(prefers-reduced-motion:reduce){ *,*::before,*::after{animation:none!important} .portal img,.home-copy,.conversation,.portal-aura,.depth-veil,.navigation,.nav-glow{transition:opacity 200ms linear!important} @@ -699,7 +702,7 @@ const Atmos = (() => { document.addEventListener('visibilitychange', () => { running = !document.hidden; if (running) { last = 0; requestAnimationFrame(frame); } }); resize(); requestAnimationFrame(frame); return { - setDepth(z) { cam.zt = z; }, + setDepth(z, instant) { cam.zt = z; if (instant) { cam.z = z; cam.zv = 0; } }, setTheme(name, instant) { themeTarget = name === 'fog' ? 1 : 0; if (instant) themeMix = themeTarget; }, info() { return {tier, scale, z: +cam.z.toFixed(3), theme: +themeMix.toFixed(2), motes: motes.length, drops: drops.length}; }, setTier(t) { tier = t; frameCost = []; resize(); document.documentElement.dataset.tier = t; } @@ -758,28 +761,43 @@ function portalBox() { const r = portal.getBoundingClientRect(), w = $('#world').getBoundingClientRect(), cs = getComputedStyle(portal); return {left: r.left - w.left, top: r.top - w.top, width: r.width, height: r.height, radius: cs.borderRadius, opacity: cs.opacity}; } -function setMode(mode) { +// Keyboard shortcuts land on the target state at once: CSS transitions are suspended for two frames +// and any finite animation already under way (geometry, line moves, ink) jumps to its end. +function snap() { + document.body.classList.add('snap'); + for (const a of document.getAnimations()) if (a.effect && a.effect.getTiming().iterations !== Infinity) a.finish(); + requestAnimationFrame(() => requestAnimationFrame(() => document.body.classList.remove('snap'))); +} +function setMode(mode, {instant = false} = {}) { const before = portalBox(); if (portalAnim) { portalAnim.cancel(); portalAnim = null; } + if (instant) snap(); state.mode = mode; document.body.dataset.mode = mode; - Atmos.setDepth(DEPTH[mode]); + Atmos.setDepth(DEPTH[mode], instant); $('#scene-label').textContent = mode === 'talk' || mode === 'record' ? '交流近景' : '窗边看书'; $('.home-copy').inert = mode !== 'home'; $('#conversation').inert = mode !== 'talk'; portal.style.height = ''; if (mode === 'talk') fitTalkStage(); const after = portalBox(); - if (!motionOff() && before.width && after.width) { - // Animate real geometry (not a scale), so the arch → frame radius and the photo never distort. - portalAnim = portal.animate([ - {left: before.left + 'px', top: before.top + 'px', width: before.width + 'px', height: before.height + 'px', borderRadius: before.radius, opacity: before.opacity, right: 'auto', transform: 'none'}, - {left: after.left + 'px', top: after.top + 'px', width: after.width + 'px', height: after.height + 'px', borderRadius: after.radius, opacity: after.opacity, right: 'auto', transform: 'none'} - ], {duration: 720, easing: EASE_SPACE}); + if (!instant && !motionOff() && before.width && after.width) { + if (html.dataset.tier === '2') { + // Slow devices: no per-frame layout; she simply fades in at the new place. + portalAnim = portal.animate([{opacity: 0}, {opacity: after.opacity}], {duration: 320, easing: EASE}); + } else { + // Animate real geometry (not a scale), so the arch → frame radius and the photo never distort. + // Measured (evidence/portal-ab.json): layout ~0.1ms/frame (absolute box, own subtree); repaint of the large desktop window is the real cost. + portalAnim = portal.animate([ + {left: before.left + 'px', top: before.top + 'px', width: before.width + 'px', height: before.height + 'px', borderRadius: before.radius, opacity: before.opacity, right: 'auto', transform: 'none'}, + {left: after.left + 'px', top: after.top + 'px', width: after.width + 'px', height: after.height + 'px', borderRadius: after.radius, opacity: after.opacity, right: 'auto', transform: 'none'} + ], {duration: 720, easing: EASE_SPACE}); + } portalAnim.finished.then(() => { portalAnim = null; }).catch(() => {}); } $$('.navigation button').forEach(n => n.dataset.nav === (mode === 'pane' || mode === 'record' ? state.dialogNav : mode) ? n.setAttribute('aria-current', 'page') : n.removeAttribute('aria-current')); moveNavGlow(); - if (mode === 'talk') { present('jump'); } + if (mode === 'talk') { present(instant ? 'instant' : 'jump'); } } +// The glow only slides (transform); its width is set without transition — all nav items share one width. function moveNavGlow() { const cur = $('.navigation [aria-current]'), glow = $('.nav-glow'); if (!cur) { glow.style.opacity = 0; return; } @@ -830,13 +848,13 @@ function dwellFor(page) { const n = graphemes(page.text).length; return page.message.role === 'user' ? Math.min(3000, Math.max(1400, 900 + n * 60)) : Math.min(8000, Math.max(2400, 1500 + n * 150)); } -function lineEl(page, current) { +function lineEl(page, current, animate = true) { const el = document.createElement('div'); el.className = 'line' + (current ? ' is-current' : ' is-prev'); el.dataset.role = page.message.role; el.dataset.key = page.key; const label = page.message.role === 'user' ? '你' : state.name; const chars = graphemes(page.text); - const ink = current && !motionOff() && page.message.role === 'her'; + const ink = current && animate && !motionOff() && page.message.role === 'her'; const body = ink ? chars.map((c, i) => `${escapeHTML(c)}`).join('') : escapeHTML(page.text); el.innerHTML = `${escapeHTML(label)}

${body}

`; return el; @@ -854,19 +872,29 @@ function present(how = 'advance') { const keys = have.map(n => n.dataset.key); state.dwell = 0; state.dwellTotal = dwellFor(page); announce(`${who(page.message)}:${page.text}`); + if (how === 'instant') { + // Keyboard review: show the target lines as they are — no slide, no re-played ink. + stage.replaceChildren(...want.map(p => lineEl(p, p === page, false))); + refreshState(); return; + } if (keys.join() === want.map(p => p.key).join()) { refreshState(); return; } const animate = !motionOff() && state.mode === 'talk'; - // Forward by one: the current line rises into the "previous" slot, the oldest drifts away. + // Forward by one: the finished line lifts toward the "previous" slot while a small copy settles there; the oldest drifts away. + // Both copies keep a fixed font size (no per-frame re-typesetting); only transform/opacity move. if (how === 'advance' && animate && keys[keys.length - 1] === (prev && prev.key)) { const first = new Map(have.map(n => [n, n.getBoundingClientRect()])); const stageRect = stage.getBoundingClientRect(); for (const n of have) if (n.dataset.key !== prev.key) leave(n, first.get(n), stageRect); - const keep = have.find(n => n.dataset.key === prev.key); - keep.classList.replace('is-current', 'is-prev'); - keep.querySelector('p').classList.remove('ink'); - const fresh = lineEl(page, true); stage.append(fresh); - const was = first.get(keep), now = keep.getBoundingClientRect(); - keep.animate([{transform: `translateY(${was.top - now.top}px)`}, {transform: 'none'}], {duration: 620, easing: EASE}); + const keep = have.find(n => n.dataset.key === prev.key), was = first.get(keep); + const settled = lineEl(prev, false), fresh = lineEl(page, true); + pin(keep, was, stageRect); + stage.append(settled, fresh); + const to = settled.getBoundingClientRect(); + if (to.height) { + const shrink = parseFloat(getComputedStyle(settled.querySelector('p')).fontSize) / parseFloat(getComputedStyle(keep.querySelector('p')).fontSize); + leave(keep, was, stageRect, false, to.top - was.top, shrink); + settled.animate([{opacity: 0, transform: `translateY(${Math.round((was.top - to.top) * .35)}px)`}, {opacity: getComputedStyle(settled).opacity, transform: 'none'}], {duration: 620, easing: EASE}); + } else leave(keep, was, stageRect); // compact screens hide the previous line fresh.animate([{opacity: 0, transform: 'translateY(16px)', filter: 'blur(8px)'}, {opacity: 1, transform: 'none', filter: 'blur(0)'}], {duration: 680, easing: EASE}); } else { // Jumps (review, new turn, resize) crossfade instead of sliding, so direction never lies. @@ -879,10 +907,14 @@ function present(how = 'advance') { } refreshState(); } -function leave(node, rect, stageRect, quick = false) { +// Take a line out of the flow at its current place, so the remaining lines lay out once at their final size. +function pin(node, rect, stageRect) { node.classList.add('leaving'); - Object.assign(node.style, {position: 'absolute', left: rect.left - stageRect.left + 'px', top: rect.top - stageRect.top + 'px', width: rect.width + 'px'}); - node.animate([{opacity: getComputedStyle(node).opacity, transform: 'none', filter: 'blur(0)'}, {opacity: 0, transform: `translateY(${quick ? -4 : -14}px)`, filter: 'blur(5px)'}], + Object.assign(node.style, {position: 'absolute', left: rect.left - stageRect.left + 'px', top: rect.top - stageRect.top + 'px', width: rect.width + 'px', transformOrigin: '50% 0'}); +} +function leave(node, rect, stageRect, quick = false, lift = quick ? -4 : -14, shrink = 1) { + pin(node, rect, stageRect); + node.animate([{opacity: getComputedStyle(node).opacity, transform: 'none', filter: 'blur(0)'}, {opacity: 0, transform: `translateY(${lift}px) scale(${shrink})`, filter: 'blur(5px)'}], {duration: quick ? 240 : 520, easing: EASE, fill: 'forwards'}).finished.then(() => node.remove()).catch(() => node.remove()); } function refreshState() { @@ -909,13 +941,14 @@ function updateControls(list = pages()) { document.body.classList.toggle('reading-paused', !state.flow); $('#portal-state').textContent = state.busy ? '正在回应 · 演示' : state.voice ? '语音界面演示' : '在你面前'; } -function go(delta, manual = true) { +function go(delta, manual = true, instant = false) { const n = pages().length; const to = Math.max(0, Math.min(state.cursor + delta, n - 1)); if (to === state.cursor) return; state.cursor = to; - present(delta === 1 ? 'advance' : 'jump'); + present(instant ? 'instant' : delta === 1 ? 'advance' : 'jump'); } +function back(instant = false) { if (state.flow) setFlow(false, true); go(-1, true, instant); updateControls(); } function setFlow(on, quiet = false) { state.flow = on; state.dwell = 0; updateControls(); @@ -987,17 +1020,19 @@ function showSheet(kind, title, kicker, markup, nav = state.dialogNav) { if (!motionOff()) sheet.animate([{opacity: 0, transform: 'translateY(18px) scale(.985)', filter: 'blur(6px)'}, {opacity: 1, transform: 'none', filter: 'blur(0)'}], {duration: 460, easing: EASE}); $('#close-sheet').focus({preventScroll: true}); } -function finishClose() { +function finishClose(instant = false) { sheet.getAnimations().forEach(a => a.cancel()); if (sheet.open) sheet.close(); state.dialog = null; closing = null; hold('sheet', false); state.dialogNav = null; - setMode(['pane', 'record'].includes(state.returnMode) ? 'home' : state.returnMode); + setMode(['pane', 'record'].includes(state.returnMode) ? 'home' : state.returnMode, {instant}); if (trigger && trigger.isConnected && !trigger.inert) trigger.focus({preventScroll: true}); } -function closeSheet() { - if (closing || !sheet.open) return; +function closeSheet(instant = false) { + if (!sheet.open) return; + if (instant) { if (closing) closing.cancel(); finishClose(true); return; } + if (closing) return; if (motionOff()) { finishClose(); return; } closing = sheet.animate([{opacity: 1, transform: 'none', filter: 'blur(0)'}, {opacity: 0, transform: 'translateY(14px) scale(.985)', filter: 'blur(4px)'}], {duration: 240, easing: EASE, fill: 'forwards'}); - closing.finished.then(finishClose).catch(() => {}); + closing.finished.then(() => finishClose()).catch(() => {}); } function renderHistory() { const keep = content.scrollTop; @@ -1090,10 +1125,10 @@ $('#start-talk').onclick = () => openTalk(); $('#shared-note').onclick = () => { openTalk(); showHistory(); }; $('#days-link').onclick = () => showDays(); $('#close-talk').onclick = () => setMode('home'); -$('#history').onclick = showHistory; $('#read-all').onclick = showFull; $('#close-sheet').onclick = closeSheet; $('#rehearse').onclick = showRehearsal; +$('#history').onclick = showHistory; $('#read-all').onclick = showFull; $('#close-sheet').onclick = () => closeSheet(); $('#rehearse').onclick = showRehearsal; $('#home-voice').onclick = showVoice; $('#voice').onclick = showVoice; $('#end-voice').onclick = () => { state.voice = false; document.body.classList.remove('has-voice'); $('#voice-row').hidden = true; cancelReply(true); fitTalkStage(); toast('语音界面演示已结束'); }; -$('#previous').onclick = () => { if (state.flow) setFlow(false, true); go(-1); updateControls(); }; +$('#previous').onclick = () => back(); $('#next').onclick = () => go(1); $('#toggle-flow').onclick = () => setFlow(!state.flow); $('#latest').onclick = () => { state.cursor = state.head; present('jump'); setFlow(true, true); }; @@ -1107,7 +1142,7 @@ $('#draft').addEventListener('blur', () => document.body.classList.remove('typin stage.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') hold('hover', true); }); stage.addEventListener('pointerleave', () => hold('hover', false)); document.addEventListener('selectionchange', () => { const s = getSelection(); hold('select', !!(String(s).length && stage.contains(s.anchorNode))); }); -sheet.addEventListener('cancel', e => { e.preventDefault(); closeSheet(); }); +sheet.addEventListener('cancel', e => { e.preventDefault(); closeSheet(true); }); // Escape document.addEventListener('submit', e => { if (e.target.id === 'relation-form') { e.preventDefault(); state.relation = $('#relation').value; state.boundaries = $('#boundaries').value; $('#form-status').textContent = '演示选择已确认。记忆和底色保持原样。'; } else if (e.target.id === 'city-form') { e.preventDefault(); $('.weather').innerHTML = `${escapeHTML($('#city').value)} · 小雨 14° 示例`; $('#form-status').textContent = '已保存演示选择;这里没有查询真实天气。'; } @@ -1119,10 +1154,11 @@ document.addEventListener('submit', e => { state.relation = relation; state.returnMode = 'talk'; setName(name); finishClose(); openTalk(); toast('临时演示身份已建立 · 未接触真实档案'); } }); +// Keyboard shortcuts are frequent, deliberate jumps: they show the target state at once (snap), unlike clicks and auto-flow. document.addEventListener('keydown', e => { - if (e.key === 'Escape' && !sheet.open && state.mode === 'talk') { setMode('home'); $('#start-talk').focus(); } + if (e.key === 'Escape' && !sheet.open && state.mode === 'talk') { setMode('home', {instant: true}); $('#start-talk').focus(); } if (state.mode === 'talk' && !sheet.open && document.activeElement !== $('#draft')) { - if (e.key === 'ArrowLeft') $('#previous').click(); else if (e.key === 'ArrowRight') go(1); else if (e.key === ' ' && document.activeElement === document.body) { e.preventDefault(); setFlow(!state.flow); } + if (e.key === 'ArrowLeft') { snap(); back(true); } else if (e.key === 'ArrowRight') { snap(); go(1, true, true); } else if (e.key === ' ' && document.activeElement === document.body) { e.preventDefault(); setFlow(!state.flow); } } }); document.addEventListener('visibilitychange', () => document.body.classList.toggle('suspended', document.hidden)); diff --git a/prototypes/hel-793/integration-hel-795.md b/prototypes/hel-793/integration-hel-795.md new file mode 100644 index 0000000..7274567 --- /dev/null +++ b/prototypes/hel-793/integration-hel-795.md @@ -0,0 +1,34 @@ +## HEL-795 入库补充 + +视觉设计和本次核心修正均由视觉总监亲自完成。总管接收 HEL-795 评论 `01a16447-b6c4-7aa0-bf9a-08671d77b700` 的源码附件 `01a16447-b0e3-727b-81df-0152f9c8f53a`,按原路径导入,不另做视觉实现。 + +基准 v1.8,已完整读取 main `3a986726d8593e0ed2274f79db6cc5d807d2ebf0` 的 AGENTS.md、需求入口、current、coverage、交接模板及本次关联原始来源。关联编号和未完成承接见 `handoff-hel-795.json`;需求变更无。原作者交接保留当时事实,本补充关闭其中入库与 Python 检查的待办。 + +PR#8 原 head `a576785535224189742725ade2e3b06656e6ee4f`,源分支 `agent/agent/cd47da943500`,目标 main,已用现有 SSH 身份认证读取真实 PR,确认 open、未合并。没有带入产品 PR#4 或旧稿 PR#6。 + +## 本环境实际验证 + +- `python3 prototypes/hel-793/build.py` 成功,HTML 与附件内文件逐字节一致。SHA256:`95c4325265bc114a745a43681e9d2bf4c7e5573ccae576370dcd793bcf570b4b`。 +- `python3 scripts/check_requirements.py --base-ref origin/main` 及加 `--handoff prototypes/hel-793/handoff-hel-795.json` 均通过59项;`git diff --check` 通过。检查不代表用户体验认可。 +- Linux Playwright / Chromium 无头实跑:交流 Esc、右箭头、连续三次左箭头、浮层 Esc 后80ms有限动画均为0;右箭头逐字 span 为0;左箭头暂停并保留回到进度入口;浮层关闭返回交流;点击进入交流保留17个动画实例。自动续读9秒采样仅 opacity/transform,无 font-size/width 过渡,无 JS 报错。原始结果 `evidence/integration-keyboard-chromium.json`。 +- 初次启动浏览器缺少 libatk,使用本机已经存在的 `/home/xiaomo/.local/pwlibs/root/usr/lib/x86_64-linux-gnu` 作为该命令的 LD_LIBRARY_PATH 后完成验证。没有安装依赖或修改共享配置。 +- 总管实际查看 `evidence/keyboard-80ms-before-after.png` 和 `evidence/handoff-frames-after.png`:键盘操作目标状态清楚,换句仍有柔和进入/退场。未把截图当成实时帧率证据。 +- 原作者19项 Edge 行为检查和人物窗 A/B 性能数字为其报告,本轮未全量重跑;交总工定点复检。 + +## emil-skills 定点核对 + +已阅读 review-animations/MAIN.md 与 STANDARDS.md。用户要求保留自然展开、柔和文字,优先于通用时长建议。 + +| Before | After | Why | +| --- | --- | --- | +| 键盘主动操作重播长动画和逐字 | `src/space.js:55` snap 与 instant 分支,实跑80ms残留0 | 符合频繁键盘操作即时响应规则 | +| 上一句字号连续插值、导航亮块 width 过渡 | `src/space.css:114` 固定字号副本,`src/space.css:145` 仅位移 | 消除容易避免的逐帧排版 | +| 人物窗720ms实际几何变化 | `src/space.js:71` 保留原设计,档2降为320ms淡入 | 性能取舍仍存在;已有软件光栅 A/B、失真替代方案说明和降级,真实GPU待验证 | + +结论:两项确定的小修已由本机复现验证。人物窗重绘成本仍是性能限制,未称全部设备流畅;保留其评估交总工复审,不重新设计。此补检不代替独立复审或白栖知的体验验收。 + +## 边界与后续 + +未部署、未合并PR#8/PR#4,未改8310/8300、用户档案/名字/生日/底色/聊天/记忆及队员运行时、模型、认证。人物仍为参考静帧,内容为演示数据。 + +真机软键盘、Safari、Firefox、真实GPU帧率未验证。完整生活/回忆/创建/管理状态、R1个性成长、R2–R7真实接入和口型继续由主卡HEL-697承接。总工只定点复检本次差异及必要回归;通过后由总管到主卡交白栖知体验,不在本轮部署或合并。 diff --git a/prototypes/hel-793/src/atmos.js b/prototypes/hel-793/src/atmos.js index 5746622..54ee860 100644 --- a/prototypes/hel-793/src/atmos.js +++ b/prototypes/hel-793/src/atmos.js @@ -368,7 +368,7 @@ const Atmos = (() => { document.addEventListener('visibilitychange', () => { running = !document.hidden; if (running) { last = 0; requestAnimationFrame(frame); } }); resize(); requestAnimationFrame(frame); return { - setDepth(z) { cam.zt = z; }, + setDepth(z, instant) { cam.zt = z; if (instant) { cam.z = z; cam.zv = 0; } }, setTheme(name, instant) { themeTarget = name === 'fog' ? 1 : 0; if (instant) themeMix = themeTarget; }, info() { return {tier, scale, z: +cam.z.toFixed(3), theme: +themeMix.toFixed(2), motes: motes.length, drops: drops.length}; }, setTier(t) { tier = t; frameCost = []; resize(); document.documentElement.dataset.tier = t; } diff --git a/prototypes/hel-793/src/space.css b/prototypes/hel-793/src/space.css index 59dd06c..5ac481f 100644 --- a/prototypes/hel-793/src/space.css +++ b/prototypes/hel-793/src/space.css @@ -110,7 +110,8 @@ body[data-mode="talk"] .conversation{opacity:1;transform:translate(-50%,0);point .line p{font-size:21px;font-weight:350;line-height:1.72;overflow-wrap:anywhere;text-shadow:0 2px 20px #030719,0 0 2px #03071980;color:var(--text-1)} .line[data-role="user"] p{color:var(--user);font-size:19px} .line.is-prev{opacity:.42;transition:opacity 520ms var(--ease-out)} -.line.is-prev p{font-size:15px;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden;transition:font-size 520ms var(--ease-out)} +/* Fixed size: the hand-off from current → previous is a crossfade of two copies (space.js), never a font-size tween. */ +.line.is-prev p{font-size:15px;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden} .line.is-prev .who{margin-bottom:0;font-size:11px}.line.is-prev .dwell{display:none} .line .dwell{display:block;width:56px;height:2px;margin:9px auto 0;border-radius:2px;background:var(--accent-soft);overflow:hidden} .line .dwell i{display:block;height:100%;background:var(--accent);box-shadow:0 0 8px var(--accent);transform-origin:left;transform:scaleX(0)} @@ -141,8 +142,8 @@ body.reading-paused .line .dwell{opacity:.25}body.reading-held .line .dwell i{op /* ── Navigation ── */ .navigation{position:absolute;left:50%;bottom:calc(18px + var(--safe-bottom));transform:translateX(-50%);display:flex;padding:5px;border-radius:40px;z-index:5;transition:transform 420ms var(--ease-out),opacity 280ms var(--ease-out)} -.nav-glow{position:absolute;left:0;top:5px;bottom:5px;border-radius:32px;background:var(--accent-soft);box-shadow:0 0 22px var(--accent-glow),inset 0 0 12px var(--accent-soft);transition:transform 460ms var(--ease-out),width 460ms var(--ease-out),opacity 280ms;pointer-events:none} -.navigation button{position:relative;min-width:68px;min-height:51px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;padding:4px 15px;color:var(--text-2);border-radius:32px;font-size:12px} +.nav-glow{position:absolute;left:0;top:5px;bottom:5px;border-radius:32px;background:var(--accent-soft);box-shadow:0 0 22px var(--accent-glow),inset 0 0 12px var(--accent-soft);transition:transform 460ms var(--ease-out),opacity 280ms;pointer-events:none} +.navigation button{position:relative;width:68px;min-width:68px;min-height:51px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;padding:4px 15px;color:var(--text-2);border-radius:32px;font-size:12px} .navigation svg{width:18px;height:18px}.navigation [aria-current="page"]{color:var(--accent)} .toast{position:absolute;left:50%;top:84px;transform:translate(-50%,-8px);padding:8px 16px;border-radius:20px;font-size:12px;color:var(--text-1);background:var(--glass-read);border:1px solid var(--glass-line);backdrop-filter:blur(14px);opacity:0;pointer-events:none;transition:opacity 280ms var(--ease-out),transform 280ms var(--ease-out);z-index:6;white-space:nowrap;max-width:calc(100vw - 32px);overflow:hidden;text-overflow:ellipsis} .toast.show{opacity:1;transform:translate(-50%,0)} @@ -210,7 +211,7 @@ body.reading-paused .line .dwell{opacity:.25}body.reading-held .line .dwell i{op .stage{padding:0 8px;gap:6px}.line p{font-size:17px;line-height:1.75}.line[data-role="user"] p{font-size:16px}.line.is-prev p{font-size:13px} .reader-bar{gap:2px;margin:6px 0 8px;font-size:11px}.reader-bar>button{padding:0 8px}.reader-bar .count{min-width:44px} .composer{padding:5px;gap:3px}.composer .round,.voice-button{width:40px;height:40px}.composer textarea{font-size:16px;padding:7px 3px;height:40px} - .navigation{bottom:calc(14px + var(--safe-bottom));padding:4px}.nav-glow{top:4px;bottom:4px}.navigation button{min-width:64px;min-height:46px;padding:3px 14px;font-size:11px}.navigation svg{width:17px;height:17px} + .navigation{bottom:calc(14px + var(--safe-bottom));padding:4px}.nav-glow{top:4px;bottom:4px}.navigation button{width:64px;min-width:64px;min-height:46px;padding:3px 14px;font-size:11px}.navigation svg{width:17px;height:17px} .toast{top:62px;font-size:11px} .sheet,.sheet[data-kind="history"],.sheet[data-kind="full"]{left:10px;right:10px;width:calc(100% - 20px);top:calc(var(--view-top) + 62px);height:calc(var(--view-h) - 146px - var(--safe-bottom));border-radius:24px} .sheet-header{padding:16px 18px;height:84px}.sheet-content{padding:18px 20px 28px;height:calc(100% - 84px)}h2{font-size:20px}.quote{font-size:18px} @@ -242,6 +243,8 @@ body.micro .reader-bar .count,body.micro #read-all{display:none} /* ── Motion preferences and fallbacks ── */ html.no-motion *,html.no-motion *::before,html.no-motion *::after{animation:none!important;transition-duration:1ms!important} body.suspended *{animation-play-state:paused!important} +/* Keyboard shortcuts (space.js snap): land on the target state with no transition; ambient loops keep running. */ +body.snap *,body.snap *::before,body.snap *::after{transition:none!important} @media(prefers-reduced-motion:reduce){ *,*::before,*::after{animation:none!important} .portal img,.home-copy,.conversation,.portal-aura,.depth-veil,.navigation,.nav-glow{transition:opacity 200ms linear!important} diff --git a/prototypes/hel-793/src/space.js b/prototypes/hel-793/src/space.js index febe92c..d6a43be 100644 --- a/prototypes/hel-793/src/space.js +++ b/prototypes/hel-793/src/space.js @@ -50,28 +50,43 @@ function portalBox() { const r = portal.getBoundingClientRect(), w = $('#world').getBoundingClientRect(), cs = getComputedStyle(portal); return {left: r.left - w.left, top: r.top - w.top, width: r.width, height: r.height, radius: cs.borderRadius, opacity: cs.opacity}; } -function setMode(mode) { +// Keyboard shortcuts land on the target state at once: CSS transitions are suspended for two frames +// and any finite animation already under way (geometry, line moves, ink) jumps to its end. +function snap() { + document.body.classList.add('snap'); + for (const a of document.getAnimations()) if (a.effect && a.effect.getTiming().iterations !== Infinity) a.finish(); + requestAnimationFrame(() => requestAnimationFrame(() => document.body.classList.remove('snap'))); +} +function setMode(mode, {instant = false} = {}) { const before = portalBox(); if (portalAnim) { portalAnim.cancel(); portalAnim = null; } + if (instant) snap(); state.mode = mode; document.body.dataset.mode = mode; - Atmos.setDepth(DEPTH[mode]); + Atmos.setDepth(DEPTH[mode], instant); $('#scene-label').textContent = mode === 'talk' || mode === 'record' ? '交流近景' : '窗边看书'; $('.home-copy').inert = mode !== 'home'; $('#conversation').inert = mode !== 'talk'; portal.style.height = ''; if (mode === 'talk') fitTalkStage(); const after = portalBox(); - if (!motionOff() && before.width && after.width) { - // Animate real geometry (not a scale), so the arch → frame radius and the photo never distort. - portalAnim = portal.animate([ - {left: before.left + 'px', top: before.top + 'px', width: before.width + 'px', height: before.height + 'px', borderRadius: before.radius, opacity: before.opacity, right: 'auto', transform: 'none'}, - {left: after.left + 'px', top: after.top + 'px', width: after.width + 'px', height: after.height + 'px', borderRadius: after.radius, opacity: after.opacity, right: 'auto', transform: 'none'} - ], {duration: 720, easing: EASE_SPACE}); + if (!instant && !motionOff() && before.width && after.width) { + if (html.dataset.tier === '2') { + // Slow devices: no per-frame layout; she simply fades in at the new place. + portalAnim = portal.animate([{opacity: 0}, {opacity: after.opacity}], {duration: 320, easing: EASE}); + } else { + // Animate real geometry (not a scale), so the arch → frame radius and the photo never distort. + // Measured (evidence/portal-ab.json): layout ~0.1ms/frame (absolute box, own subtree); repaint of the large desktop window is the real cost. + portalAnim = portal.animate([ + {left: before.left + 'px', top: before.top + 'px', width: before.width + 'px', height: before.height + 'px', borderRadius: before.radius, opacity: before.opacity, right: 'auto', transform: 'none'}, + {left: after.left + 'px', top: after.top + 'px', width: after.width + 'px', height: after.height + 'px', borderRadius: after.radius, opacity: after.opacity, right: 'auto', transform: 'none'} + ], {duration: 720, easing: EASE_SPACE}); + } portalAnim.finished.then(() => { portalAnim = null; }).catch(() => {}); } $$('.navigation button').forEach(n => n.dataset.nav === (mode === 'pane' || mode === 'record' ? state.dialogNav : mode) ? n.setAttribute('aria-current', 'page') : n.removeAttribute('aria-current')); moveNavGlow(); - if (mode === 'talk') { present('jump'); } + if (mode === 'talk') { present(instant ? 'instant' : 'jump'); } } +// The glow only slides (transform); its width is set without transition — all nav items share one width. function moveNavGlow() { const cur = $('.navigation [aria-current]'), glow = $('.nav-glow'); if (!cur) { glow.style.opacity = 0; return; } @@ -122,13 +137,13 @@ function dwellFor(page) { const n = graphemes(page.text).length; return page.message.role === 'user' ? Math.min(3000, Math.max(1400, 900 + n * 60)) : Math.min(8000, Math.max(2400, 1500 + n * 150)); } -function lineEl(page, current) { +function lineEl(page, current, animate = true) { const el = document.createElement('div'); el.className = 'line' + (current ? ' is-current' : ' is-prev'); el.dataset.role = page.message.role; el.dataset.key = page.key; const label = page.message.role === 'user' ? '你' : state.name; const chars = graphemes(page.text); - const ink = current && !motionOff() && page.message.role === 'her'; + const ink = current && animate && !motionOff() && page.message.role === 'her'; const body = ink ? chars.map((c, i) => `${escapeHTML(c)}`).join('') : escapeHTML(page.text); el.innerHTML = `${escapeHTML(label)}

${body}

`; return el; @@ -146,19 +161,29 @@ function present(how = 'advance') { const keys = have.map(n => n.dataset.key); state.dwell = 0; state.dwellTotal = dwellFor(page); announce(`${who(page.message)}:${page.text}`); + if (how === 'instant') { + // Keyboard review: show the target lines as they are — no slide, no re-played ink. + stage.replaceChildren(...want.map(p => lineEl(p, p === page, false))); + refreshState(); return; + } if (keys.join() === want.map(p => p.key).join()) { refreshState(); return; } const animate = !motionOff() && state.mode === 'talk'; - // Forward by one: the current line rises into the "previous" slot, the oldest drifts away. + // Forward by one: the finished line lifts toward the "previous" slot while a small copy settles there; the oldest drifts away. + // Both copies keep a fixed font size (no per-frame re-typesetting); only transform/opacity move. if (how === 'advance' && animate && keys[keys.length - 1] === (prev && prev.key)) { const first = new Map(have.map(n => [n, n.getBoundingClientRect()])); const stageRect = stage.getBoundingClientRect(); for (const n of have) if (n.dataset.key !== prev.key) leave(n, first.get(n), stageRect); - const keep = have.find(n => n.dataset.key === prev.key); - keep.classList.replace('is-current', 'is-prev'); - keep.querySelector('p').classList.remove('ink'); - const fresh = lineEl(page, true); stage.append(fresh); - const was = first.get(keep), now = keep.getBoundingClientRect(); - keep.animate([{transform: `translateY(${was.top - now.top}px)`}, {transform: 'none'}], {duration: 620, easing: EASE}); + const keep = have.find(n => n.dataset.key === prev.key), was = first.get(keep); + const settled = lineEl(prev, false), fresh = lineEl(page, true); + pin(keep, was, stageRect); + stage.append(settled, fresh); + const to = settled.getBoundingClientRect(); + if (to.height) { + const shrink = parseFloat(getComputedStyle(settled.querySelector('p')).fontSize) / parseFloat(getComputedStyle(keep.querySelector('p')).fontSize); + leave(keep, was, stageRect, false, to.top - was.top, shrink); + settled.animate([{opacity: 0, transform: `translateY(${Math.round((was.top - to.top) * .35)}px)`}, {opacity: getComputedStyle(settled).opacity, transform: 'none'}], {duration: 620, easing: EASE}); + } else leave(keep, was, stageRect); // compact screens hide the previous line fresh.animate([{opacity: 0, transform: 'translateY(16px)', filter: 'blur(8px)'}, {opacity: 1, transform: 'none', filter: 'blur(0)'}], {duration: 680, easing: EASE}); } else { // Jumps (review, new turn, resize) crossfade instead of sliding, so direction never lies. @@ -171,10 +196,14 @@ function present(how = 'advance') { } refreshState(); } -function leave(node, rect, stageRect, quick = false) { +// Take a line out of the flow at its current place, so the remaining lines lay out once at their final size. +function pin(node, rect, stageRect) { node.classList.add('leaving'); - Object.assign(node.style, {position: 'absolute', left: rect.left - stageRect.left + 'px', top: rect.top - stageRect.top + 'px', width: rect.width + 'px'}); - node.animate([{opacity: getComputedStyle(node).opacity, transform: 'none', filter: 'blur(0)'}, {opacity: 0, transform: `translateY(${quick ? -4 : -14}px)`, filter: 'blur(5px)'}], + Object.assign(node.style, {position: 'absolute', left: rect.left - stageRect.left + 'px', top: rect.top - stageRect.top + 'px', width: rect.width + 'px', transformOrigin: '50% 0'}); +} +function leave(node, rect, stageRect, quick = false, lift = quick ? -4 : -14, shrink = 1) { + pin(node, rect, stageRect); + node.animate([{opacity: getComputedStyle(node).opacity, transform: 'none', filter: 'blur(0)'}, {opacity: 0, transform: `translateY(${lift}px) scale(${shrink})`, filter: 'blur(5px)'}], {duration: quick ? 240 : 520, easing: EASE, fill: 'forwards'}).finished.then(() => node.remove()).catch(() => node.remove()); } function refreshState() { @@ -201,13 +230,14 @@ function updateControls(list = pages()) { document.body.classList.toggle('reading-paused', !state.flow); $('#portal-state').textContent = state.busy ? '正在回应 · 演示' : state.voice ? '语音界面演示' : '在你面前'; } -function go(delta, manual = true) { +function go(delta, manual = true, instant = false) { const n = pages().length; const to = Math.max(0, Math.min(state.cursor + delta, n - 1)); if (to === state.cursor) return; state.cursor = to; - present(delta === 1 ? 'advance' : 'jump'); + present(instant ? 'instant' : delta === 1 ? 'advance' : 'jump'); } +function back(instant = false) { if (state.flow) setFlow(false, true); go(-1, true, instant); updateControls(); } function setFlow(on, quiet = false) { state.flow = on; state.dwell = 0; updateControls(); @@ -279,17 +309,19 @@ function showSheet(kind, title, kicker, markup, nav = state.dialogNav) { if (!motionOff()) sheet.animate([{opacity: 0, transform: 'translateY(18px) scale(.985)', filter: 'blur(6px)'}, {opacity: 1, transform: 'none', filter: 'blur(0)'}], {duration: 460, easing: EASE}); $('#close-sheet').focus({preventScroll: true}); } -function finishClose() { +function finishClose(instant = false) { sheet.getAnimations().forEach(a => a.cancel()); if (sheet.open) sheet.close(); state.dialog = null; closing = null; hold('sheet', false); state.dialogNav = null; - setMode(['pane', 'record'].includes(state.returnMode) ? 'home' : state.returnMode); + setMode(['pane', 'record'].includes(state.returnMode) ? 'home' : state.returnMode, {instant}); if (trigger && trigger.isConnected && !trigger.inert) trigger.focus({preventScroll: true}); } -function closeSheet() { - if (closing || !sheet.open) return; +function closeSheet(instant = false) { + if (!sheet.open) return; + if (instant) { if (closing) closing.cancel(); finishClose(true); return; } + if (closing) return; if (motionOff()) { finishClose(); return; } closing = sheet.animate([{opacity: 1, transform: 'none', filter: 'blur(0)'}, {opacity: 0, transform: 'translateY(14px) scale(.985)', filter: 'blur(4px)'}], {duration: 240, easing: EASE, fill: 'forwards'}); - closing.finished.then(finishClose).catch(() => {}); + closing.finished.then(() => finishClose()).catch(() => {}); } function renderHistory() { const keep = content.scrollTop; @@ -382,10 +414,10 @@ $('#start-talk').onclick = () => openTalk(); $('#shared-note').onclick = () => { openTalk(); showHistory(); }; $('#days-link').onclick = () => showDays(); $('#close-talk').onclick = () => setMode('home'); -$('#history').onclick = showHistory; $('#read-all').onclick = showFull; $('#close-sheet').onclick = closeSheet; $('#rehearse').onclick = showRehearsal; +$('#history').onclick = showHistory; $('#read-all').onclick = showFull; $('#close-sheet').onclick = () => closeSheet(); $('#rehearse').onclick = showRehearsal; $('#home-voice').onclick = showVoice; $('#voice').onclick = showVoice; $('#end-voice').onclick = () => { state.voice = false; document.body.classList.remove('has-voice'); $('#voice-row').hidden = true; cancelReply(true); fitTalkStage(); toast('语音界面演示已结束'); }; -$('#previous').onclick = () => { if (state.flow) setFlow(false, true); go(-1); updateControls(); }; +$('#previous').onclick = () => back(); $('#next').onclick = () => go(1); $('#toggle-flow').onclick = () => setFlow(!state.flow); $('#latest').onclick = () => { state.cursor = state.head; present('jump'); setFlow(true, true); }; @@ -399,7 +431,7 @@ $('#draft').addEventListener('blur', () => document.body.classList.remove('typin stage.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') hold('hover', true); }); stage.addEventListener('pointerleave', () => hold('hover', false)); document.addEventListener('selectionchange', () => { const s = getSelection(); hold('select', !!(String(s).length && stage.contains(s.anchorNode))); }); -sheet.addEventListener('cancel', e => { e.preventDefault(); closeSheet(); }); +sheet.addEventListener('cancel', e => { e.preventDefault(); closeSheet(true); }); // Escape document.addEventListener('submit', e => { if (e.target.id === 'relation-form') { e.preventDefault(); state.relation = $('#relation').value; state.boundaries = $('#boundaries').value; $('#form-status').textContent = '演示选择已确认。记忆和底色保持原样。'; } else if (e.target.id === 'city-form') { e.preventDefault(); $('.weather').innerHTML = `${escapeHTML($('#city').value)} · 小雨 14° 示例`; $('#form-status').textContent = '已保存演示选择;这里没有查询真实天气。'; } @@ -411,10 +443,11 @@ document.addEventListener('submit', e => { state.relation = relation; state.returnMode = 'talk'; setName(name); finishClose(); openTalk(); toast('临时演示身份已建立 · 未接触真实档案'); } }); +// Keyboard shortcuts are frequent, deliberate jumps: they show the target state at once (snap), unlike clicks and auto-flow. document.addEventListener('keydown', e => { - if (e.key === 'Escape' && !sheet.open && state.mode === 'talk') { setMode('home'); $('#start-talk').focus(); } + if (e.key === 'Escape' && !sheet.open && state.mode === 'talk') { setMode('home', {instant: true}); $('#start-talk').focus(); } if (state.mode === 'talk' && !sheet.open && document.activeElement !== $('#draft')) { - if (e.key === 'ArrowLeft') $('#previous').click(); else if (e.key === 'ArrowRight') go(1); else if (e.key === ' ' && document.activeElement === document.body) { e.preventDefault(); setFlow(!state.flow); } + if (e.key === 'ArrowLeft') { snap(); back(true); } else if (e.key === 'ArrowRight') { snap(); go(1, true, true); } else if (e.key === ' ' && document.activeElement === document.body) { e.preventDefault(); setFlow(!state.flow); } } }); document.addEventListener('visibilitychange', () => document.body.classList.toggle('suspended', document.hidden)); diff --git a/prototypes/hel-793/tools/motion-probe.ps1 b/prototypes/hel-793/tools/motion-probe.ps1 new file mode 100644 index 0000000..590b6b0 --- /dev/null +++ b/prototypes/hel-793/tools/motion-probe.ps1 @@ -0,0 +1,98 @@ +param([string]$html = "$PSScriptRoot\..\index.html", [string]$out = "$PSScriptRoot\..\evidence\motion-probe.json", [int]$port = 9341) +# HEL-795 motion probe: what is still animating 80ms after a keyboard action, vs. the same action by mouse / auto-flow, +# and how much layout work each transition costs (CDP Performance metrics, headless Edge, software compositing). +. "$PSScriptRoot\cdp.ps1" +$url = 'file:///' + ((Resolve-Path $html).Path -replace '\\', '/') +$results = New-Object System.Collections.ArrayList +function Rec([string]$name, $data) { [void]$results.Add([ordered]@{ probe = $name; data = $data }); "{0} {1}" -f $name, ($data | ConvertTo-Json -Compress -Depth 6) } +function J($e) { Invoke-Js $e | ConvertFrom-Json } +function JA($e) { Invoke-Js $e -await | ConvertFrom-Json } +function Key([string]$key, [int]$vk) { + [void](Send-Cdp 'Input.dispatchKeyEvent' @{ type = 'rawKeyDown'; key = $key; code = $key; windowsVirtualKeyCode = $vk }) + [void](Send-Cdp 'Input.dispatchKeyEvent' @{ type = 'keyUp'; key = $key; code = $key; windowsVirtualKeyCode = $vk }) +} +# Finite animations still running, grouped; ambient loops (infinite) are excluded on purpose. +$lib = @' +window.__anims = () => { const g = {}; for (const a of document.getAnimations()) { if (a.playState !== 'running') continue; const t = a.effect.getTiming(); if (t.iterations === Infinity) continue; + const ct = a.effect.getComputedTiming(); const left = Math.round(ct.endTime - (a.currentTime || 0)); if (left <= 0) continue; const el = a.effect.target; + const k = (el.id || String(el.className).split(' ')[0] || el.tagName) + ' | ' + (a.transitionProperty || a.animationName || 'script') + ' | ' + Math.round(t.duration) + 'ms'; + g[k] = (g[k] || 0) + 1; } return g; }; +window.__after = ms => new Promise(r => setTimeout(() => r(JSON.stringify(__anims())), ms)); +window.__props = () => { const s = new Set(); for (const a of document.getAnimations()) if (a.transitionProperty) s.add(a.transitionProperty); return [...s]; }; +'@ +$null = Start-Browser $port +try { + Set-View 1440 900 + [void](Send-Cdp 'Performance.enable' @{ timeDomain = 'timeTicks' }) + function Metrics { $m = @{}; foreach ($x in (Send-Cdp 'Performance.getMetrics').metrics) { $m[$x.name] = $x.value }; $m } + function Cost([scriptblock]$act, [int]$ms) { + $a = Metrics; & $act; Wait-Ms $ms; $b = Metrics + [ordered]@{ layouts = $b.LayoutCount - $a.LayoutCount; layoutMs = [math]::Round(($b.LayoutDuration - $a.LayoutDuration) * 1000, 2); styleRecalcs = $b.RecalcStyleCount - $a.RecalcStyleCount; styleMs = [math]::Round(($b.RecalcStyleDuration - $a.RecalcStyleDuration) * 1000, 2); windowMs = $ms } + } + Open-Page "${url}?theme=moon"; [void](Invoke-Js $lib); Wait-Ms 1200 + + # 1. Mouse/click open + close keeps the natural geometry expansion (must stay animated). + [void](Invoke-Js "document.querySelector('#start-talk').click()") + Rec 'click-open-talk +80ms' (JA '__after(80)') + Wait-Ms 1500 + Rec 'keyboard Escape +80ms' $(Key 'Escape' 27; JA '__after(80)') + Rec 'keyboard Escape: mode/portal' (J 'JSON.stringify({mode:document.body.dataset.mode,z:__proto.atmos().z,portalW:Math.round(document.querySelector("#portal").getBoundingClientRect().width)})') + Wait-Ms 1200 + [void](Invoke-Js "document.querySelector('#start-talk').click()"); Wait-Ms 1500 + [void](Invoke-Js "document.querySelector('#close-talk').click()") + Rec 'click close-talk +80ms' (JA '__after(80)') + Wait-Ms 1500 + + # 2. Reading: keyboard review vs. auto flow. + [void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 9000 + Rec 'keyboard ArrowRight +80ms' $(Key 'ArrowRight' 39; JA '__after(80)') + Rec 'keyboard ArrowRight: ink spans' (J 'JSON.stringify({ink:document.querySelectorAll("#stage .is-current .ink span").length,lines:document.querySelectorAll("#stage .line:not(.leaving)").length,cursor:__proto.state.cursor})') + Wait-Ms 300 + Rec 'keyboard ArrowLeft +80ms' $(Key 'ArrowLeft' 37; JA '__after(80)') + Rec 'keyboard ArrowLeft: state' (J 'JSON.stringify({flow:__proto.state.flow,cursor:__proto.state.cursor,latestHidden:document.querySelector("#latest").hidden})') + Wait-Ms 200 + Rec 'keyboard ArrowLeft x3 rapid +80ms' $(Key 'ArrowLeft' 37; Key 'ArrowLeft' 37; Key 'ArrowLeft' 37; JA '__after(80)') + [void](Invoke-Js "document.querySelector('#next').click()") + Rec 'click next +80ms' (JA '__after(80)') + Wait-Ms 900 + [void](Invoke-Js "document.querySelector('#latest').click()"); Wait-Ms 600 + # Catch the moment auto-flow advances on its own, then look 80ms later. + $auto = JA "new Promise(r=>{const c=__proto.state.cursor;const t=setInterval(()=>{if(__proto.state.cursor!==c){clearInterval(t);setTimeout(()=>r(JSON.stringify({anims:__anims(),props:__props()})),80)}},5)})" + Rec 'auto advance +80ms' $auto + # Transitions on layout properties seen across an auto advance (font-size / width must be absent). + $seen = JA "new Promise(r=>{const s=new Set();const t=setInterval(()=>{for(const a of document.getAnimations())if(a.transitionProperty)s.add(a.transitionProperty)},16);setTimeout(()=>{clearInterval(t);r(JSON.stringify([...s]))},9000)})" + Rec 'css transitions seen over 9s of auto flow' $seen + Rec 'layout cost: auto flow 9s' (Cost {} 9000) + + # 3. Nav glow: move between items by click; width must not be a transition. + [void](Invoke-Js "__proto.setMode('home')"); Wait-Ms 900 + [void](Invoke-Js "document.querySelector('[data-nav=talk]').click()") + Rec 'nav click +80ms transitions' (JA "new Promise(r=>setTimeout(()=>r(JSON.stringify(document.getAnimations().filter(a=>a.effect.target.classList&&a.effect.target.classList.contains('nav-glow')).map(a=>a.transitionProperty))),80))") + Wait-Ms 1200 + + # 4. Portal geometry cost: click open/close (720ms) vs. keyboard Escape. + [void](Invoke-Js "__proto.setMode('home')"); Wait-Ms 1500 + Rec 'layout cost: idle 1s (baseline)' (Cost {} 1000) + Rec 'layout cost: click open talk 1s' (Cost { [void](Invoke-Js "document.querySelector('#start-talk').click()") } 1000) + Rec 'layout cost: keyboard Escape 1s' (Cost { Key 'Escape' 27 } 1000) + Rec 'layout cost: click open + close-talk 2s' (Cost { [void](Invoke-Js "document.querySelector('#start-talk').click()"); Wait-Ms 1000; [void](Invoke-Js "document.querySelector('#close-talk').click()") } 1000) + # 5. Frame pacing during the 720ms geometry move (main-thread rAF intervals; headless = software raster, not a GPU number). + $fp = "new Promise(r=>{const ts=[];let s=performance.now();(function f(t){ts.push(t);if(t-s<900)requestAnimationFrame(f);else{const d=ts.slice(1).map((x,i)=>x-ts[i]).sort((a,b)=>a-b);r(JSON.stringify({frames:d.length,p50:+d[Math.floor(d.length*.5)].toFixed(1),p95:+d[Math.floor(d.length*.95)].toFixed(1),max:+d[d.length-1].toFixed(1)}))}})(s)})" + Wait-Ms 1500 + Rec 'frames: idle home 900ms' (JA $fp) + [void](Invoke-Js "document.querySelector('#start-talk').click()"); Rec 'frames: click open talk 900ms' (JA $fp); Wait-Ms 600 + [void](Invoke-Js "document.querySelector('#close-talk').click()"); Rec 'frames: click close talk 900ms' (JA $fp); Wait-Ms 600 + + # 6. Escape inside the history sheet returns to talk at once. + [void](Invoke-Js "document.querySelector('#start-talk').click()"); Wait-Ms 1200 + [void](Invoke-Js "document.querySelector('#history').click()"); Wait-Ms 1200 + Rec 'keyboard Escape in sheet +80ms' $(Key 'Escape' 27; JA '__after(80)') + Rec 'keyboard Escape in sheet: state' (J 'JSON.stringify({open:document.querySelector("#sheet").open,mode:document.body.dataset.mode})') + + # 7. Forced slow tier: geometry is replaced by a short fade. + Open-Page "${url}?theme=moon&tier=2"; [void](Invoke-Js $lib); Wait-Ms 1200 + [void](Invoke-Js "document.querySelector('#start-talk').click()") + Rec 'tier2 click open talk +80ms' (JA '__after(80)') + Rec 'atmos' (J 'JSON.stringify(__proto.atmos())') +} finally { Stop-Browser } +$results | ConvertTo-Json -Depth 8 | Set-Content $out -Encoding utf8 diff --git a/prototypes/hel-793/tools/portal-ab.ps1 b/prototypes/hel-793/tools/portal-ab.ps1 new file mode 100644 index 0000000..58b2c61 --- /dev/null +++ b/prototypes/hel-793/tools/portal-ab.ps1 @@ -0,0 +1,27 @@ +param([string]$html = "$PSScriptRoot\..\index.html", [string]$out = "$PSScriptRoot\..\evidence\portal-ab.json", [int]$port = 9343, [int]$runs = 6) +# HEL-795 A/B: frame pacing of the click open/close transition with the real portal geometry vs. the same +# transition with the geometry tween removed (everything else identical). Headless Edge, software raster - relative only. +. "$PSScriptRoot\cdp.ps1" +$url = 'file:///' + ((Resolve-Path $html).Path -replace '\\', '/') +$fp = "new Promise(r=>{const ts=[];let s=performance.now();(function f(t){ts.push(t);if(t-s<900)requestAnimationFrame(f);else{const d=ts.slice(1).map((x,i)=>x-ts[i]);r(JSON.stringify({frames:d.length,long:d.filter(x=>x>25).length,max:+Math.max(...d).toFixed(1)}))}})(s)})" +$noGeo = "(()=>{const p=document.querySelector('#portal'),a=Element.prototype.animate;p.animate=function(k,o){return a.call(this,[{opacity:getComputedStyle(this).opacity},{opacity:getComputedStyle(this).opacity}],o)};return 1})()" +$res = [ordered]@{} +$null = Start-Browser $port +try { + foreach ($size in @(@(1440, 900, $false), @(390, 844, $true))) { + Set-View $size[0] $size[1] -mobile:$size[2] + foreach ($variant in @('geometry', 'no-geometry')) { + Open-Page "${url}?theme=moon"; Wait-Ms 1500 + if ($variant -eq 'no-geometry') { [void](Invoke-Js $noGeo) } + $rows = @() + for ($i = 0; $i -lt $runs; $i++) { + [void](Invoke-Js "document.querySelector('#start-talk').click()"); $rows += (Invoke-Js $fp -await | ConvertFrom-Json); Wait-Ms 500 + [void](Invoke-Js "document.querySelector('#close-talk').click()"); $rows += (Invoke-Js $fp -await | ConvertFrom-Json); Wait-Ms 500 + } + $k = "$($size[0])x$($size[1]) $variant" + $res[$k] = [ordered]@{ transitions = $rows.Count; avgFrames = [math]::Round(($rows | Measure-Object frames -Average).Average, 1); avgLongFrames = [math]::Round(($rows | Measure-Object long -Average).Average, 2); worstFrameMs = ($rows | Measure-Object max -Maximum).Maximum } + "$k $($res[$k] | ConvertTo-Json -Compress)" + } + } +} finally { Stop-Browser } +$res | ConvertTo-Json -Depth 5 | Set-Content $out -Encoding utf8