HEL-795: import visual director keyboard and text motion fixes

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
白栖知
2026-10-22 23:47:54 +08:00
co-authored by multica-agent
parent a576785535
commit 21170f9fc9
22 changed files with 1047 additions and 81 deletions
+11 -3
View File
@@ -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`。
## 验证范围与未验证项
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.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 818 KiB

After

Width:  |  Height:  |  Size: 818 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

@@ -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": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 814 KiB

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 739 KiB

After

Width:  |  Height:  |  Size: 740 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -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
}
}
]
@@ -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
}
}
]
@@ -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
}
}
@@ -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
}
},
+37
View File
@@ -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 项全部通过,截图已查看。其余旧截图和录屏未重录,因为本次没有改变静止画面。"
]
}
+74 -38
View File
@@ -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) => `<span style="--i:${Math.min(i, 48)}">${escapeHTML(c)}</span>`).join('') : escapeHTML(page.text);
el.innerHTML = `<span class="who"><i></i><b>${escapeHTML(label)}</b><em class="state"></em></span><p class="${ink ? 'ink' : ''}">${body}</p><span class="dwell"><i></i></span>`;
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° <small>示例</small>`; $('#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));
+34
View File
@@ -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承接。总工只定点复检本次差异及必要回归;通过后由总管到主卡交白栖知体验,不在本轮部署或合并。
+1 -1
View File
@@ -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; }
+7 -4
View File
@@ -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}
+66 -33
View File
@@ -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) => `<span style="--i:${Math.min(i, 48)}">${escapeHTML(c)}</span>`).join('') : escapeHTML(page.text);
el.innerHTML = `<span class="who"><i></i><b>${escapeHTML(label)}</b><em class="state"></em></span><p class="${ink ? 'ink' : ''}">${body}</p><span class="dwell"><i></i></span>`;
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° <small>示例</small>`; $('#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));
+98
View File
@@ -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
+27
View File
@@ -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