Compare commits

..
2 Commits
Author SHA1 Message Date
白栖知andmultica-agent 21170f9fc9 HEL-795: import visual director keyboard and text motion fixes
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 23:47:54 +08:00
白栖知andmultica-agent a576785535 HEL-793: import visual director immersive conversation prototype and evidence
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 22:37:53 +08:00
53 changed files with 4039 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
# 夜里的同一片空间 · HEL-793 动态小样
视觉总监本轮亲自设计并编写的独立小样。直接用浏览器打开 `index.html` 即可,图片、样式和脚本都在这个文件里,不用安装,也不联网。`evidence/` 里是这份文件的实际截图、录屏、录屏抽帧和行为检查记录。本目录没有接入产品入口,也不替换正式界面。
人物画面仍是此前获批的两张参考静帧,界面上标为「参考静帧」。文字、生活、回忆和天气都是虚构示例。小样没有连接模型、麦克风、摄像头、生活、天气或备份服务。
## 一句话方向
月光照进的同一片夜:雾在光里流动,她的窗是这片空间里唯一的暖光。说话时镜头推近她,话一句接一句轻轻落下,再淡淡散去。
## 怎样试
1. **相处页**:先停留十几秒,不要操作。月光光束会摆动、变亮或变暗,云影会掠过月亮,雾会从远到近分层飘过。远处城市的窗灯会一盏盏亮起、熄灭,塔顶红灯在闪,细雨在落。光点会缓慢上升。人物窗内的镜头在缓慢推移,灯光也在呼吸。在电脑上移动鼠标,各层景物会按远近产生视差。
2. 点「和她说话」:拱形人物窗连续展开成交流画框,同时从房间远景推近到同一个人的近景。背景镜头一起推近,月亮退开,前景光点变大变虚。
3. 打开右上角「独立小样 · 演示数据」,选「一段长回复」,或直接输入带「慢慢讲」的话。**回复默认自动一句接一句呈现**,一直读到最后一句,中间不用点击。屏幕上只保留上一句(变淡)和当前一句,每句都标明是谁说的。当前句下方的细线表示这句还会停留多久。鼠标停在文字上时会先暂停计时,移开后继续。
4. 需要停下时点「暂停」。左右箭头可以往回看,往回看时会出现「回到读到的地方 · 后面还有 N 句」,点击后自动呈现继续。「全文」用来看整段,左下角的记录按钮打开完整聊天记录。打开时她会退到左侧,记录从右侧展开。关闭后回到刚才那一句,并从那里继续。
5. 暂停后再发新消息,会自动恢复呈现,并提示「新的一轮会自动呈现,随时可以暂停」,不会再停在需要手动翻页的状态。「生成中与停止」可以试停止回复:已出现的内容会保留,并标为未完成。
6. 「日子」「设置」打开时,空间会往后退,人物窗缩成远处的小窗,浮层从下方升起。从一条生活进入「看那时说的话」,可以返回这一天。
7. 「月光/雾光」切换时,天空、光雾、景物、雨、窗灯和光点会在约 1 秒内连续混色,浮层材质和强调色约 0.6 秒渐变,布局不动。月光主题有月亮、光束和云影;雾光主题没有月亮,改为缓慢折叠流动的极光雾带。
8. 手机可以打开同一个文件。可视高度变小时(键盘弹出或横屏变矮),人物窗先让出空间,只保留当前一句;输入时导航会收起,文字和输入框都不会被挡住。
## 和 HEL-790 旧稿的区别
| 旧稿(PR#6,未获认可) | 本稿 | 原因 |
| --- | --- | --- |
| 默认手动翻页,每次发消息都会关闭自动续读。实测内容已生成到第 12 页,画面仍停在第 2 页 | 默认自动连续呈现,停留时间随句子长度变化(约 2.4–8 秒)。暂停、回看、全文都保留。发新消息必定自动呈现 | 用户反馈中的第一个问题;当前要求 VIS-013、UX-002 |
| 背景只有缓慢平移的 CSS 雾层;月亮和城市基本静止 | 用 Canvas 实时绘制八层空间:天空、星、月亮与光束(月光)或极光雾带(雾光)、远雾、带呼吸窗灯的城市、细雨、近雾、景深光点。停留时能明显看到变化 | 用户问“动态背景、特效、氛围感呢?”;当前要求 VIS-006、VIS-007 |
| 人物窗用缩放 FLIP 展开,弧形会变形 | 直接对实际几何属性做动画(位置、尺寸、圆角):拱形连续变为画框,房间远景推近到近景,可以中途打断 | 连续空间关系,交流时自然放大(VIS-011、CG-004) |
| 进入交流、打开浮层时背景只做 1.05 倍缩放 | 弹簧镜头带动各层按深度推近或拉远;光点按景深虚化,月亮避开人物 | 操作时要有明显的景深变化(VIS-007) |
| 记录浮层出现在右侧时会盖住人物 | 打开全文或记录时,她退到左侧并保持在画面中 | 交流以人物为主(VIS-013) |
| 当前句只有一行,切换时直接替换 | 显示上一句和当前句;旧句上移变淡,新句由模糊转清晰,她的字逐字柔和浮现;仍在生成的半句不提前显示,所以不会反复闪烁 | 当前一两句,柔和进出(UX-002) |
可以继续沿用的部分:获批的两套主题色、首页人物和文字的位置与分量、透光导航、各页面入口与演示内容,以及未分享不显示的原则。
## 视觉规范(施工依据)
**配色 Token**(两套主题结构相同)
| Token | 靛蓝月光 | 蓝紫雾光 | 用途 |
| --- | --- | --- | --- |
| `--accent` | `#a9dcff` | `#ebbcff` | 关键操作、选中状态、伴侣身份点 |
| `--accent-glow` | `#96d2ff55` | `#e2aaff5c` | 微光阴影 |
| `--text-1/2/3` | `#eaf0ff / #b9c6e6 / #94a5c8` | `#f3ecff / #d8c8ee / #b9a5d6` | 正文/次要/说明 |
| `--glass` / `--glass-read` | `#16244e70` / `#0b1534b8` | `#36226670` / `#211444bd` | 轻浮层/阅读浮层(模糊 20px/30px) |
| `--user` | `#d7e2ff` | `#f1e2ff` | 你说的话,比她的话更素 |
| Canvas 天空 5 段 | `#050918 → #0a1430` | `#0e0820 → #4a3478` | 见 `atmos.js` 中 `THEMES` |
**字体**:PingFang SC / HarmonyOS Sans SC / MiSans / 微软雅黑。
| 场景 | 电脑 | 手机 |
| --- | --- | --- |
| 当前句 | 21px / 350 / 行高 1.72 | 17px |
| 你说的当前句 | 19px | 16px |
| 上一句 | 15px,透明度 .42,单行 | 13px |
| 身份标签 | 12px,字距 .06em | 12px |
| 首页标题 | 30px / 300 | 23px |
**身份**:她的话前面有实心发光点和她的名字;你的话前面是空心圆圈和「你」。完整记录使用同样的标记。名字过长时,标签最多显示 16em,超出部分省略;顶部栏会单独截断。
**动效**:只用两条曲线。进入和停稳用 `cubic-bezier(.22,1,.36,1)`;空间移动用 `cubic-bezier(.65,0,.25,1)`。
| 动效 | 触发 | 时长 | 能否打断 |
| --- | --- | --- | --- |
| 按钮反馈 | 按下 | 160ms,缩放 .97 | 是 |
| 新句进入 | 自动或点击前进 | 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 | 页面隐藏时暂停 |
**断点**:
- **电脑(>1100px)**:左侧文字,右侧拱形人物窗;交流时画框居中,宽度为 76vw,最大 1000px。
- **720–1100px**:隐藏天气和状态文字。
- **手机(≤720px)**:人物窗在右上方,文字在下方;交流时画框占满宽度。
- **可视高度 <610px**:只显示当前句,人物窗让出空间;正在输入时收起导航。
- **可视高度 <420px**:再隐藏计数和「全文」,入口仍保留在记录中。
**性能与降级**:
- Canvas 会自动降档:最近 90 帧的平均绘制时间持续超过 9ms 时,从档 0(全分辨率)降到档 1(0.75 倍分辨率、六成粒子),再降到档 2(0.5 倍分辨率、三成多粒子、约 30fps,并去掉浮层背景模糊)。
- 远处城市只在尺寸变化时预渲染,柔光和雾在 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 帧率未测。
## 复现
```bash
python3 prototypes/hel-793/build.py # 或 Windows:powershell -File prototypes/hel-793/build.ps1
```
`tools/` 是本次在 Windows 上实际使用的 PowerShell 和无头 Edge 驱动,没有新增产品依赖:
- `shots.ps1`:截图。
- `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`。
## 验证范围与未验证项
- 已实测:无头 Edge 155(Chromium)下,电脑 1440×900、1280×720,手机等效视口 390×844、360×740、320×568,以及缩小到 390×430 的键盘等效视口;两套主题;系统减少动效;强制低档。
- **未验证**:真机软键盘、iOS Safari 和 Safari 桌面版(`ctx.filter` 在 Safari 上会退化为不模糊的边缘)、Firefox、真实 GPU 性能,以及低端手机上的实际帧率。等效视口不能代替真机验收。
- 回复是逐句模拟生成的,不是真实 SSE。真实的词元流如何拼成完整句子,需要在接入时验证。
+16
View File
@@ -0,0 +1,16 @@
# Same output as build.py, for machines without Python (Windows PowerShell 5.1).
$root = $PSScriptRoot
$src = Join-Path $root 'src'
$utf8 = New-Object System.Text.UTF8Encoding($false)
$read = { param($p) [System.IO.File]::ReadAllText((Join-Path $src $p), $utf8) }
$html = & $read 'shell.html'
$html = $html.Replace('{{STYLE}}', (& $read 'space.css'))
$html = $html.Replace('{{SCRIPT}}', (& $read 'atmos.js') + "`n" + (& $read 'space.js'))
$assets = Join-Path (Split-Path (Split-Path $root)) 'web\public\assets'
foreach ($pair in @(@('ROOM', 'her-room.jpg'), @('TALK', 'her-talk.jpg'))) {
$b64 = [Convert]::ToBase64String([System.IO.File]::ReadAllBytes((Join-Path $assets $pair[1])))
$html = $html.Replace('{{' + $pair[0] + '}}', 'data:image/jpeg;base64,' + $b64)
}
if ($html.Contains('{{')) { throw 'Unreplaced placeholder' }
[System.IO.File]::WriteAllText((Join-Path $root 'index.html'), $html, $utf8)
"Built offline index.html ($($utf8.GetByteCount($html)) bytes)"
+15
View File
@@ -0,0 +1,15 @@
"""Build an offline, single-file prototype. Standard library only; no network."""
import base64
from pathlib import Path
ROOT = Path(__file__).resolve().parent
src = ROOT / 'src'
html = (src / 'shell.html').read_text(encoding='utf-8')
html = html.replace('{{STYLE}}', (src / 'space.css').read_text(encoding='utf-8'))
html = html.replace('{{SCRIPT}}', (src / 'atmos.js').read_text(encoding='utf-8') + '\n' + (src / 'space.js').read_text(encoding='utf-8'))
for key, filename in [('ROOM', 'her-room.jpg'), ('TALK', 'her-talk.jpg')]:
data = (ROOT.parents[1] / 'web/public/assets' / filename).read_bytes()
html = html.replace('{{' + key + '}}', 'data:image/jpeg;base64,' + base64.b64encode(data).decode())
assert '{{' not in html
(ROOT / 'index.html').write_text(html, encoding='utf-8', newline='\n')
print(f'Built offline index.html ({len(html.encode()):,} bytes)')
Binary file not shown.

After

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

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.

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

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
}
}
@@ -0,0 +1,195 @@
[
{
"check": "auto-continues-long-reply-to-last-line",
"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 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,
"busy": false,
"flow": true
}
}
},
{
"check": "all-text-preserved-in-lines",
"pass": true,
"detail": {
"same": true,
"lines": 12
}
},
{
"check": "pause-holds-position",
"pass": true,
"detail": {
"before": 1,
"after6s": 1
}
},
{
"check": "review-back-offers-return",
"pass": true,
"detail": {
"hidden": false,
"text": "回到读到的地方 · 后面还有 11 句",
"c": 0,
"head": 1
}
},
{
"check": "return-resumes-flow",
"pass": true,
"detail": {
"now": {
"c": 2,
"flow": true
},
"head": 1
}
},
{
"check": "send-resumes-auto",
"pass": true,
"detail": {
"c": 2,
"n": 3,
"flow": true,
"toast": "新的一轮会自动呈现,随时可以暂停"
}
},
{
"check": "identity-labelled",
"pass": true,
"detail": {
"value": [
{
"role": "her",
"label": "晚晚"
},
{
"role": "her",
"label": "晚晚"
}
],
"Count": 2
}
},
{
"check": "cancel-marks-incomplete",
"pass": true,
"detail": {
"status": "cancelled",
"len": 115,
"busy": false
}
},
{
"check": "history-holds-then-returns",
"pass": true,
"detail": {
"before": 1,
"whileOpen": 1,
"after": {
"c": 1,
"mode": "talk"
}
}
},
{
"check": "camera-depth-talk",
"pass": true,
"detail": {
"tier": 0,
"scale": 1,
"z": 0.998,
"theme": 0,
"motes": 40,
"drops": 170
}
},
{
"check": "long-name-contained",
"pass": true,
"detail": {
"name": "星河与晚风之间慢慢醒来的小月亮",
"overflow": false,
"fits": true
}
},
{
"check": "keyboard-viewport-no-overlap",
"pass": true,
"detail": {
"compact": true,
"composerBottom": 422,
"vh": 430,
"lineTop": 237.015625,
"lineBottom": 330,
"portalBottom": 224,
"composerTop": 370
}
},
{
"check": "small-phone-layout",
"pass": true,
"detail": {
"lineBottom": 468,
"composerTop": 508,
"composerBottom": 560,
"navTop": 581.4749755859375,
"w": 320,
"lineRight": 302
}
},
{
"check": "reduced-motion-respected",
"pass": true,
"detail": {
"z": 1,
"anims": 0
}
},
{
"check": "low-tier-fallback",
"pass": true,
"detail": {
"tier": 2,
"scale": 0.5,
"z": 0,
"theme": 1,
"motes": 18,
"drops": 59
}
},
{
"check": "no-overlap-while-flowing-390x844",
"pass": true,
"detail": {
"minGapPx": 8
}
},
{
"check": "no-overlap-while-flowing-360x740",
"pass": true,
"detail": {
"minGapPx": 8
}
},
{
"check": "no-overlap-while-flowing-1440x900",
"pass": true,
"detail": {
"minGapPx": 22
}
},
{
"check": "no-overlap-while-flowing-1280x720",
"pass": true,
"detail": {
"minGapPx": 22
}
}
]
+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 项全部通过,截图已查看。其余旧截图和录屏未重录,因为本次没有改变静止画面。"
]
}
+40
View File
@@ -0,0 +1,40 @@
{
"task": "HEL-793 视觉总监亲自重做沉浸界面和对话代码",
"baseline_version": "1.8",
"read_commit": "81d7cb523db30b9019421355237b3184fc272075",
"read_notes": [
"完整读取治理分支 agent/agent/6f5a7d8d7cb6(PR#7)head 81d7cb5 的 AGENTS.md、docs/requirements/README.md、current.md、coverage.md、task-handoff-template.md 与 sources/visual-confirmation-v2.md;并对照 main 4242f8377e350e5f17e836d275c15b0f099bf109(v1.7)。v1.8 仅新增 GOV-005,产品要求未变。",
"已读主卡用户反馈线程 01a1639b-8416-7b0b-8dbf-0de16c257cb5 与分工裁定 01a163a5-e2a7-7ac8-afdb-991868e721bc。",
"参考旧稿 PR#6 head ca5ddd6cb4525b37b7c1c18f44221bb7e3e57558 的 prototypes/hel-790(只读,不作为基准)。"
],
"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/:视觉总监本轮设计并亲自编写的离线单文件动态小样(index.html),含源码(src/atmos.js 空间引擎、src/space.js 交互与阅读、src/space.css 规范 Token 与布局)、构建脚本、视觉规范(README.md)、实测截图、录屏、录屏抽帧和行为检查记录。未改正式界面、8310/8300、用户库、伴侣、出生底色、聊天或记忆。",
"acceptance": [
"长回复默认自动一句接一句呈现到最后一句,无需点击;实测 12 句约 52 秒走完(evidence/verification.json: auto-continues-long-reply-to-last-line)。",
"分句后的文字拼接起来与原文完全一致,不丢字(all-text-preserved-in-lines)。",
"可以暂停;往回看时会提示“回到读到的地方”,点击后继续自动呈现;暂停后发送新消息会自动恢复呈现并给出提示。",
"每行都标明“你”或伴侣实际的名字,长名字不溢出;完整记录使用同样的身份标记。",
"打开记录或全文时她仍在画面中(退到左侧);关闭后回到同一句并继续。",
"停留时背景能明显看到变化:月光光束和云影、多层雾、城市窗灯与塔灯、细雨、景深光点、人物窗慢推与灯光呼吸;雾光主题为流动的极光雾带(evidence/*-idle.webm 及抽帧图)。",
"相处、交流、记录、浮层之间是一个连续的空间:人物窗由拱形连续变为画框,并从远景推近到近景;镜头深度随之推近或后退。",
"两套主题结构一致,空间与材质一起渐变。",
"电脑 1440×900、1280×720 和手机等效 390×844、360×740 下,长回复呈现过程中文字与人物窗、输入框始终不重叠;320×568 与键盘等效 390×430 视口下当前句、输入框可用且不被遮挡。",
"系统减少动效时只保留淡入淡出;强制低档时分辨率与粒子数降低,并去掉浮层模糊。"
],
"deferred": [
"用户体验验收:本小样尚未经白栖知认可;认可前不替换正式界面,不合并 PR#4。",
"PR:本次运行在 Windows 运行时上,没有 git 与仓库写入凭据(ssh 主机未登记、无密钥),无法建任务分支、提交或开 PR;交付以附件形式提交,需总管协调有写权限的环境把 prototypes/hel-793 提交到目标 main 的独立分支并开 PR。",
"需求检查脚本:此机器没有 Python,未运行 python3 scripts/check_requirements.py;仅用 PowerShell 按脚本的 --handoff 规则核对了本 JSON 的结构。提交后须在有 Python 与 git 的环境补跑 --base-ref origin/main(若届时 main 仍为 v1.7,应在 PR#7 合入后再核对,或把本交接的基准改为实际目标版本)。",
"真机软键盘、iOS/macOS Safari、Firefox、真实 GPU 与低端手机帧率未验证。",
"emil-skills 在本运行时不可用;动效按其公开的通用原则(进入用 ease-out、交互反馈 ≤300ms、可打断、不从 0 缩放、尊重减少动效)自行确定,待技能可用时复核。",
"其余页面(生活与回忆、创建与管理的完整状态)只沿用旧稿入口和内容,并已统一到新空间;完整页面细稿将在本轮方向获认可后补齐。",
"R1 个性诠释与成长、R2–R7 真实接入、口型研究仍由主卡 HEL-697 追踪。"
],
"requirement_changes": [],
"verification": [
"evidence/verification.json:无头 Edge 155 中 19 项行为检查全部通过(含自动续读、完整性、暂停与回看、发送后恢复、身份、停止、记录开合、镜头深度、长名字、键盘等效视口、小屏、减少动效、低档降级、4 种视口下持续不重叠)。",
"evidence/*.png:两套主题的电脑与手机相处页、交流页、日子页、记录页截图,均已实际查看。",
"evidence/*.webm:电脑月光停留 12 秒、雾光停留 12 秒、雾光自动续读完整长回复 62 秒、月光空间往返(交流、暂停、回看、全文、记录、主题切换、日子、生活详情、设置、返回)52 秒、手机雾光完整流程 77 秒;每段都已解码,并生成 *-contact.png 抽帧图后实际查看。",
"录屏与截图基于同一份源码;最后一处修改只给浮层补充了主题切换时的颜色渐变(CSS),修改后已重新运行全部行为检查,录屏未重录。"
]
}
File diff suppressed because one or more lines are too long
+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承接。总工只定点复检本次差异及必要回归;通过后由总管到主卡交白栖知体验,不在本轮部署或合并。
+40
View File
@@ -0,0 +1,40 @@
# HEL-793 入库补充
视觉设计和原型代码由视觉总监亲自完成。总管在 Linux 环境仅接收附件、原样入库、补跑构建和需求检查,不改画面、动效或交互实现。原 `handoff.json` 保留作者交付时的事实,其中“未提交 PR、未运行 Python”由本补充的入库证据承接;其余未验证项仍有效。
## 来源与分支
- 原交付:HEL-793 评论 `01a16406-e51d-7052-b62d-d8558450c453`。
- 源码包附件:`01a16406-dea8-7a4b-934a-b4f994887dd5`,文件名 `hel-793-prototype-src-evidence.zip`。解包时仅将 Windows 路径分隔符转换为目录,共 42 个原始文件。除去截图工具 `tools/shots.ps1` 末尾一行多余空行,未更改作者的实现;入 Git 时两份原有 CRLF 文本按仓库规则归一为 LF。可运行 HTML 与作者交付逐字节相同。
- 需求基准:v1.8,`81d7cb523db30b9019421355237b3184fc272075`。已读正式需求入口、完整正文、覆盖表、视觉确认与本次关联原始评论。
- 本分支建立在治理 PR#7 的上述提交之上,目标仍为 `main`。入库时 `origin/main` 为 `4242f8377e350e5f17e836d275c15b0f099bf109`(v1.7)。PR#7 是显式依赖,尚未合并;本分支不包含产品 PR#4 或旧小样 PR#6。
- 相对 PR#7 的本次新增内容全部在 `prototypes/hel-793/`,没有修改正式入口或用户数据。没有变更需求。
## 实际补跑的检查
```bash
python3 prototypes/hel-793/build.py
python3 scripts/check_requirements.py --base-ref origin/main
python3 scripts/check_requirements.py --base-ref origin/main --handoff prototypes/hel-793/handoff.json
git diff --check
```
Python 构建成功,输出与作者提供的离线 HTML 逐字节相同,SHA256 为 `676e3e34de0c3a96fceb87eb84019ca62cf4cca1b6015861b17058a76b526a3b`。两项需求检查在本分支的 v1.8 上均通过,共 59 项;这不表示 v1.8 已进入 main,也不表示已获得视觉验收。
`evidence/verification.json` 是视觉总监在 Windows 无头 Edge 中运行的 19 项行为检查,不能表述成总管重新跑过浏览器。总管本轮没有重新验证真机键盘、Safari、Firefox 或 GPU 性能。独立审核仍须实看画面、动态过程并按 emil-skills 复核,不能只依据结构检查。
## 五段原始录屏
作者单独上传的录屏未包含在源码 zip 中,因此也没有放进本目录。`README.md` 和 `handoff.json` 中提到的 `evidence/*.webm` 指这些录屏,不能据该路径认定仓库已有视频。以下为平台附件入口;全部集中在同一条原交付评论下,可用 `multica attachment download <附件ID>` 获取:
| 录屏 | 附件 ID | 下载 |
| --- | --- | --- |
| 月光静置 12 秒 | `01a163fd-9367-78ab-a56b-310c1285205a` | [desktop-moon-idle.webm](https://api.solsum.cn/api/attachments/01a163fd-9367-78ab-a56b-310c1285205a/download) |
| 雾光静置 12 秒 | `01a163fe-0099-77d5-a83b-231c0e8f250c` | [desktop-fog-idle.webm](https://api.solsum.cn/api/attachments/01a163fe-0099-77d5-a83b-231c0e8f250c/download) |
| 长回复默认自动走完 | `01a163ff-e5c9-70c1-8fe2-bdd1c23d8c7a` | [desktop-fog-autoflow.webm](https://api.solsum.cn/api/attachments/01a163ff-e5c9-70c1-8fe2-bdd1c23d8c7a/download) |
| 电脑月光空间往返 | `01a16401-89d1-76f3-912b-c6849be35cb4` | [desktop-moon-walkthrough.webm](https://api.solsum.cn/api/attachments/01a16401-89d1-76f3-912b-c6849be35cb4/download) |
| 手机雾光完整流程 | `01a16403-7d98-7c10-9ea2-c2175a55a118` | [mobile-fog-walkthrough.webm](https://api.solsum.cn/api/attachments/01a16403-7d98-7c10-9ea2-c2175a55a118/download) |
## 下一步与边界
交总工独立核对治理依赖、完整需求、功能、界面和动效。小样体验是否认可由白栖知决定,认可前不替换正式界面、不合并小样或产品 PR#4。完整生活/回忆/创建/管理状态、R1 个性与成长、R2–R7 真实接入及口型研究仍由 HEL-697 追踪;本次入库不宣称这些已经完成。不改运行时、模型、认证、8310/8300 或用户数据。
+376
View File
@@ -0,0 +1,376 @@
'use strict';
// Atmosphere engine. One canvas, painted far → near:
// sky · stars · moon/aurora · soft fog (quarter-res, upscaled for natural blur) · far city with living windows
// · rain · near fog · motes. A spring camera gives each layer its own depth when the mode changes.
// Quality tiers drop resolution and particle counts if frames run long; reduced motion freezes drift.
const Atmos = (() => {
const cv = document.getElementById('atmos'), ctx = cv.getContext('2d');
const soft = document.createElement('canvas'), sctx = soft.getContext('2d');
const reducedQuery = matchMedia('(prefers-reduced-motion: reduce)');
const TAU = Math.PI * 2;
const THEMES = {
moon: {
sky: [[5,9,24],[9,19,46],[17,35,80],[24,44,88],[10,20,46]],
glow: [135,175,255], horizon: [131,183,228], fog: [[118,152,230],[150,184,240],[96,128,206]],
motes: [[169,220,255],[255,214,170],[205,225,255]], far: [19,37,74], near: [12,25,54],
win: [255,212,160], stars: 1, moon: 1, aurora: .25, rain: [190,215,255]
},
fog: {
sky: [[14,8,32],[28,18,64],[51,35,106],[74,52,120],[29,18,54]],
glow: [210,160,255], horizon: [224,171,250], fog: [[213,169,255],[166,146,244],[236,172,218]],
motes: [[235,188,255],[255,196,214],[196,176,255]], far: [46,33,80], near: [33,22,60],
win: [255,198,196], stars: .35, moon: 0, aurora: 1, rain: [230,205,255]
}
};
const lerp = (a, b, t) => a + (b - a) * t;
const mix3 = (a, b, t) => [lerp(a[0],b[0],t), lerp(a[1],b[1],t), lerp(a[2],b[2],t)];
const rgba = (c, a) => `rgba(${c[0]|0},${c[1]|0},${c[2]|0},${a})`;
const rand = (a, b) => a + Math.random() * (b - a);
let W = 0, H = 0, scale = 1, tier = 0, running = true, last = 0, time = 0;
let themeMix = 0, themeTarget = 0;
// Camera: z = depth push (talk +1, home 0, panes −0.6). Spring so interruptions stay continuous.
const cam = {z: 0, zv: 0, zt: 0, px: 0, py: 0, tx: 0, ty: 0};
let stars = [], windows = [], drops = [], motes = [], fogs = [], skyline = null, beacon = null, clouds = [];
let frameCost = [], slowFrames = 0;
function build() {
const dense = tier === 0 ? 1 : tier === 1 ? .6 : .35;
const area = W * H / (1440 * 900);
stars = Array.from({length: Math.round(120 * area * dense)}, () => ({x: Math.random(), y: Math.random() * .55, r: rand(.4, 1.3), p: rand(0, TAU), s: rand(.4, 1.6)}));
drops = Array.from({length: Math.round(170 * Math.min(area, 1.4) * dense)}, () => newDrop(true));
motes = Array.from({length: Math.round(34 * Math.min(area, 1.3) * dense) + 6}, () => newMote(true));
// Mist patches, far → near. Speeds are px/s at full width: slow, but visible within a few seconds.
fogs = [
{d: .12, y: .34, h: .12, w: .62, speed: 7, x: .15, c: 1, a: .2, bob: 3},
{d: .18, y: .46, h: .16, w: .8, speed: -5, x: .7, c: 0, a: .22, bob: 5},
{d: .25, y: .6, h: .2, w: .7, speed: 11, x: .4, c: 2, a: .26, bob: 4},
{d: .35, y: .7, h: .18, w: .9, speed: -9, x: .05, c: 1, a: .3, bob: 6},
{d: .45, y: .78, h: .2, w: .6, speed: 15, x: .85, c: 0, a: .28, bob: 5},
{d: .62, y: .88, h: .22, w: .75, speed: -16, x: .55, c: 2, a: .3, bob: 7},
{d: .8, y: .97, h: .24, w: .9, speed: 22, x: .2, c: 0, a: .32, bob: 6},
{d: .95, y: 1.04, h: .26, w: 1.1, speed: -28, x: .65, c: 1, a: .28, bob: 8}
];
clouds = Array.from({length: 3}, (_, i) => ({x: rand(-.3, 1.1), y: rand(.1, .2), w: rand(.2, .34), h: rand(.07, .11), v: rand(.006, .012) * (i % 2 ? 1 : .7)}));
buildSkyline();
}
function newDrop(anywhere) {
const near = Math.random() < .18;
return {x: rand(-.1, 1.1), y: anywhere ? Math.random() : rand(-.2, 0), l: near ? rand(26, 40) : rand(10, 20), v: near ? rand(.9, 1.2) : rand(.45, .7), a: near ? rand(.08, .14) : rand(.05, .1), near};
}
function newMote(anywhere) {
const d = Math.random();
return {x: Math.random(), y: anywhere ? Math.random() : 1.08, d, r: lerp(1.4, 9, d * d), vy: lerp(.004, .016, d), vx: rand(-.004, .004), p: rand(0, TAU), c: (Math.random() * 3) | 0, a: lerp(.5, .2, d)};
}
// The far city is code-drawn scenery behind fog — never a photo, so it never competes with her window.
// Two pre-rendered silhouettes per theme: a hazy far city that dissolves into mist at its base,
// and a low, out-of-focus near edge. Only window lights and the beacon are painted live.
function buildSkyline() {
const ow = Math.ceil(W * 1.2), oh = Math.ceil(H * (W < 720 ? .26 : .3)), nh = Math.ceil(H * .12);
skyline = {w: ow, h: oh, nh, far: {}, near: {}};
windows = [];
const far = [], near = [];
let x = 0, n = 0, tallest = null;
// Height follows a slow profile (a dense centre-left cluster, lower edges), plus per-tower jitter.
const profile = u => .35 + .4 * Math.exp(-Math.pow((u - .38) / .16, 2)) + .22 * Math.exp(-Math.pow((u - .78) / .1, 2)) + .08 * Math.sin(u * 23);
while (x < ow) {
const w = 10 + ((n * 37) % 30) + (n % 5 === 0 ? 16 : 0), jitter = ((n * 53) % 97) / 97;
const h = oh * Math.min(.98, Math.max(.1, profile(x / ow) * (.55 + jitter * .5)));
far.push({x, w, h, spire: n % 9 === 4, step: n % 3 === 1});
if (!tallest || h > tallest.h) tallest = {x: x + w / 2, h};
const cols = Math.max(1, Math.floor((w - 5) / 6)), rows = Math.floor((h - 10) / 8);
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
if (((n * 7 + r * 13 + c * 5) % 13) > 2) continue;
const lit = Math.random() < .5, y = oh - h + 7 + r * 8;
windows.push({x: x + 3 + c * 6, y, fade: 1 - (y / oh) * .75, a: lit ? rand(.3, .8) : 0, t: lit ? rand(.3, .8) : 0});
}
x += w + 1 + ((n * 11) % 5) - 2; n++;
}
for (let nx = 0, i = 0; nx < ow; i++) { const w = 40 + ((i * 29) % 70); near.push({x: nx, w, h: nh * (.35 + ((i * 41) % 60) / 100)}); nx += w + 6; }
beacon = tallest;
for (const name of ['moon', 'fog']) {
const th = THEMES[name];
const fc = document.createElement('canvas'); fc.width = ow; fc.height = oh;
const f = fc.getContext('2d'); f.filter = 'blur(1px)';
f.fillStyle = rgba(th.far, 1);
for (const r of far) {
if (r.step) { f.fillRect(r.x, oh - r.h * .82, r.w, r.h * .82); f.fillRect(r.x + r.w * .2, oh - r.h, r.w * .6, r.h * .2); }
else f.fillRect(r.x, oh - r.h, r.w, r.h);
if (r.spire) f.fillRect(r.x + r.w / 2 - 1, oh - r.h - 16, 2, 16);
}
f.filter = 'none'; f.globalCompositeOperation = 'source-atop';
const haze = f.createLinearGradient(0, 0, 0, oh);
haze.addColorStop(0, rgba(th.sky[1], .1)); haze.addColorStop(.6, rgba(th.horizon, .1)); haze.addColorStop(1, rgba(th.horizon, .28));
f.fillStyle = haze; f.fillRect(0, 0, ow, oh);
const nc = document.createElement('canvas'); nc.width = ow; nc.height = nh;
const g = nc.getContext('2d'); g.filter = 'blur(3px)'; g.fillStyle = rgba(th.near, 1);
for (const r of near) g.fillRect(r.x, nh - r.h, r.w, r.h + 4);
skyline.far[name] = fc; skyline.near[name] = nc;
}
}
function resize() {
const dpr = Math.min(devicePixelRatio || 1, 2);
W = innerWidth; H = innerHeight;
scale = (tier === 0 ? Math.min(dpr, 1.25) : tier === 1 ? .75 : .5);
cv.width = Math.round(W * scale); cv.height = Math.round(H * scale);
soft.width = Math.max(64, Math.round(W / 5)); soft.height = Math.max(48, Math.round(H / 5));
build();
}
function paintSky(th, T) {
const s = th.sky, g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(s[0], 1)); g.addColorStop(.32, rgba(s[1], 1)); g.addColorStop(.62, rgba(s[2], 1)); g.addColorStop(.8, rgba(s[3], 1)); g.addColorStop(1, rgba(s[4], 1));
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
}
// Soft layer: drawn at 1/5 resolution then stretched — light, halo, aurora and fog all blur for free.
function paintSoft(th, T, moonMix, auroraMix) {
const w = soft.width, h = soft.height, k = w / W;
sctx.clearRect(0, 0, w, h);
sctx.globalCompositeOperation = 'lighter';
const {x: mx, y: my} = moonPos();
// Moon halo breathes and dims as clouds cross it.
let cover = 0;
for (const c of clouds) { const dx = Math.abs(((c.x + c.w / 2) - mx)); cover = Math.max(cover, Math.max(0, 1 - dx / (c.w * .8))); }
const breathe = .82 + .18 * Math.sin(T * TAU / 9);
const haloA = moonMix * (.5 * breathe) * (1 - cover * .55);
if (haloA > .01) {
const R = Math.max(W, H) * .42 * k, g = sctx.createRadialGradient(mx * w, my * h, 0, mx * w, my * h, R);
g.addColorStop(0, rgba(th.glow, haloA)); g.addColorStop(.35, rgba(th.glow, haloA * .32)); g.addColorStop(1, rgba(th.glow, 0));
sctx.fillStyle = g; sctx.fillRect(0, 0, w, h);
// Moonlight shafts falling through the mist; they sway and brighten when the clouds clear.
for (let i = 0; i < 4; i++) {
const ang = .55 + i * .16 + Math.sin(T * .05 + i * 1.3) * .035, len = h * 1.25, spread = .028 + i * .006;
const a0 = haloA * (.22 - i * .035) * (.75 + .25 * Math.sin(T * .3 + i));
if (a0 <= 0) continue;
const ox = mx * w, oy = my * h;
const sg = sctx.createLinearGradient(ox, oy, ox + Math.cos(ang) * len, oy + Math.sin(ang) * len);
sg.addColorStop(0, rgba(th.glow, a0)); sg.addColorStop(.6, rgba(th.glow, a0 * .35)); sg.addColorStop(1, rgba(th.glow, 0));
sctx.fillStyle = sg; sctx.beginPath(); sctx.moveTo(ox, oy);
sctx.lineTo(ox + Math.cos(ang - spread) * len, oy + Math.sin(ang - spread) * len);
sctx.lineTo(ox + Math.cos(ang + spread) * len, oy + Math.sin(ang + spread) * len); sctx.closePath(); sctx.fill();
}
}
// Aurora ribbons: folding bands that keep moving; their crest light travels along the band.
if (auroraMix > .01) {
sctx.filter = 'blur(5px)';
for (let i = 0; i < 3; i++) {
const base = h * (.18 + i * .15), amp = h * (.05 + i * .02), thick = h * (.11 + i * .03);
const col = th.fog[i];
sctx.beginPath();
for (let x = 0; x <= w; x += 2) {
const y = base + amp * Math.sin(x / w * TAU * (.8 + i * .3) + T * (.16 + i * .05) + i * 2) + amp * .5 * Math.sin(x / w * TAU * 2.1 - T * .11);
x ? sctx.lineTo(x, y) : sctx.moveTo(x, y);
}
for (let x = w; x >= 0; x -= 2) {
const y = base + thick + amp * .7 * Math.sin(x / w * TAU * (.8 + i * .3) + T * (.16 + i * .05) + i * 2.4);
sctx.lineTo(x, y);
}
sctx.closePath();
const sweep = (Math.sin(T * .21 + i * 1.7) + 1) / 2;
const g = sctx.createLinearGradient(0, 0, w, 0);
g.addColorStop(0, rgba(col, 0)); g.addColorStop(Math.max(0, sweep - .35), rgba(col, .05 * auroraMix));
g.addColorStop(sweep, rgba(col, (.42 - i * .07) * auroraMix)); g.addColorStop(Math.min(1, sweep + .35), rgba(col, .05 * auroraMix)); g.addColorStop(1, rgba(col, 0));
sctx.fillStyle = g; sctx.fill();
}
sctx.filter = 'none';
}
// Horizon glow from the city below.
const hg = sctx.createRadialGradient(w * .5, h * .78, 0, w * .5, h * .78, w * .6);
hg.addColorStop(0, rgba(th.horizon, .3 + .06 * Math.sin(T * .7))); hg.addColorStop(1, rgba(th.horizon, 0));
sctx.fillStyle = hg; sctx.fillRect(0, 0, w, h);
// Far fog banks (depth < .5) live here; near banks are painted after the city.
for (const f of fogs) if (f.d < .5) fogBank(sctx, f, th, T, w, h, k);
sctx.globalCompositeOperation = 'source-over';
}
function fogBank(c, f, th, T, w, h, k) {
const off = layerOffset(f.d);
const span = w * f.w, x = (((f.x * w + f.speed * T * k * (reduced() ? .1 : 1)) % (w + span)) + w + span) % (w + span) - span / 2;
const y = h * f.y + off.y * k + Math.sin(T * .2 + f.x * 9) * f.bob * k * 3, rx = span / 2, ry = h * f.h / 2;
for (const dx of [0, -(w + span)]) {
const cx = x + dx + off.x * k, g = c.createRadialGradient(cx, y, 0, cx, y, rx);
g.addColorStop(0, rgba(th.fog[f.c], f.a)); g.addColorStop(.55, rgba(th.fog[f.c], f.a * .4)); g.addColorStop(1, rgba(th.fog[f.c], 0));
c.save(); c.translate(cx, y); c.scale(1, ry / rx); c.translate(-cx, -y);
c.fillStyle = g; c.fillRect(cx - rx, y - rx, rx * 2, rx * 2); c.restore();
}
}
// The moon keeps clear of her window: it slides aside as the camera pushes into a conversation.
function moonPos() {
const mobile = W < 720;
return {x: (mobile ? .2 : .17) - Math.max(0, cam.z) * (mobile ? .06 : .1), y: (mobile ? .12 : .15) - Math.max(0, cam.z) * (mobile ? .045 : .03)};
}
function layerOffset(d) {
return {x: cam.px * d * 26, y: cam.py * d * 14};
}
function withDepth(d, fn) {
const zoom = 1 + cam.z * .085 * (0.35 + d), o = layerOffset(d);
ctx.save(); ctx.translate(W / 2 + o.x, H * .62 + o.y); ctx.scale(zoom, zoom); ctx.translate(-W / 2, -H * .62); fn(); ctx.restore();
}
function paintStars(th, T, amount) {
if (amount < .02) return;
withDepth(.02, () => {
for (const s of stars) {
const a = amount * (.35 + .65 * (.5 + .5 * Math.sin(T * s.s + s.p)));
ctx.fillStyle = `rgba(230,236,255,${a * .7})`;
ctx.fillRect(s.x * W, s.y * H, s.r, s.r);
}
});
}
function paintMoon(th, T, amount) {
if (amount < .02) return;
const p = moonPos(), mx = p.x * W, my = p.y * H, r = W < 720 ? 22 : 34;
withDepth(.05, () => {
// In a conversation the moon steps back so it never sits beside her face.
ctx.globalAlpha = amount * Math.max(0, 1 - Math.max(0, cam.z) * 1.1);
const g = ctx.createRadialGradient(mx - r * .3, my - r * .3, r * .2, mx, my, r);
g.addColorStop(0, '#f6f8ff'); g.addColorStop(1, '#d6e0ff');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(mx, my, r, 0, TAU); ctx.fill();
// Thin clouds pass in front of the moon and carry its light.
for (const c of clouds) {
const cx = c.x * W, cy = c.y * H, cw = c.w * W, ch = c.h * H;
const cg = ctx.createRadialGradient(cx + cw / 2, cy, 0, cx + cw / 2, cy, cw / 2);
const lit = Math.max(0, 1 - Math.abs(cx + cw / 2 - mx) / (cw * .9));
cg.addColorStop(0, rgba(mix3(th.sky[1], [190,205,245], lit * .45), .42 + lit * .2)); cg.addColorStop(.6, rgba(th.sky[1], .18)); cg.addColorStop(1, rgba(th.sky[1], 0));
ctx.save(); ctx.translate(cx + cw / 2, cy); ctx.scale(1, ch / cw); ctx.translate(-(cx + cw / 2), -cy);
ctx.fillStyle = cg; ctx.fillRect(cx, cy - cw / 2, cw, cw); ctx.restore();
}
ctx.globalAlpha = 1;
});
}
function paintCity(th, T, dt) {
if (!skyline) return;
withDepth(.35, () => {
const ox = -W * .1, oy = H * .93 - skyline.h;
ctx.globalAlpha = .78 * (1 - themeMix); ctx.drawImage(skyline.far.moon, ox, oy);
ctx.globalAlpha = .72 * themeMix; ctx.drawImage(skyline.far.fog, ox, oy);
ctx.globalAlpha = 1;
// Windows switch on and off over a few seconds: life without motion noise.
if (!reduced() && Math.random() < dt * 3) { const w = windows[(Math.random() * windows.length) | 0]; if (w) w.t = w.t > .1 ? 0 : rand(.35, .8); }
for (const w of windows) {
w.a += (w.t - w.a) * Math.min(1, dt * .7);
if (w.a < .02) continue;
ctx.fillStyle = rgba(th.win, w.a * .6 * w.fade);
ctx.fillRect(ox + w.x, oy + w.y, 2, 2.5);
}
if (beacon) {
const on = (Math.sin(T * TAU / 2.6) + 1) / 2;
const bx = ox + beacon.x, by = oy + skyline.h - beacon.h - 3;
const g = ctx.createRadialGradient(bx, by, 0, bx, by, 9);
g.addColorStop(0, `rgba(255,120,120,${.65 * on})`); g.addColorStop(1, 'rgba(255,120,120,0)');
ctx.fillStyle = g; ctx.fillRect(bx - 9, by - 9, 18, 18);
}
});
// Near silhouette, darker, moves more with the camera.
withDepth(.75, () => {
const ox = -W * .1, oy = H - skyline.nh + 6;
ctx.globalAlpha = .95 * (1 - themeMix); ctx.drawImage(skyline.near.moon, ox, oy);
ctx.globalAlpha = .95 * themeMix; ctx.drawImage(skyline.near.fog, ox, oy);
ctx.globalAlpha = 1;
});
}
function paintRain(th, T, dt) {
const slant = .18;
for (const pass of [false, true]) {
ctx.beginPath();
for (const d of drops) {
if (d.near !== pass) continue;
if (!reduced()) { d.y += d.v * dt; d.x += d.v * dt * slant * .35; }
if (d.y > 1.05) Object.assign(d, newDrop(false));
const x = d.x * W + layerOffset(pass ? .8 : .3).x, y = d.y * H;
ctx.moveTo(x, y); ctx.lineTo(x - d.l * slant, y - d.l);
}
ctx.strokeStyle = rgba(th.rain, pass ? .11 : .07); ctx.lineWidth = pass ? 1.1 : .8; ctx.stroke();
}
}
const spriteCache = new Map();
function moteSprite(col) {
const key = col.join(',');
if (spriteCache.has(key)) return spriteCache.get(key);
const s = document.createElement('canvas'); s.width = s.height = 64;
const c = s.getContext('2d'), g = c.createRadialGradient(32, 32, 0, 32, 32, 32);
g.addColorStop(0, rgba(col, 1)); g.addColorStop(.25, rgba(col, .55)); g.addColorStop(1, rgba(col, 0));
c.fillStyle = g; c.fillRect(0, 0, 64, 64); spriteCache.set(key, s); return s;
}
function paintMotes(th, T, dt) {
ctx.globalCompositeOperation = 'lighter';
for (const m of motes) {
if (!reduced()) { m.y -= m.vy * dt; m.x += (m.vx + Math.sin(T * .3 + m.p) * .002) * dt; }
if (m.y < -.08) Object.assign(m, newMote(false));
const o = layerOffset(.2 + m.d), focus = Math.max(0, cam.z);
// Talking focuses on her: near motes grow and soften like a lens pulling focus.
const size = m.r * (1 + focus * m.d * 1.6) * 3.2, a = m.a * (.55 + .45 * Math.sin(T * .9 + m.p)) * (1 - focus * m.d * .4);
ctx.globalAlpha = Math.max(0, a);
ctx.drawImage(moteSprite(mix3(THEMES.moon.motes[m.c], THEMES.fog.motes[m.c], themeMix)), m.x * W + o.x - size / 2, m.y * H + o.y - size / 2, size, size);
}
ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over';
}
function paintNearFog(th, T) {
const w = soft.width, h = soft.height, k = w / W;
sctx.clearRect(0, 0, w, h);
sctx.globalCompositeOperation = 'lighter';
for (const f of fogs) if (f.d >= .5) fogBank(sctx, f, th, T, w, h, k);
sctx.globalCompositeOperation = 'source-over';
ctx.drawImage(soft, 0, 0, W, H);
}
function paintVignette() {
const strength = .55 + Math.max(0, cam.z) * .15 + Math.max(0, -cam.z) * .2;
const g = ctx.createRadialGradient(W / 2, H * .48, Math.min(W, H) * .3, W / 2, H * .5, Math.max(W, H) * .78);
g.addColorStop(0, 'rgba(2,4,14,0)'); g.addColorStop(1, `rgba(2,4,14,${strength})`);
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
}
function reduced() { return reducedQuery.matches || document.documentElement.classList.contains('no-motion'); }
function frame(now) {
if (!running) return;
const t0 = performance.now();
let dt = Math.min(.05, (now - (last || now)) / 1000); last = now;
// Tier 2 paints at ~30fps.
if (tier === 2 && dt < .03 && dt > 0) { requestAnimationFrame(frame); last -= dt * 1000; return; }
time += reduced() ? dt * .15 : dt;
// Spring camera (critically damped-ish) toward mode depth and pointer parallax.
const k = 38, damp = 2 * Math.sqrt(k) * .92;
if (reduced()) { cam.z = cam.zt; cam.zv = 0; } else { cam.zv += (cam.zt - cam.z) * k * dt - cam.zv * damp * dt; cam.z += cam.zv * dt; }
const drift = reduced() ? 0 : 1;
cam.px += ((cam.tx + Math.sin(time * .07) * .35 * drift) - cam.px) * Math.min(1, dt * 2.2);
cam.py += ((cam.ty + Math.cos(time * .05) * .25 * drift) - cam.py) * Math.min(1, dt * 2.2);
themeMix += (themeTarget - themeMix) * Math.min(1, dt * (reduced() ? 20 : 2.6));
const a = THEMES.moon, b = THEMES.fog, m = themeMix;
const th = {
sky: a.sky.map((c, i) => mix3(c, b.sky[i], m)), glow: mix3(a.glow, b.glow, m), horizon: mix3(a.horizon, b.horizon, m),
fog: a.fog.map((c, i) => mix3(c, b.fog[i], m)), far: mix3(a.far, b.far, m), near: mix3(a.near, b.near, m),
win: mix3(a.win, b.win, m), rain: mix3(a.rain, b.rain, m)
};
for (const c of clouds) { if (!reduced()) c.x += c.v * dt; if (c.x > 1.2) { c.x = -.35; c.y = rand(.08, .22); } }
ctx.setTransform(scale, 0, 0, scale, 0, 0);
paintSky(th, time);
paintStars(th, time, lerp(a.stars, b.stars, m));
paintSoft(th, time, lerp(a.moon, b.moon, m), lerp(a.aurora, b.aurora, m));
withDepth(.1, () => ctx.drawImage(soft, 0, 0, W, H));
paintMoon(th, time, lerp(a.moon, b.moon, m));
paintCity(th, time, dt);
paintRain(th, time, dt);
paintNearFog(th, time);
paintMotes(th, time, dt);
paintVignette();
// Adaptive quality: sustained slow frames step the tier down once.
const cost = performance.now() - t0;
frameCost.push(cost); if (frameCost.length > 90) frameCost.shift();
if (frameCost.length === 90 && tier < 2) {
const avg = frameCost.reduce((s, v) => s + v, 0) / 90;
if (avg > 9) { slowFrames++; if (slowFrames > 2) { tier++; slowFrames = 0; frameCost = []; resize(); document.documentElement.dataset.tier = tier; } }
else slowFrames = 0;
}
requestAnimationFrame(frame);
}
addEventListener('resize', resize);
addEventListener('pointermove', e => { if (e.pointerType === 'touch') return; cam.tx = (e.clientX / W - .5) * 2; cam.ty = (e.clientY / H - .5) * 2; }, {passive: true});
document.addEventListener('visibilitychange', () => { running = !document.hidden; if (running) { last = 0; requestAnimationFrame(frame); } });
resize(); requestAnimationFrame(frame);
return {
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; }
};
})();
+83
View File
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="zh-CN" data-theme="moon">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,interactive-widget=resizes-content">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'none'; form-action 'none'; base-uri 'none'">
<title>CyberGril · 夜里的同一片空间 · HEL-793 动态小样</title>
<style>{{STYLE}}</style>
</head>
<body data-mode="home">
<svg class="symbols" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><defs>
<symbol id="i-moon" viewBox="0 0 24 24"><path d="M20 15.5A9 9 0 0 1 8.5 4 9 9 0 1 0 20 15.5Z"/></symbol>
<symbol id="i-talk" viewBox="0 0 24 24"><path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5H4l-2 2 1.8-6A8.5 8.5 0 1 1 21 11.5Z"/></symbol>
<symbol id="i-book" viewBox="0 0 24 24"><path d="M5 3h14v18H5zM8 7h8M8 11h6M8 17h8"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 1v2m0 18v2M1 12h2m18 0h2M4 4l2 2m12 12 2 2M4 20l2-2M18 6l2-2"/></symbol>
<symbol id="i-mic" viewBox="0 0 24 24"><rect x="9" y="2" width="6" height="13" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2m-7 9v3m-4 0h8"/></symbol>
<symbol id="i-arrow" viewBox="0 0 24 24"><path d="m5 12 7-7 7 7m-7-7v16"/></symbol>
<symbol id="i-close" viewBox="0 0 24 24"><path d="m6 6 12 12M6 18 18 6"/></symbol>
<symbol id="i-lines" viewBox="0 0 24 24"><path d="M5 6h14M5 12h14M5 18h9"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><path d="M9 6v12M15 6v12"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="M8 5.5v13l10-6.5z"/></symbol>
<symbol id="i-prev" viewBox="0 0 24 24"><path d="m14.5 6-6 6 6 6"/></symbol>
<symbol id="i-next" viewBox="0 0 24 24"><path d="m9.5 6 6 6-6 6"/></symbol>
<symbol id="i-down" viewBox="0 0 24 24"><path d="m6 9.5 6 6 6-6"/></symbol>
</defs></svg>
<!-- The living space: one canvas engine draws sky, light, fog, city, rain and motes at their own depths. -->
<canvas id="atmos" aria-hidden="true"></canvas>
<div class="depth-veil" aria-hidden="true"></div>
<div id="viewport">
<header class="topbar">
<div class="identity"><span class="glint"></span><strong data-name>晚晚</strong><span class="presence">在家 · 醒着</span></div>
<div class="top-actions"><button id="rehearse" class="sample-tag">独立小样 · 演示数据</button><span class="weather">上海 · 小雨 14° <small>示例</small></span><div class="theme" role="group" aria-label="空间主题"><button data-theme="moon" aria-pressed="true"><i></i>月光</button><button data-theme="fog" aria-pressed="false"><i></i>雾光</button></div></div>
</header>
<main id="world" aria-label="相处空间">
<section class="home-copy" aria-label="此刻">
<p class="eyebrow">此 刻</p><h1>书翻到一半,<br>窗外的雨声不大。</h1><p class="quiet">安静,有点倦 <span>·</span> 你们最近挺熟络</p>
<button class="left-note glass" id="shared-note"><span class="note-meta"><span>她留给你的</span><time>今天 15:52</time></span><span class="note-text">楼下花店的洋桔梗开了。<br>我挑了一小束,放在窗边。</span></button>
<button class="small-link" id="days-link">你不在时,她分享过的小事 <span>›</span></button>
<div class="home-actions"><button class="primary" id="start-talk"><svg><use href="#i-talk"/></svg>和她说话</button><button class="round glass" id="home-voice" aria-label="语音演示"><svg><use href="#i-mic"/></svg></button></div>
</section>
<div class="portal-aura" aria-hidden="true"></div>
<figure class="portal" id="portal">
<div class="frame">
<img class="room-photo" src="{{ROOM}}" alt="获批参考静帧:她坐在高层卧室沙发上看书">
<img class="talk-photo" src="{{TALK}}" alt="获批参考静帧:同一人物、房间和衣服的交流近景">
<div class="lamp" aria-hidden="true"></div>
<div class="sheen" aria-hidden="true"></div>
</div>
<figcaption><span id="scene-label">窗边看书</span><span>参考静帧</span></figcaption>
<div class="portal-top"><span id="portal-state">在你面前</span><button class="close-talk" id="close-talk"><svg><use href="#i-close"/></svg><span>回到此刻</span></button></div>
</figure>
<section class="conversation" id="conversation" aria-label="当前对话" inert>
<div class="stage" id="stage" aria-hidden="true"></div>
<div class="reader-bar">
<button id="toggle-flow" class="flow" aria-pressed="true"><svg><use href="#i-pause"/></svg><span>暂停</span></button>
<button id="previous" class="icon" aria-label="上一句"><svg><use href="#i-prev"/></svg></button>
<span id="page-count" class="count">1 / 1</span>
<button id="next" class="icon" aria-label="下一句"><svg><use href="#i-next"/></svg></button>
<button id="read-all">全文</button>
<button id="stop" hidden>停止回复</button>
<button id="latest" class="latest" hidden><svg><use href="#i-down"/></svg><span>回到最新</span></button>
</div>
<div class="voice-row" id="voice-row" hidden><span class="voice-wave" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>语音界面演示 · 没有收音</span><button id="end-voice">结束演示</button></div>
<form class="composer glass" id="composer"><button type="button" class="round" id="history" aria-label="完整聊天记录"><svg><use href="#i-lines"/></svg></button><textarea id="draft" rows="1" maxlength="4000" placeholder="想和她说点什么…" aria-label="想和她说的话"></textarea><button type="submit" class="send round" aria-label="发送演示消息"><svg><use href="#i-arrow"/></svg></button><button type="button" class="voice-button" id="voice" aria-label="语音演示"><svg><use href="#i-mic"/></svg></button></form>
</section>
</main>
<nav class="navigation glass" aria-label="主要区域">
<span class="nav-glow" aria-hidden="true"></span>
<button data-nav="home" aria-current="page"><svg><use href="#i-moon"/></svg><span>此刻</span></button><button data-nav="talk"><svg><use href="#i-talk"/></svg><span>交谈</span></button><button data-nav="days"><svg><use href="#i-book"/></svg><span>日子</span></button><button data-nav="manage"><svg><use href="#i-sun"/></svg><span>设置</span></button>
</nav>
<p class="toast" id="toast" role="status" aria-live="polite"></p>
</div>
<dialog id="sheet" class="sheet glass" aria-labelledby="sheet-title"><header class="sheet-header"><div><p class="eyebrow" id="sheet-kicker"></p><h2 id="sheet-title"></h2></div><button class="round" id="close-sheet" aria-label="关闭浮层"><svg><use href="#i-close"/></svg></button></header><div id="sheet-content" class="sheet-content"></div></dialog>
<p id="announcement" class="sr" aria-live="polite" aria-atomic="true"></p>
<script>{{SCRIPT}}</script>
</body></html>
+253
View File
@@ -0,0 +1,253 @@
/* HEL-793 · 夜里的同一片空间
Tokens keep the approved two themes (HEL-744/746). Motion budget:
UI feedback 160–280ms · text in 420–680ms · space/camera 720ms · ambient loops 6–30s. */
:root{
color-scheme:dark;
--font-sans:"PingFang SC","HarmonyOS Sans SC","MiSans","Microsoft YaHei UI","Microsoft YaHei","Noto Sans CJK SC",system-ui,sans-serif;
--ease-out:cubic-bezier(.22,1,.36,1);--ease-space:cubic-bezier(.65,0,.25,1);--ease-in-out:cubic-bezier(.65,0,.35,1);
--dur-fast:160ms;--dur-ui:280ms;--dur-text:620ms;--dur-space:720ms;
--view-h:100dvh;--view-top:0px;--safe-bottom:env(safe-area-inset-bottom,0px);
--stage-h:158px;
}
html[data-theme="moon"]{--bg-0:#050918;--text-1:#eaf0ff;--text-2:#b9c6e6;--text-3:#94a5c8;--accent:#a9dcff;--accent-soft:#a9dcff1f;--accent-glow:#96d2ff55;--on-accent:#08142e;--user:#d7e2ff;--glass:#16244e70;--glass-read:#0b1534b8;--glass-line:#cddeff2b;--surface:#0b1330;--window-glow:#ffc48c2e;--rim:#f1e2ce40;--glass-blur:20px}
html[data-theme="fog"]{--bg-0:#0e0820;--text-1:#f3ecff;--text-2:#d8c8ee;--text-3:#b9a5d6;--accent:#ebbcff;--accent-soft:#ebbcff24;--accent-glow:#e2aaff5c;--on-accent:#2a0f40;--user:#f1e2ff;--glass:#36226670;--glass-read:#211444bd;--glass-line:#ecd8ff2e;--surface:#24154a;--window-glow:#ffaabe2e;--rim:#ffd8ee45;--glass-blur:30px}
html{transition:--accent .6s}
*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--bg-0)}
body{font:15px/1.7 var(--font-sans);color:var(--text-1);-webkit-font-smoothing:antialiased;transition:color .6s}
button,input,textarea,select{font:inherit;color:inherit}
button{border:0;background:none;cursor:pointer;transition:transform var(--dur-fast) var(--ease-out),opacity var(--dur-fast) var(--ease-out),background-color var(--dur-ui) var(--ease-out),color var(--dur-ui) var(--ease-out),box-shadow var(--dur-ui) var(--ease-out)}
button:disabled{opacity:.3;cursor:default}button:active:not(:disabled){transform:scale(.97)}
button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:4px}
button svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;flex:none}
h1,h2,h3,p,figure{margin:0}h2{font-size:22px;font-weight:350}h3{font-size:18px;font-weight:400}strong{font-weight:500}[hidden]{display:none!important}
.symbols,.sr{position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden;white-space:nowrap}
.glass{transition:background-color .6s,border-color .6s,box-shadow .6s,transform var(--dur-fast) var(--ease-out),opacity var(--dur-fast) var(--ease-out);background:var(--glass);border:1px solid var(--glass-line);box-shadow:inset 0 1px 0 #ffffff0d,0 20px 80px #0206162a;backdrop-filter:blur(var(--glass-blur)) saturate(125%);-webkit-backdrop-filter:blur(var(--glass-blur)) saturate(125%)}
.round{width:44px;height:44px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;flex:none}
.primary{display:inline-flex;gap:10px;align-items:center;justify-content:center;background:var(--accent);color:var(--on-accent);padding:13px 23px;border-radius:30px;box-shadow:0 0 30px var(--accent-glow),inset 0 1px 0 #ffffff60;position:relative;overflow:hidden}
.primary::after{content:"";position:absolute;inset:0;background:linear-gradient(105deg,transparent 30%,#ffffff55 50%,transparent 70%);transform:translateX(-120%);animation:glint 7s var(--ease-in-out) 2s infinite}
@keyframes glint{0%,70%{transform:translateX(-120%)}100%{transform:translateX(120%)}}
.small-link{color:var(--text-2);padding:12px 0;font-size:13px;text-align:left}
.eyebrow{font-size:12px;letter-spacing:.18em;color:var(--text-3);margin-bottom:15px}.quiet{color:var(--text-2);font-size:14px}.quiet span{margin:0 10px}.subtle{font-size:13px;color:var(--text-2)}
/* ── The space ── */
#atmos{position:fixed;inset:0;width:100%;height:100%;display:block}
.depth-veil{position:fixed;inset:0;background:radial-gradient(ellipse at 50% 40%,#02061400,#020614 90%);opacity:0;transition:opacity var(--dur-space) var(--ease-out);pointer-events:none}
body[data-mode="talk"] .depth-veil{opacity:.35}body[data-mode="pane"] .depth-veil{opacity:.3}
#viewport{position:absolute;top:var(--view-top);left:0;right:0;height:var(--view-h);min-height:280px;overflow:hidden}
#world{position:absolute;inset:0}
.topbar{height:78px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 32px;position:relative;z-index:4}
.identity{display:flex;align-items:center;gap:12px;min-width:0}.identity strong{font-size:18px;font-weight:450;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;letter-spacing:.04em}
.presence{color:var(--text-3);font-size:13px;white-space:nowrap}
.glint{flex:none;width:6px;height:6px;background:var(--accent);border-radius:50%;box-shadow:0 0 14px var(--accent);animation:pulse 4.5s var(--ease-in-out) infinite}
@keyframes pulse{50%{box-shadow:0 0 4px var(--accent);opacity:.65}}
.top-actions{display:flex;align-items:center;gap:12px;flex:none}
.sample-tag{border:1px dashed var(--glass-line);border-radius:20px;font-size:11px;padding:4px 10px;color:var(--text-2);white-space:nowrap}
.weather{font-size:12px;color:var(--text-2)}.weather small{color:var(--text-3)}
.theme{display:flex;border:1px solid var(--glass-line);padding:3px;border-radius:25px;background:var(--glass);backdrop-filter:blur(12px)}
.theme button{font-size:12px;color:var(--text-2);padding:5px 13px;border-radius:20px;display:flex;align-items:center;gap:6px;white-space:nowrap}
.theme button i{width:7px;height:7px;border-radius:50%;background:#a9dcff}.theme button[data-theme="fog"] i{background:#ebbcff}
.theme [aria-pressed="true"]{color:var(--text-1);background:var(--accent-soft);box-shadow:0 0 20px var(--accent-glow)}
/* ── Home ── */
.home-copy{position:absolute;left:9vw;top:50%;transform:translateY(-42%);width:min(440px,38vw);z-index:2;transition:opacity 420ms var(--ease-out),transform 520ms var(--ease-out),filter 420ms var(--ease-out)}
.home-copy>*{animation:rise 900ms var(--ease-out) both}.home-copy>:nth-child(2){animation-delay:80ms}.home-copy>:nth-child(3){animation-delay:160ms}.home-copy>:nth-child(4){animation-delay:240ms}.home-copy>:nth-child(5){animation-delay:300ms}.home-copy>:nth-child(6){animation-delay:360ms}
@keyframes rise{from{opacity:0;transform:translateY(10px);filter:blur(4px)}}
h1{font-size:30px;font-weight:300;line-height:1.65;letter-spacing:.02em;margin-bottom:16px;text-shadow:0 2px 24px #02061480}
.left-note{display:block;width:100%;text-align:left;padding:18px 20px;border-radius:20px;margin-top:32px;line-height:1.85}
.note-meta{display:flex;justify-content:space-between;gap:8px;font-size:12px;color:var(--accent);margin-bottom:10px}.note-meta time{color:var(--text-3)}
.home-actions{display:flex;align-items:center;gap:12px;margin-top:18px}.home-actions .round{width:50px;height:50px}
body:not([data-mode="home"]) .home-copy{opacity:0;transform:translate(-28px,-42%);filter:blur(6px);pointer-events:none}
/* ── Her window: one persistent element; geometry is animated by script between places. ── */
.portal{position:absolute;right:14vw;top:calc(50% - 7px);transform:translateY(-50%);width:clamp(300px,27vw,430px);aspect-ratio:1/1.3;border-radius:48% 48% 26px 26px;isolation:isolate;z-index:3;background:#1e2332;box-shadow:0 0 0 1px var(--rim),0 0 60px #ffdca712,0 30px 80px #03081866}
.portal .frame{position:absolute;inset:0;border-radius:inherit;overflow:hidden}
.portal img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transition:opacity var(--dur-space) var(--ease-out),transform var(--dur-space) var(--ease-space),filter var(--dur-space) var(--ease-out)}
/* Ambient life on approved stills: slow lens drift + breathing. Marked "参考静帧", never presented as video. */
.room-photo{object-position:88% 42%;transform-origin:80% 46%;transform:scale(1.16);animation:drift 28s var(--ease-in-out) infinite alternate}
.talk-photo{opacity:0;transform:scale(1.12);transform-origin:50% 30%;animation:breathe 6.5s var(--ease-in-out) infinite}
@keyframes drift{from{translate:0 0;scale:1}to{translate:-2.2% -1%;scale:1.045}}
@keyframes breathe{50%{scale:1.012;translate:0 -.25%}}
.lamp{position:absolute;inset:0;background:radial-gradient(circle at 74% 14%,#ffd29a55,transparent 32%),radial-gradient(circle at 18% 78%,#ffbf8a30,transparent 30%);mix-blend-mode:screen;animation:flicker 7s steps(1,end) infinite,glow 9s var(--ease-in-out) infinite alternate;pointer-events:none}
@keyframes flicker{0%,100%{opacity:.85}12%{opacity:.78}13%{opacity:.88}47%{opacity:.8}48%{opacity:.9}81%{opacity:.82}}
@keyframes glow{to{filter:brightness(1.18)}}
.sheen{position:absolute;inset:-40%;background:linear-gradient(115deg,transparent 42%,#ffffff12 48%,#ffffff05 52%,transparent 58%);animation:sheen 14s var(--ease-in-out) infinite;pointer-events:none}
@keyframes sheen{0%{transform:translateX(-30%)}60%,100%{transform:translateX(30%)}}
.portal figcaption{position:absolute;bottom:14px;left:17px;right:17px;display:flex;justify-content:space-between;gap:8px;color:#f3ede3;font-size:11px;text-shadow:0 1px 5px #000;transition:opacity var(--dur-ui) var(--ease-out)}
.portal figcaption span{padding:3px 9px;border-radius:15px;background:#14132170;backdrop-filter:blur(6px)}
.portal-aura{position:absolute;right:4vw;top:22%;width:46vw;height:56%;pointer-events:none;background:radial-gradient(ellipse,var(--window-glow),transparent 68%);filter:blur(30px);animation:aura 8s var(--ease-in-out) infinite alternate;transition:opacity var(--dur-space) var(--ease-out),transform var(--dur-space) var(--ease-space);z-index:1}
@keyframes aura{from{opacity:.65}to{opacity:1}}
.portal-top{position:absolute;inset:14px 14px auto;display:flex;align-items:flex-start;justify-content:space-between;opacity:0;pointer-events:none;color:#fff;transition:opacity var(--dur-ui) var(--ease-out) 0ms}
.portal-top>span,.close-talk{background:#10152594;border:1px solid #ffffff20;backdrop-filter:blur(12px);border-radius:24px;padding:7px 13px;font-size:12px}
.close-talk{display:flex;align-items:center;gap:5px}.close-talk svg{width:16px;height:16px}
body[data-mode="talk"] .portal{top:78px;left:50%;right:auto;transform:translateX(-50%);width:min(1000px,76vw);height:calc(var(--view-h) - 400px);min-height:120px;aspect-ratio:auto;border-radius:30px}
body[data-mode="talk"] .room-photo{opacity:0;transform:scale(1.55) translate(-6%,-2%);filter:blur(6px)}
body[data-mode="talk"] .talk-photo{opacity:1;transform:scale(1)}
body[data-mode="talk"] .portal-top{opacity:1;pointer-events:auto;transition-delay:380ms}
body[data-mode="talk"] .portal-aura{transform:scale(1.5) translateX(-22vw);opacity:.7}
body[data-mode="pane"] .portal{width:clamp(150px,15vw,230px);right:5vw;top:calc(50% - 7px);opacity:.6}
/* Record: she steps aside to the left and stays present while the full text or history is open on the right. */
body[data-mode="record"] .portal{top:84px;left:4vw;right:auto;transform:none;width:calc(92vw - min(480px,42vw) - 28px);height:calc(var(--view-h) - 183px - var(--safe-bottom));aspect-ratio:auto;border-radius:30px;opacity:.92}
body[data-mode="record"] .room-photo{opacity:0;transform:scale(1.55) translate(-6%,-2%)}
body[data-mode="record"] .talk-photo{opacity:1;transform:scale(1)}
body[data-mode="record"] .portal-aura{transform:scale(1.4) translateX(-30vw);opacity:.6}
body[data-mode="record"] .depth-veil{opacity:.3}
body[data-mode="pane"] .portal-aura{opacity:.3}
body[data-mode="pane"] .portal figcaption{opacity:0}
/* ── Conversation ── */
.conversation{position:absolute;bottom:calc(96px + var(--safe-bottom));left:50%;width:min(760px,80vw);transform:translate(-50%,16px);opacity:0;pointer-events:none;z-index:3;transition:opacity 420ms var(--ease-out),transform 520ms var(--ease-out)}
body[data-mode="talk"] .conversation{opacity:1;transform:translate(-50%,0);pointer-events:auto;transition-delay:160ms}
.stage{position:relative;height:var(--stage-h);display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:10px;padding:0 20px;text-align:center}
/* A reading pool: light falls off behind the words instead of a box being drawn around them. */
.stage::before{content:"";position:absolute;left:-12%;right:-12%;top:-10%;bottom:-36%;background:radial-gradient(ellipse 50% 52% at 50% 62%,#03071ab8,#03071a66 55%,transparent 100%);pointer-events:none;z-index:-1}
.line{width:100%;max-width:100%}
.line .who{display:inline-flex;align-items:center;gap:7px;font-size:12px;letter-spacing:.06em;color:var(--accent);margin-bottom:4px;max-width:100%}
.line .who b{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:16em}
.line .who i{width:6px;height:6px;border-radius:50%;background:var(--accent);box-shadow:0 0 10px var(--accent);flex:none}
.line[data-role="user"] .who{color:var(--text-2)}.line[data-role="user"] .who i{background:transparent;box-shadow:none;border:1px solid var(--text-2)}
.line .state{font-style:normal;color:var(--text-3);font-size:11px;letter-spacing:0}
.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)}
/* 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)}
body.reading-paused .line .dwell{opacity:.25}body.reading-held .line .dwell i{opacity:.45}
.line.waiting .state::after{content:"";display:inline-block;width:18px;height:6px;margin-left:5px;background:radial-gradient(circle,var(--accent) 1.5px,transparent 2px) 0 50%/6px 6px repeat-x;animation:dots 1.4s linear infinite;vertical-align:middle;opacity:.8}
@keyframes dots{50%{opacity:.25}}
/* Ink-in: characters settle in a soft wave (her lines only). */
.ink span{display:inline;animation:ink 460ms var(--ease-out) both;animation-delay:calc(var(--i) * 14ms)}
@keyframes ink{from{opacity:0;filter:blur(3px)}}
.reader-bar{display:flex;align-items:center;justify-content:center;gap:6px;font-size:12px;color:var(--text-2);margin:10px 0 12px;min-height:32px;flex-wrap:nowrap}
.reader-bar>button{min-height:32px;padding:0 10px;border-radius:16px;white-space:nowrap;display:inline-flex;align-items:center;gap:5px}
.reader-bar .icon{width:32px;padding:0;justify-content:center}.reader-bar svg{width:16px;height:16px}
.reader-bar .flow{border:1px solid var(--glass-line);background:#ffffff08}
.reader-bar .flow[aria-pressed="false"]{color:var(--accent);border-color:var(--accent-glow);box-shadow:0 0 14px var(--accent-glow)}
.reader-bar .count{min-width:52px;text-align:center;font-variant-numeric:tabular-nums;color:var(--text-3)}
#stop{color:var(--accent)}
.latest{color:var(--on-accent);background:var(--accent);box-shadow:0 0 18px var(--accent-glow)}
.composer{display:flex;gap:6px;align-items:flex-end;padding:7px;border-radius:30px}
.composer textarea{flex:1;min-width:0;resize:none;height:40px;max-height:92px;border:0;background:transparent;line-height:1.6;padding:8px 4px;outline:none;color:var(--text-1);font-size:15px}
.composer textarea:focus-visible{outline:0}.composer:focus-within{border-color:var(--accent-glow);box-shadow:0 0 0 1px var(--accent-soft),0 0 30px var(--accent-soft)}
.composer textarea::placeholder{color:var(--text-3)}
.send{background:var(--accent-soft);color:var(--accent)}
.voice-button{display:flex;align-items:center;justify-content:center;border-radius:50%;width:44px;height:44px;background:var(--accent);color:var(--on-accent);flex:none;box-shadow:0 0 18px var(--accent-glow)}
.voice-row{display:flex;align-items:center;justify-content:center;gap:12px;font-size:12px;color:var(--text-2);margin:0 10px 8px}.voice-row button{color:var(--accent)}
.voice-wave{display:inline-flex;gap:3px;align-items:center;height:14px}.voice-wave i{width:2px;height:100%;background:var(--accent);border-radius:2px;animation:wave 1.1s var(--ease-in-out) infinite}.voice-wave i:nth-child(2){animation-delay:.15s}.voice-wave i:nth-child(3){animation-delay:.3s}.voice-wave i:nth-child(4){animation-delay:.45s}
@keyframes wave{50%{transform:scaleY(.3)}}
/* ── 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),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)}
/* ── Panels: reading surfaces inside the space ── */
.sheet{position:fixed;inset:auto;margin:0;top:calc(var(--view-top) + 84px);left:5vw;width:min(950px,66vw);height:calc(var(--view-h) - 183px - var(--safe-bottom));max-height:none;max-width:calc(100vw - 24px);padding:0;border-radius:28px;color:var(--text-1);background:var(--glass-read);overflow:hidden;box-shadow:0 25px 100px #02041270,inset 0 1px 0 #ffffff10}
.sheet::backdrop{background:transparent}
.sheet[data-kind="history"],.sheet[data-kind="full"]{left:auto;right:4vw;width:min(480px,42vw)}
.sheet-header{display:flex;justify-content:space-between;align-items:center;gap:20px;padding:24px 26px 18px;border-bottom:1px solid var(--glass-line);height:110px}
.sheet-header .eyebrow{font-size:11px;margin-bottom:3px}
.sheet-content{height:calc(100% - 110px);overflow:auto;overscroll-behavior:contain;padding:24px 26px 34px;scrollbar-gutter:stable;line-height:1.9}
.sheet-content>*{animation:rise 520ms var(--ease-out) both}.sheet-content>:nth-child(2){animation-delay:40ms}.sheet-content>:nth-child(3){animation-delay:80ms}.sheet-content>:nth-child(4){animation-delay:120ms}.sheet-content>:nth-child(n+5){animation-delay:160ms}
.sheet-content :is(p,ul){margin-bottom:16px}
.sheet-content .row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:15px 0}
.sheet-content .pill{border:1px solid var(--glass-line);border-radius:24px;padding:8px 17px;font-size:13px}
.sheet-content .pill[aria-pressed="true"]{background:var(--accent-soft);color:var(--accent);box-shadow:0 0 16px var(--accent-soft)}
.sheet-content .item{width:100%;text-align:left;border-top:1px solid var(--glass-line);padding:22px 4px;display:block;border-radius:6px}
.item small{display:block;color:var(--text-2);margin-top:7px;font-size:13px}.item .date{font-size:11px;letter-spacing:.08em;color:var(--accent);margin-bottom:10px}
.quote{font-size:20px;font-weight:350;border-left:1px solid var(--accent);padding-left:20px;margin:24px 0}
.sheet-content .message{padding:18px 0;border-bottom:1px solid var(--glass-line)}
.message .author{display:flex;align-items:center;gap:7px;color:var(--accent);font-size:12px;margin-bottom:7px;overflow-wrap:anywhere}
.message .author i{width:6px;height:6px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent);flex:none}
.message[data-role="user"] .author{color:var(--text-2)}.message[data-role="user"] .author i{background:none;box-shadow:none;border:1px solid var(--text-2)}
.message p{font-size:15px;white-space:pre-wrap;overflow-wrap:anywhere}.message .incomplete{font-size:12px;color:var(--text-2)}
.sheet-content label{display:block;font-size:13px;color:var(--text-2);margin:18px 0 7px}
.sheet-content input,.sheet-content textarea,.sheet-content select{border:1px solid var(--glass-line);border-radius:12px;background:var(--surface);padding:11px 14px;width:100%;max-width:100%;font-size:15px}
.sheet-content textarea{min-height:90px;resize:vertical}
.sheet-content .notice{padding:14px 17px;border:1px solid var(--glass-line);border-radius:16px;color:var(--text-2);font-size:13px}
.sheet-content .status{color:var(--accent);min-height:26px;font-size:13px}.sheet-content .guide{font-size:13px;color:var(--text-2)}
.sheet-content summary{cursor:pointer;color:var(--accent);padding:10px 0}.sheet-content details{border-top:1px solid var(--glass-line);margin-top:20px}
.sheet-content .error{color:#ffb8c8}
*{scrollbar-width:thin;scrollbar-color:var(--accent-glow) transparent}
::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-thumb{background:var(--accent-glow);border-radius:10px}::-webkit-scrollbar-thumb:hover{background:var(--accent)}::-webkit-scrollbar-track{background:transparent}
@media(hover:hover) and (pointer:fine){
button:hover:not(:disabled){opacity:.85}.sheet-content .item:hover{color:var(--accent);background:linear-gradient(90deg,var(--accent-soft),transparent 70%)}
.reader-bar>button:hover:not(:disabled){background:#ffffff0d;opacity:1}
}
@media(max-width:1100px){
.weather,.presence{display:none}.sheet{width:86vw;left:7vw}.identity strong{max-width:180px}
body[data-mode="pane"] .portal{opacity:.3;right:3vw}
.sheet[data-kind="history"],.sheet[data-kind="full"]{width:min(460px,60vw);right:4vw}
body[data-mode="record"] .portal{width:calc(92vw - min(460px,60vw) - 28px)}
}
/* ── Phone ── */
@media(max-width:720px){
:root{--stage-h:168px}
.topbar{height:57px;padding:0 16px;gap:8px}.identity{gap:8px}.identity strong{font-size:15px;max-width:96px}
.top-actions{gap:6px}.sample-tag{font-size:10px;padding:4px 7px}.theme{padding:2px}.theme button{font-size:11px;padding:4px 9px;gap:4px}.theme button i{width:5px;height:5px}
.home-copy{left:24px;top:auto;bottom:calc(104px + var(--safe-bottom));width:calc(100% - 48px);transform:none}
body:not([data-mode="home"]) .home-copy{transform:translateY(16px)}
.home-copy .eyebrow{margin-bottom:10px}.home-copy h1{font-size:23px;line-height:1.55;margin-bottom:10px}.home-copy .quiet{font-size:12px}
.left-note{padding:13px 15px;font-size:13px;margin-top:16px;border-radius:18px}.note-meta{font-size:11px;margin-bottom:5px}
.home-copy .small-link{padding:8px 0;font-size:12px}.home-actions{margin-top:6px}.home-actions .primary{padding:10px 20px;font-size:14px}.home-actions .round{width:44px;height:44px}
.portal{right:22px;top:72px;transform:none;width:clamp(150px,46vw,230px);height:calc(var(--view-h) - 530px - var(--safe-bottom));min-height:180px;max-height:290px;aspect-ratio:auto;border-radius:48% 48% 18px 18px}
.portal figcaption{left:8px;right:8px;bottom:9px;font-size:9px;gap:4px}.portal figcaption span{padding:2px 6px}
.portal-aura{right:-20%;top:6%;width:100vw;height:44%}
body[data-mode="talk"] .portal{left:12px;right:12px;top:62px;width:calc(100% - 24px);transform:none;height:calc(var(--view-h) - 360px);min-height:56px;max-height:none;border-radius:24px}
body[data-mode="talk"] .portal-aura{transform:scale(1.2) translateX(-10vw)}
.portal-top{inset:10px 10px auto}.portal-top>span,.close-talk{font-size:11px;padding:5px 10px}.close-talk svg{width:13px;height:13px}
body[data-mode="pane"] .portal{top:76px;right:16px;width:120px;min-height:150px;height:150px;opacity:.22}
body[data-mode="record"] .portal{top:62px;left:12px;width:calc(100% - 24px);height:calc(var(--view-h) - 146px);min-height:56px;max-height:none;border-radius:24px;opacity:.3}
.conversation{width:calc(100% - 20px);bottom:calc(86px + var(--safe-bottom))}
.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{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}
body[data-mode="talk"] .latest span{max-width:9.5em;overflow:hidden;text-overflow:ellipsis}
}
@media(max-height:700px) and (max-width:720px){
body[data-mode="home"] .portal{top:64px;min-height:140px;height:170px;width:150px}
.home-copy{bottom:88px}.home-copy h1{font-size:20px}.home-copy .eyebrow,.home-copy .quiet{display:none}.home-copy .left-note{margin-top:10px;padding:9px 13px}.home-copy .left-note br{display:none}
}
/* A short visual viewport = keyboard open or a squat window. Her window yields first, text never does. */
body.compact{--stage-h:96px}
body.compact .topbar{height:48px}body.compact .weather,body.compact .presence{display:none}
body.compact[data-mode="talk"] .portal{top:52px;border-radius:16px}body.compact .portal figcaption{display:none}
body.compact .line.is-prev{display:none}body.compact .line p{font-size:16px;line-height:1.65}
body.compact .conversation{bottom:calc(72px + var(--safe-bottom))}body.compact .reader-bar{margin:3px 0 5px}
body.compact .navigation{bottom:calc(8px + var(--safe-bottom))}body.compact .navigation button{min-height:40px}
body.compact .sheet{top:calc(var(--view-top) + 52px);height:calc(var(--view-h) - 120px - var(--safe-bottom))}body.compact .sheet-header{height:72px;padding:10px 18px}body.compact .sheet-content{height:calc(100% - 72px)}
body.compact.typing[data-mode="talk"] .navigation{transform:translate(-50%,140%);opacity:0;pointer-events:none}
body.compact.typing .conversation{bottom:calc(8px + var(--safe-bottom))}
body.micro .reader-bar .count,body.micro #read-all{display:none}
@media(max-width:720px){
body.compact[data-mode="home"] .home-copy{top:60px;bottom:84px;transform:none}body.compact[data-mode="home"] h1{width:calc(100% - 130px);font-size:19px;line-height:1.6}
body.compact[data-mode="home"] .portal{top:60px;right:20px;width:115px;height:135px;min-height:100px}
body.compact[data-mode="home"] .left-note{position:absolute;bottom:58px;margin:0;padding:9px 12px}body.compact[data-mode="home"] .left-note .note-meta{margin:0}body.compact[data-mode="home"] .left-note .note-text{display:none}
body.compact[data-mode="home"] .small-link{display:none}body.compact[data-mode="home"] .home-actions{position:absolute;bottom:0;margin:0}
body.micro[data-mode="home"] .left-note{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}
}
/* Tier 2 (slow device): drop backdrop blur, the most expensive compositor effect. */
html[data-tier="2"] .glass,html[data-tier="2"] .sheet{backdrop-filter:none;-webkit-backdrop-filter:none;background:var(--glass-read)}
+478
View File
@@ -0,0 +1,478 @@
'use strict';
// Local fixtures only. CSP forbids connections; nothing is persisted.
const $ = s => document.querySelector(s);
const $$ = s => [...document.querySelectorAll(s)];
const html = document.documentElement;
const sheet = $('#sheet'), content = $('#sheet-content'), stage = $('#stage'), portal = $('#portal');
const reducedQuery = matchMedia('(prefers-reduced-motion: reduce)');
const motionOff = () => reducedQuery.matches || html.classList.contains('no-motion');
const EASE = 'cubic-bezier(.22,1,.36,1)'; // enter / settle
const EASE_SPACE = 'cubic-bezier(.65,0,.25,1)'; // camera-like moves between spaces
// record = the full text / history beside her; pane = life, memories, settings with her window set back.
const DEPTH = {home: 0, talk: 1, record: .7, pane: -.6};
const state = {
mode: 'home', name: '晚晚', relation: '朋友', turn: 0, cursor: 0, head: 0,
flow: true, held: new Set(), busy: false, voice: false, token: 0, dialog: null, returnMode: 'home',
dwell: 0, dwellTotal: 0, shown: [], createdNew: false
};
let messages = [
{id: -3, turn: -2, role: 'user', text: '淡色的花看起来很安静。', status: 'complete', life: 0},
{id: -2, turn: -2, role: 'her', text: '楼下花店的洋桔梗开了。我挑了一小束,放在窗边。', status: 'complete', life: 0},
{id: -1, turn: -1, role: 'her', text: '我们就这样一起听一会儿雨吧。', status: 'complete', life: 1},
{id: 1, turn: 0, role: 'user', text: '你今天在读什么?', status: 'complete'},
{id: 2, turn: 0, role: 'her', text: '一本旧散文集。读到写雨的那页,突然很想和你说说话。', status: 'complete'}
];
let nextId = 3, streamTimer = 0, portalAnim = null, closing = null, trigger = null, toastTimer = 0;
const SHORT = '刚刚看完一小节,正好想歇一会儿。你呢,今天过得怎么样?';
const LONG = '今天读到一段写雨的文字,想到小时候放学回家的那条路。那时候总觉得路很长,下雨时又不舍得走快,想听雨落在不同屋檐上的声音。后来搬到高一点的地方,雨声变远了,能看见的灯却多了。我还是会在这种时候把书放一放,看看窗外,给自己泡一杯热茶。\n\n有时候,一天里也没有发生什么大事。可能只是花店换了橱窗,或者读到一个特别喜欢的句子。但如果能把这些细小的事情讲给你听,这一天就像多了一盏亮着的灯。\n\n我也想知道你那边的天气。如果你今天有点累,我们可以慢慢聊,不急着把所有的话一次说完。等你想说的时候,我就在这里。最后这一句也完整保留:愿今晚的雨声让你睡得安稳。';
const FIXTURES = [
{title: '窗边的洋桔梗', time: '今天 · 15:52', text: '楼下花店的洋桔梗开了。我挑了一小束,放在窗边。', memory: '你说,淡色的花看起来很安静。'},
{title: '一起听过的雨', time: '昨天 · 21:16', text: '昨晚我们都没有急着说话,只是听了一会儿雨。', memory: '没有话的时候,也可以一起待一会儿。'}
];
const escapeHTML = v => String(v).replace(/[&<>"']/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
const graphemes = text => typeof Intl.Segmenter === 'function' ? [...new Intl.Segmenter('zh', {granularity: 'grapheme'}).segment(text)].map(s => s.segment) : Array.from(text);
const who = m => m.role === 'user' ? '你' : state.name;
function toast(text) {
const t = $('#toast'); t.textContent = text; t.classList.add('show');
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 2600);
}
function announce(text) { $('#announcement').textContent = text; }
function setName(name) {
state.name = name;
$$('[data-name]').forEach(n => { n.textContent = name; n.title = name; });
present('jump');
}
// ── Space: one persistent portal moves between places; the canvas camera moves with it. ──
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};
}
// 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], 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 (!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(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; }
glow.style.opacity = 1; glow.style.width = cur.offsetWidth + 'px'; glow.style.transform = `translateX(${cur.offsetLeft}px)`;
}
function openTalk(focus = false) { if (state.mode !== 'talk') setMode('talk'); if (focus) $('#draft').focus({preventScroll: true}); }
function fitTalkStage() {
if (state.mode !== 'talk') return;
const conv = $('#conversation').getBoundingClientRect(), w = $('#world').getBoundingClientRect();
const top = parseFloat(getComputedStyle(portal).top) || 0;
const gap = innerWidth <= 720 ? 10 : 18;
portal.style.height = Math.max(56, Math.floor(conv.top - w.top - top - gap)) + 'px';
}
// ── Reading: replies flow on by themselves, one or two lines at a time. ──
function chunks(text) {
const compact = document.body.classList.contains('compact');
const width = Math.max(220, stage.clientWidth - 24), font = compact ? 15 : innerWidth <= 720 ? 17 : 21;
const perLine = Math.floor(width / font), max = Math.max(14, Math.min(64, Math.floor(perLine * (compact ? 1.6 : 2.2))));
const units = graphemes(text), out = []; let line = '';
for (const ch of units) {
if (ch === '\n') { if (line.trim()) { out.push(line); line = ''; } continue; }
// Keep closing punctuation with its sentence; never produce an empty line.
if (!line && out.length && /[。!?!?,、;:,;:」』))”]/.test(ch)) { out[out.length - 1] += ch; continue; }
line += ch;
const n = graphemes(line).length;
if (/[。!?!?]/.test(ch) && n >= 4) { out.push(line); line = ''; }
else if (n >= max) {
// Prefer to break after a comma within the last third of the line.
const cut = Math.max(line.lastIndexOf(','), line.lastIndexOf('、'), line.lastIndexOf(';'));
if (cut > max * .55) { out.push(line.slice(0, cut + 1)); line = line.slice(cut + 1); } else { out.push(line); line = ''; }
}
}
if (line.trim()) out.push(line);
return out;
}
function pages() {
const list = [];
for (const m of messages.filter(m => m.turn === state.turn)) {
const parts = chunks(m.text);
// A still-growing sentence is held back until it is complete, so nothing re-animates mid-stream.
if (m.status === 'generating' && parts.length && !/[。!?!?\n]\s*$/.test(m.text)) parts.pop();
parts.forEach((text, index) => list.push({message: m, text, index, key: `${m.id}:${index}`}));
}
return list;
}
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, 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 && 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;
}
function present(how = 'advance') {
const list = pages();
state.cursor = Math.max(0, Math.min(state.cursor, list.length - 1));
state.head = Math.max(state.head, state.cursor);
updateControls(list);
const page = list[state.cursor];
if (!page) { stage.replaceChildren(); return; }
const prev = list[state.cursor - 1];
const want = [prev, page].filter(Boolean);
const have = [...stage.querySelectorAll('.line:not(.leaving)')];
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 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), 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.
const stageRect = stage.getBoundingClientRect();
for (const n of have) animate ? leave(n, n.getBoundingClientRect(), stageRect, true) : n.remove();
want.forEach(p => {
const el = lineEl(p, p === page); stage.append(el);
if (animate) el.animate([{opacity: 0, filter: 'blur(6px)'}, {opacity: 1, filter: 'blur(0)'}], {duration: 520, delay: 90, easing: EASE, fill: 'backwards'});
});
}
refreshState();
}
// 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', 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() {
const cur = stage.querySelector('.line.is-current:not(.leaving)'); if (!cur) return;
const list = pages(), atEnd = state.cursor >= list.length - 1;
const page = list[state.cursor];
const s = cur.querySelector('.state');
s.textContent = page && page.message.status === 'cancelled' && page.index === chunks(page.message.text).length - 1 ? '未完成 · 已取消' : atEnd && state.busy ? '还在说' : '';
cur.classList.toggle('waiting', atEnd && state.busy);
}
function updateControls(list = pages()) {
const n = list.length;
$('#previous').disabled = state.cursor <= 0;
$('#next').disabled = state.cursor >= n - 1;
$('#page-count').textContent = n ? `${state.cursor + 1} / ${n}${state.busy ? '…' : ''}` : '0 / 0';
$('#stop').hidden = !state.busy;
const t = $('#toggle-flow');
t.setAttribute('aria-pressed', String(state.flow));
t.innerHTML = state.flow ? '<svg><use href="#i-pause"/></svg><span>暂停</span>' : '<svg><use href="#i-play"/></svg><span>继续</span>';
const unread = n - 1 - state.cursor;
const latest = $('#latest');
latest.hidden = state.flow || state.cursor >= state.head;
latest.querySelector('span').textContent = unread > 0 ? `回到读到的地方 · 后面还有 ${unread} 句` : '回到读到的地方';
document.body.classList.toggle('reading-paused', !state.flow);
$('#portal-state').textContent = state.busy ? '正在回应 · 演示' : state.voice ? '语音界面演示' : '在你面前';
}
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(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();
if (!quiet) toast(on ? '继续自动呈现' : '已暂停 · 可以慢慢回看');
}
// Reading clock. Holds (hover, selection, open panels, hidden tab) freeze it without changing the user's choice.
let lastTick = 0;
function tick(now) {
const dt = lastTick ? Math.min(100, now - lastTick) : 0; lastTick = now;
if (state.mode === 'talk' && state.flow && !state.held.size && !document.hidden) {
const list = pages(), page = list[state.cursor];
if (page) {
if (state.dwell < state.dwellTotal) state.dwell += dt;
if (state.dwell >= state.dwellTotal && state.cursor < list.length - 1) go(1, false);
}
}
const bar = stage.querySelector('.line.is-current:not(.leaving) .dwell i');
if (bar) bar.style.transform = `scaleX(${state.dwellTotal ? Math.min(1, state.dwell / state.dwellTotal) : 0})`;
document.body.classList.toggle('reading-held', state.held.size > 0 && state.flow);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
const hold = (why, on) => { on ? state.held.add(why) : state.held.delete(why); };
// ── Simulated reply stream (sentence by sentence; not a real SSE connection). ──
function cancelReply(silent = false) {
const wasBusy = state.busy;
state.token++; clearTimeout(streamTimer); state.busy = false;
messages.filter(m => m.status === 'generating').forEach(m => { m.status = 'cancelled'; if (!m.text) m.text = '(回复在内容出现前取消)'; });
present('refresh');
if (wasBusy && !silent) toast('已停止。已出现的部分保留,并标为未完成');
}
function beginReply(text, reply = SHORT, after = null) {
cancelReply(true);
const resumed = !state.flow;
state.turn++; state.flow = true; state.cursor = 0; state.head = 0;
messages.push({id: nextId++, turn: state.turn, role: 'user', text, status: 'complete'});
present('jump');
if (resumed) toast('新的一轮会自动呈现,随时可以暂停');
streamReply(reply, after);
}
function streamReply(reply, after = null) {
const token = ++state.token;
const message = {id: nextId++, turn: state.turn, role: 'her', text: '', status: 'generating'};
messages.push(message); state.busy = true; updateControls(); refreshState();
const parts = reply.match(/[^。!?!?\n]+[。!?!?]*|\n+/gu) || [reply]; let index = 0;
function append() {
if (token !== state.token) return;
message.text += parts[index++];
if (index >= parts.length) { state.busy = false; message.status = 'complete'; }
updateControls(); refreshState();
if (state.dialog === 'history') renderHistory();
if (state.busy) streamTimer = setTimeout(append, 520); else if (after) after();
}
streamTimer = setTimeout(append, 900);
}
// ── Panels. Native dialog restores focus; the space pulls back while one is open. ──
function showSheet(kind, title, kicker, markup, nav = state.dialogNav) {
hold('sheet', true);
if (closing) { closing.cancel(); closing = null; }
state.dialogNav = nav;
const target = (kind === 'history' || kind === 'full') && (state.mode === 'talk' || state.mode === 'record') ? 'record' : 'pane';
if (!sheet.open) { trigger = document.activeElement; state.returnMode = state.mode; sheet.showModal(); setMode(target); }
else if (state.mode !== target) setMode(target);
else { $$('.navigation button').forEach(n => n.dataset.nav === nav ? n.setAttribute('aria-current', 'page') : n.removeAttribute('aria-current')); moveNavGlow(); }
state.dialog = kind; sheet.dataset.kind = kind;
$('#sheet-title').textContent = title; $('#sheet-kicker').textContent = kicker; content.innerHTML = markup; content.scrollTop = 0;
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(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, {instant});
if (trigger && trigger.isConnected && !trigger.inert) trigger.focus({preventScroll: true});
}
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(() => {});
}
function renderHistory() {
const keep = content.scrollTop;
content.innerHTML = '<p class="guide">本小样中的文字均为虚构。内容完整保留;关闭后回到刚才那一句,自动呈现会接着走。</p>' + messages.map(m => `<article class="message" data-role="${m.role}" data-life="${m.life ?? ''}"><div class="author"><i></i>${escapeHTML(who(m))}</div><p>${escapeHTML(m.text || '正在回应…')}</p>${m.status === 'cancelled' ? '<span class="incomplete">已停止 · 这是未完成片段</span>' : ''}</article>`).join('');
content.scrollTop = keep;
}
function showHistory() { showSheet('history', '你们说过的话', '完整记录 · 演示', '', 'talk'); renderHistory(); }
function showFull() {
const cur = pages()[state.cursor]; if (!cur) return;
const m = cur.message;
showSheet('full', '这一段,慢慢看', `${who(m)} · 演示全文`, `<p class="guide">整段完整可读。关闭后回到刚才那一句。</p><article class="message" data-role="${m.role}"><div class="author"><i></i>${escapeHTML(who(m))}</div><p>${escapeHTML(m.text)}</p>${m.status === 'cancelled' ? '<span class="incomplete">这段回复已停止,内容未完成。</span>' : ''}</article>`, 'talk');
}
function showDays(memory = false) {
if (state.createdNew) { showSheet('days', memory ? '我们的回忆' : '她的日子', '初次见面 · 演示', `<div class="row"><button class="pill" data-action="days" aria-pressed="${!memory}">她的日子</button><button class="pill" data-action="memories" aria-pressed="${memory}">我们的回忆</button></div><p>刚刚见面,还没有分享过的日子与共同回忆。</p><p class="guide">真实生活和记忆尚未接入,小样不会自动编出她未分享的经历。</p>`, 'days'); return; }
showSheet('days', '留在日子里的微光', '生活与共同回忆 · 演示', `<div class="row"><button class="pill" data-action="days" aria-pressed="${!memory}">她的日子</button><button class="pill" data-action="memories" aria-pressed="${memory}">我们的回忆</button></div><p class="guide">只放她已经向你分享的事。以下两条都是虚构的演示记录。</p>${FIXTURES.map((f, i) => `<button class="item" data-event="${i}"><div class="date">${f.time} · 已分享</div><h3>${memory ? f.memory : f.title}</h3><small>${memory ? '与这一天相连' : f.text}</small></button>`).join('')}<p class="subtle">更多真实生活、记忆和成长,等待后续功能接入。</p>`, 'days');
}
function eventDetail(i) {
const f = FIXTURES[i]; if (!f) return;
showSheet('event', f.title, '已分享的生活 · 演示', `<button class="small-link" data-action="days">‹ 回到她的日子</button><p class="subtle">${f.time}</p><div class="quote">${f.text}</div><h3>与你有关的回忆</h3><p>${f.memory}</p><button class="pill" data-event-history="${i}">看那时说的话</button><p class="guide">演示故事,不是真实的后台生活。</p>`, 'days');
}
function showManage() {
showSheet('manage', '慢慢安排你们的世界', '设置 · 演示', `<p class="guide">这里只体验入口和操作关系。所有更改只在这次小样中保留。</p><button class="item" data-action="profile"><h3>她与最初的底色</h3><small>出生底色只读,成长另行积累</small></button><button class="item" data-action="relationship"><h3>你们的关系</h3><small>称呼与相处边界,可以慢慢调整</small></button><button class="item" data-action="city"><h3>城市与天气</h3><small>跟随你选择的城市 · 本小样天气为示例</small></button><button class="item" data-action="appearance"><h3>外观素材</h3><small>换外观,保留同一个她</small></button><button class="item" data-action="backup"><h3>备份与恢复</h3><small>完整导出与每日备份 · 尚未接入</small></button><button class="item" data-action="services"><h3>语音与视觉观察</h3><small>开关、状态与时效 · 尚未接入</small></button><button class="item" data-action="display"><h3>空间与显示</h3><small>两套主题、动效强度</small></button>`, 'manage');
}
function showInfo(kind) {
const info = {
profile: ['她与最初的底色', '出生资料在创建后保留为只读底色,每轮重新提供给模型。个性的诠释、少量初始喜好与后来的成长分别保留。', '本小样不生成排盘、性格或成长,也不读取真实档案。相关真实能力仍须后续补齐。'],
appearance: ['外观素材', '替换外观前应先检查素材、预览新旧效果,再明确启用。记忆、生日、关系和成长仍属于同一个她。', '本小样只使用已认可的两张参考静帧,没有导入素材或实时口型。'],
backup: ['备份与恢复', '产品仍需每日自动备份,并支持包含人物、场景、设定与记忆的完整导出和恢复。', '备份尚未接入。本小样没有执行或伪装任何导出、备份、恢复操作。'],
services: ['语音与视觉观察', '语音由你主动开启,结束后停止收音。视觉观察可关闭,过期观察不再当作眼前事实。', '本小样不申请麦克风或摄像头权限,不采集声音与影像。']
}[kind];
showSheet(kind, info[0], '入口示意 · 尚未接入', `<button class="small-link" data-action="manage">‹ 返回设置</button><p>${info[1]}</p><p class="notice">${info[2]}</p>${kind === 'services' ? '<button class="pill" data-action="voice">体验语音界面演示</button>' : ''}`, 'manage');
}
function showRelationship() {
showSheet('relationship', '你们的关系', '演示表单', `<button class="small-link" data-action="manage">‹ 返回设置</button><form id="relation-form"><label for="relation">现在的关系</label><select id="relation"><option ${state.relation === '朋友' ? 'selected' : ''}>朋友</option><option ${state.relation === '恋人' ? 'selected' : ''}>恋人</option></select><label for="boundaries">相处边界</label><textarea id="boundaries" maxlength="500">${escapeHTML(state.boundaries || '彼此保留自己的时间,有想法可以直接说。')}</textarea><p class="guide">修改关系不会改写出生底色、过去的记忆与信任。</p><div class="row"><button class="primary" type="submit">确认本次演示选择</button><button class="pill" type="button" data-action="manage">取消</button></div><p id="form-status" class="status" role="status"></p></form>`, 'manage');
}
function showCity() {
showSheet('city', '她窗外的城市', '城市选择 · 演示', `<button class="small-link" data-action="manage">‹ 返回设置</button><form id="city-form"><label for="city">选择一个城市</label><select id="city"><option>上海</option><option>杭州</option><option>成都</option></select><p class="guide">正式产品将按城市跟随当地天气与作息。这里仅演示选择反馈,没有查询天气。</p><button class="primary" type="submit">保存演示选择</button><p id="form-status" class="status" role="status"></p></form>`, 'manage');
}
function showDisplay() {
showSheet('display', '空间与显示', '显示偏好 · 小样', `<button class="small-link" data-action="manage">‹ 返回设置</button><p>保留同一布局,切换整片空间的光、雾、景物与材质。</p><div class="row"><button class="pill" data-theme="moon" aria-pressed="${html.dataset.theme === 'moon'}">靛蓝月光</button><button class="pill" data-theme="fog" aria-pressed="${html.dataset.theme === 'fog'}">蓝紫雾光</button></div><button class="pill" data-action="motion">${html.classList.contains('no-motion') ? '恢复空间动效' : '减少动效'}</button><p class="guide">系统“减少动态效果”同样生效:背景几乎静止,只保留淡入淡出。设备较慢时会自动降低背景分辨率与粒子数量。</p>`, 'manage');
}
function showRehearsal() {
showSheet('rehearsal', '试试这些相处片段', '小样试用 · 全部为演示', `<p>回复会自动一句接一句呈现。想停下来时点「暂停」,往回看用左右箭头,「全文」看整段。</p><div class="row"><button class="pill" data-scenario="long">一段长回复</button><button class="pill" data-scenario="short">短句回复</button><button class="pill" data-scenario="incoming">连续来两段话</button><button class="pill" data-scenario="waiting">生成中与停止</button></div><p class="guide">每句停留随长度变化(约 2.4–8 秒),鼠标停在文字上会先停住计时,移开继续。新发消息总会自动呈现。</p><details><summary>试试初次见面与长名字</summary><p class="guide">只创建临时演示身份,不连接或重置现有伴侣。</p><button class="pill" data-action="create">重新演示初次创建</button></details><details><summary>验证说明与边界</summary><p class="guide">人物为获批参考静帧;文字、生活与回忆都是虚构。没有连接模型、语音、摄像头、天气或备份服务。背景中的雨、城市灯光与雾是界面氛围,不代表真实天气数据。</p></details>`);
}
function showCreation() {
showSheet('create', '先给她一个名字', '初次创建 · 临时演示', `<form id="create-form"><label for="new-name">你想怎么叫她</label><input id="new-name" maxlength="40" autocomplete="off" required placeholder="由你起名,1–20 字"><label for="new-relation">你们从什么关系开始</label><select id="new-relation" required><option value="" selected disabled>由你选择</option><option value="朋友">朋友</option><option value="恋人">恋人</option></select><p class="guide">出生资料、排盘与底色说明沿用既有创建流程,本次不模拟生成。确认仅进入这次虚构小样,不会接触真实档案。</p><div class="row"><button class="primary" type="submit">以这个名字体验小样</button><button class="pill" type="button" data-action="dismiss">取消</button></div><p id="creation-error" class="error" role="status"></p></form>`);
}
function showVoice() {
showSheet('voice', '体验语音界面', '没有收音的演示', `<p>这里仅演示语音开启、结束的界面。不会申请麦克风权限,也不会播放或识别声音。</p><div class="row"><button class="primary" data-action="start-voice">开始界面演示</button><button class="pill" data-action="dismiss">暂不开启</button></div>`, 'talk');
}
function runScenario(kind) {
if (sheet.open) { state.returnMode = 'talk'; finishClose(); }
openTalk();
if (kind === 'incoming') beginReply('你刚才想和我说什么?', '窗边的花开了一点。', () => streamReply('还有,今天的雨声很轻。我想把这片安静也分一点给你。'));
else if (kind === 'long') beginReply('慢慢讲,我想听听你今天的想法。', LONG);
else if (kind === 'waiting') beginReply('可以和我多说一会儿吗?', LONG);
else beginReply('你在做什么?', SHORT);
}
function applyTheme(theme, instant = false) {
if (!['moon', 'fog'].includes(theme)) return;
html.dataset.theme = theme; Atmos.setTheme(theme, instant || motionOff());
$$('button[data-theme]').forEach(n => n.setAttribute('aria-pressed', String(n.dataset.theme === theme)));
}
document.addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
if (b.dataset.theme) { applyTheme(b.dataset.theme); return; }
if (b.dataset.nav) {
const mode = b.dataset.nav;
if (mode === 'home' || mode === 'talk') { if (sheet.open) { state.returnMode = mode; closeSheet(); } else mode === 'home' ? setMode('home') : openTalk(); }
else if (mode === 'days') showDays(); else showManage();
return;
}
if (b.dataset.event !== undefined) { eventDetail(Number(b.dataset.event)); return; }
if (b.dataset.eventHistory !== undefined) {
const i = Number(b.dataset.eventHistory); showHistory();
content.insertAdjacentHTML('afterbegin', `<button class="small-link" data-event="${i}">‹ 返回这一天</button>`);
content.querySelector(`[data-life="${i}"]`)?.scrollIntoView({block: 'center'}); return;
}
if (b.dataset.scenario) { runScenario(b.dataset.scenario); return; }
const actions = {
days: () => showDays(), memories: () => showDays(true), manage: showManage, relationship: showRelationship, city: showCity, display: showDisplay, create: showCreation, voice: showVoice, dismiss: closeSheet,
motion: () => { html.classList.toggle('no-motion'); showDisplay(); },
'start-voice': () => { state.returnMode = 'talk'; finishClose(); openTalk(); state.voice = true; document.body.classList.add('has-voice'); $('#voice-row').hidden = false; fitTalkStage(); updateControls(); toast('语音界面演示已开启 · 没有收音'); }
};
const a = b.dataset.action;
if (actions[a]) actions[a](); else if (['profile', 'appearance', 'backup', 'services'].includes(a)) showInfo(a);
});
$('#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;
$('#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 = () => back();
$('#next').onclick = () => go(1);
$('#toggle-flow').onclick = () => setFlow(!state.flow);
$('#latest').onclick = () => { state.cursor = state.head; present('jump'); setFlow(true, true); };
$('#stop').onclick = () => cancelReply();
$('#composer').addEventListener('submit', e => { e.preventDefault(); const text = $('#draft').value.trim(); if (!text) return; $('#draft').value = ''; autoSize(); beginReply(text, /长|故事|慢慢|完整|多说/.test(text) ? LONG : SHORT); });
function autoSize() { const d = $('#draft'); d.style.height = 'auto'; d.style.height = Math.min(d.scrollHeight, 92) + 'px'; }
$('#draft').addEventListener('input', autoSize);
$('#draft').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); $('#composer').requestSubmit(); } });
$('#draft').addEventListener('focus', () => document.body.classList.add('typing'));
$('#draft').addEventListener('blur', () => document.body.classList.remove('typing'));
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(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 = '已保存演示选择;这里没有查询真实天气。'; }
else if (e.target.id === 'create-form') {
e.preventDefault(); const name = $('#new-name').value.trim(), relation = $('#new-relation').value;
if (!name || Array.from(name).length > 20 || /[<>\x00-\x1f\x7f]/.test(name) || !relation) { $('#creation-error').textContent = '名字请用 1–20 字,不含尖括号和控制符,并选择关系。'; return; }
cancelReply(true); messages = []; state.createdNew = true; state.turn++; state.cursor = 0; state.head = 0; state.flow = true;
messages.push({id: nextId++, turn: state.turn, role: 'her', text: '你好。很高兴在这里见到你。', status: 'complete'});
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', {instant: true}); $('#start-talk').focus(); }
if (state.mode === 'talk' && !sheet.open && document.activeElement !== $('#draft')) {
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));
// Keyboard / short screens: the visual viewport decides how much room her window and the lines get.
function fitViewport() {
const vv = window.visualViewport, h = vv ? vv.height : innerHeight, t = vv ? vv.offsetTop : 0;
html.style.setProperty('--view-h', `${h}px`); html.style.setProperty('--view-top', `${t}px`);
document.body.classList.toggle('compact', h < 610);
document.body.classList.toggle('micro', h < 420);
// Re-chunk without losing the reading position: keep the same character offset in the same message.
const cur = pages()[state.cursor];
const anchor = cur ? {id: cur.message.id, offset: chunks(cur.message.text).slice(0, cur.index).join('').length} : null;
requestAnimationFrame(() => {
if (anchor) { const list = pages(); let used = 0; for (let i = 0; i < list.length; i++) { if (list[i].message.id !== anchor.id) continue; if (used + list[i].text.length > anchor.offset) { state.cursor = i; break; } used += list[i].text.length; } }
stage.replaceChildren(); present('jump'); fitTalkStage(); moveNavGlow();
});
}
window.visualViewport?.addEventListener('resize', fitViewport); window.visualViewport?.addEventListener('scroll', fitViewport); addEventListener('resize', fitViewport);
// Optional start parameters for review links and recordings: ?theme=fog&mode=talk&scene=long
const params = new URLSearchParams(location.search);
applyTheme(params.get('theme') || 'moon', true);
if (params.get('tier')) Atmos.setTier(+params.get('tier'));
fitViewport(); present('jump'); moveNavGlow();
if (params.get('mode') === 'talk') openTalk();
if (params.get('scene')) setTimeout(() => runScenario(params.get('scene')), 600);
window.__proto = {state, pages, runScenario, setMode, applyTheme, showDays, showManage, showHistory, closeSheet, setFlow, go, atmos: () => Atmos.info()};
+93
View File
@@ -0,0 +1,93 @@
# Minimal Chrome DevTools Protocol driver for headless Edge (Windows PowerShell 5.1).
$ErrorActionPreference = 'Stop'
$script:edge = 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe'
$script:id = 0
$script:events = New-Object System.Collections.ArrayList
function Start-Browser([int]$port = 9333) {
$profile = Join-Path $env:TEMP "edge-hel793-$port"
$script:proc = Start-Process $script:edge -ArgumentList "--headless=new", "--remote-debugging-port=$port", "--user-data-dir=$profile", "--no-first-run", "--hide-scrollbars", "--force-color-profile=srgb", "--autoplay-policy=no-user-gesture-required", "about:blank" -PassThru
for ($i = 0; $i -lt 50; $i++) {
try { $targets = Invoke-RestMethod "http://127.0.0.1:$port/json" -TimeoutSec 2; break } catch { Start-Sleep -Milliseconds 200 }
}
$page = $targets | Where-Object { $_.type -eq 'page' } | Select-Object -First 1
$script:ws = New-Object System.Net.WebSockets.ClientWebSocket
$script:ws.Options.KeepAliveInterval = [TimeSpan]::FromSeconds(30)
$script:ws.ConnectAsync([Uri]$page.webSocketDebuggerUrl, [Threading.CancellationToken]::None).Wait()
$script:proc.Id
}
function Stop-Browser { try { $script:ws.Dispose() } catch {}; if ($script:proc -and -not $script:proc.HasExited) { Stop-Process -Id $script:proc.Id -Force } }
function Receive-Message {
$buffer = New-Object byte[] 1048576
$ms = New-Object System.IO.MemoryStream
do {
$seg = New-Object System.ArraySegment[byte] -ArgumentList @(, $buffer)
$r = $script:ws.ReceiveAsync($seg, [Threading.CancellationToken]::None).GetAwaiter().GetResult()
$ms.Write($buffer, 0, $r.Count)
} while (-not $r.EndOfMessage)
[Text.Encoding]::UTF8.GetString($ms.ToArray()) | ConvertFrom-Json
}
function Send-Cdp([string]$method, $params = @{}) {
$script:id++
$myId = $script:id
$json = @{ id = $myId; method = $method; params = $params } | ConvertTo-Json -Depth 10 -Compress
$bytes = [Text.Encoding]::UTF8.GetBytes($json)
$seg = New-Object System.ArraySegment[byte] -ArgumentList @(, $bytes)
$script:ws.SendAsync($seg, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, [Threading.CancellationToken]::None).Wait()
while ($true) {
$m = Receive-Message
if ($m.id -eq $myId) { if ($m.error) { throw "$method failed: $($m.error.message)" }; return $m.result }
if ($m.method) { Handle-Event $m }
}
}
function Handle-Event($m) {
if ($m.method -eq 'Page.screencastFrame') {
if ($script:recording) { [void]$script:frames.Add(@{ data = $m.params.data; t = $m.params.metadata.timestamp }) }
Send-Ack $m.params.sessionId
} else { [void]$script:events.Add($m.method) }
}
function Send-Ack($sid) {
$script:id++
$json = @{ id = $script:id; method = 'Page.screencastFrameAck'; params = @{ sessionId = $sid } } | ConvertTo-Json -Compress
$bytes = [Text.Encoding]::UTF8.GetBytes($json)
$seg = New-Object System.ArraySegment[byte] -ArgumentList @(, $bytes)
$script:ws.SendAsync($seg, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, [Threading.CancellationToken]::None).Wait()
}
function Invoke-Js([string]$expr, [switch]$await) {
$r = Send-Cdp 'Runtime.evaluate' @{ expression = $expr; returnByValue = $true; awaitPromise = [bool]$await }
if ($r.exceptionDetails) { throw "JS error: $($r.exceptionDetails.exception.description)" }
$r.result.value
}
function Set-View([int]$w, [int]$h, [switch]$mobile, [double]$dpr = 1) {
[void](Send-Cdp 'Emulation.setDeviceMetricsOverride' @{ width = $w; height = $h; deviceScaleFactor = $dpr; mobile = [bool]$mobile })
if ($mobile) { [void](Send-Cdp 'Emulation.setTouchEmulationEnabled' @{ enabled = $true; maxTouchPoints = 5 }) }
else { [void](Send-Cdp 'Emulation.setTouchEmulationEnabled' @{ enabled = $false }) }
}
function Open-Page([string]$url) {
[void](Send-Cdp 'Page.enable')
[void](Send-Cdp 'Page.navigate' @{ url = $url })
Wait-Ms 1500
}
# Keep pumping events (screencast frames) while waiting.
function Wait-Ms([int]$ms) {
$until = [DateTime]::Now.AddMilliseconds($ms)
while ([DateTime]::Now -lt $until) {
[void](Send-Cdp 'Runtime.evaluate' @{ expression = '0'; returnByValue = $true })
Start-Sleep -Milliseconds 40
}
}
function Save-Shot([string]$path) {
$r = Send-Cdp 'Page.captureScreenshot' @{ format = 'png' }
[IO.File]::WriteAllBytes($path, [Convert]::FromBase64String($r.data))
}
function Start-Rec([int]$maxW = 1440, [int]$maxH = 900) {
$script:frames = New-Object System.Collections.ArrayList
$script:recording = $true
[void](Send-Cdp 'Page.startScreencast' @{ format = 'jpeg'; quality = 82; maxWidth = $maxW; maxHeight = $maxH; everyNthFrame = 1 })
}
function Stop-Rec {
[void](Send-Cdp 'Page.stopScreencast')
$script:recording = $false
$script:frames
}
+36
View File
@@ -0,0 +1,36 @@
param([string]$video, [string]$png, [int]$cols = 4, [int]$count = 12, [int]$thumbW = 480)
# Decodes the finished WebM in a browser and tiles evenly spaced frames, proving the file plays and showing motion over time.
. "$PSScriptRoot\cdp.ps1"
$dir = Split-Path $video
$page = Join-Path $dir 'contact.html'
Set-Content -Path $page -Encoding utf8 -Value '<!doctype html><body style="margin:0;background:#000"><video id=v muted playsinline></video><canvas id=c></canvas></body>'
$script:proc = Start-Process $script:edge -ArgumentList '--headless=new', '--remote-debugging-port=9337', "--user-data-dir=$env:TEMP\edge-hel793-contact", '--allow-file-access-from-files', '--autoplay-policy=no-user-gesture-required', 'about:blank' -PassThru
for ($i = 0; $i -lt 50; $i++) { try { $targets = Invoke-RestMethod 'http://127.0.0.1:9337/json' -TimeoutSec 2; break } catch { Start-Sleep -Milliseconds 200 } }
$t = $targets | Where-Object { $_.type -eq 'page' } | Select-Object -First 1
$script:ws = New-Object System.Net.WebSockets.ClientWebSocket
$script:ws.ConnectAsync([Uri]$t.webSocketDebuggerUrl, [Threading.CancellationToken]::None).Wait()
try {
Open-Page ('file:///' + ($page -replace '\\', '/'))
$src = 'file:///' + ((Resolve-Path $video).Path -replace '\\', '/')
$js = @"
(async () => {
const v = document.getElementById('v'); v.src = '$src';
await new Promise((ok, no) => { v.onloadeddata = ok; v.onerror = () => no(new Error('decode failed')); });
// WebM from MediaRecorder has no duration header; seek to the end once to learn it.
if (!isFinite(v.duration)) { v.currentTime = 1e6; await new Promise(ok => v.ondurationchange = ok); await new Promise(ok => { v.onseeked = ok; v.currentTime = 0; }); }
const n = $count, cols = $cols, tw = $thumbW, th = Math.round(tw * v.videoHeight / v.videoWidth), rows = Math.ceil(n / cols);
const c = document.getElementById('c'); c.width = cols * tw; c.height = rows * (th + 22); const x = c.getContext('2d');
x.fillStyle = '#000'; x.fillRect(0, 0, c.width, c.height); x.font = '14px sans-serif';
for (let i = 0; i < n; i++) {
const t = v.duration * (i + .5) / n;
await new Promise(ok => { v.onseeked = ok; v.currentTime = t; });
const cx = (i % cols) * tw, cy = Math.floor(i / cols) * (th + 22);
x.drawImage(v, cx, cy + 22, tw, th); x.fillStyle = '#fff'; x.fillText(t.toFixed(1) + 's', cx + 6, cy + 16);
}
return JSON.stringify({duration: v.duration, w: v.videoWidth, h: v.videoHeight, png: c.toDataURL('image/png').split(',')[1]});
})()
"@
$r = Invoke-Js $js -await | ConvertFrom-Json
[IO.File]::WriteAllBytes($png, [Convert]::FromBase64String($r.png))
"duration=$([math]::Round($r.duration,1))s size=$($r.w)x$($r.h)"
} finally { Stop-Browser; Remove-Item $page -ErrorAction SilentlyContinue }
+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
+87
View File
@@ -0,0 +1,87 @@
param([string]$name, [int]$w = 1440, [int]$h = 900, [switch]$mobile, [string]$query = '', [string]$steps, [string]$out = "$PWD\rec")
# Records a scripted walkthrough as WebM. $steps: lines of "wait <ms>" | "tap <css>" | "js <expr>" | "type <css>|<text>" | "shot <file>".
. "$PSScriptRoot\cdp.ps1"
New-Item -ItemType Directory -Force $out | Out-Null
$frameDir = Join-Path $out "frames-$name"
if (Test-Path $frameDir) { Remove-Item $frameDir -Recurse -Force }
New-Item -ItemType Directory -Force $frameDir | Out-Null
$url = 'file:///' + ((Resolve-Path "$PSScriptRoot\..\proto\prototypes\hel-793\index.html").Path -replace '\\', '/') + $query
# Recording-only touch marker so viewers can see where a tap happened. Not part of the prototype.
$tapJs = @'
window.__tap = sel => { const el = document.querySelector(sel); if (!el) throw new Error('missing ' + sel);
const r = el.getBoundingClientRect(), d = document.createElement('div');
d.style.cssText = `position:fixed;z-index:99999;left:${r.left + r.width / 2 - 18}px;top:${r.top + r.height / 2 - 18}px;width:36px;height:36px;border-radius:50%;border:2px solid #fff;background:#ffffff40;pointer-events:none;transition:opacity .5s,transform .5s`;
document.body.append(d); requestAnimationFrame(() => { d.style.transform = 'scale(1.6)'; d.style.opacity = '0'; }); setTimeout(() => d.remove(), 700); el.click(); return 1; };
'@
$null = Start-Browser 9335
try {
Set-View $w $h -mobile:$mobile -dpr 1
Open-Page $url
Wait-Ms 1200
[void](Invoke-Js $tapJs)
Start-Rec 1280 900
foreach ($line in ($steps -split "`n")) {
$line = $line.Trim(); if (-not $line) { continue }
$cmd, $arg = $line -split ' ', 2
switch ($cmd) {
'wait' { Wait-Ms ([int]$arg) }
'tap' { [void](Invoke-Js "__tap('$arg')"); Wait-Ms 60 }
'js' { [void](Invoke-Js $arg) }
'type' { $sel, $text = $arg -split '\|', 2; [void](Invoke-Js "(()=>{const e=document.querySelector('$sel');e.focus();e.value='$text';e.dispatchEvent(new Event('input'));return 1})()") }
'shot' { Save-Shot (Join-Path $out $arg) }
'log' { Write-Output ("{0}: {1}" -f $arg, (Invoke-Js $arg)) }
}
}
$frames = Stop-Rec
} finally { Stop-Browser }
# Write frames + timing manifest.
$t0 = $frames[0].t; $list = @()
for ($i = 0; $i -lt $frames.Count; $i++) {
$f = $frames[$i]; $file = 'f{0:D5}.jpg' -f $i
[IO.File]::WriteAllBytes((Join-Path $frameDir $file), [Convert]::FromBase64String($f.data))
$list += @{ f = $file; t = [math]::Round(($f.t - $t0) * 1000) }
}
$dur = $list[-1].t
"frames=$($frames.Count) duration=${dur}ms fps=$([math]::Round($frames.Count / ($dur / 1000), 1))"
$manifest = ($list | ConvertTo-Json -Compress)
# Encode in a second headless browser: draw frames on a canvas at their real timing and capture with MediaRecorder.
$encoder = Join-Path $frameDir 'encode.html'
Set-Content -Path $encoder -Encoding utf8 -Value '<!doctype html><canvas id=c></canvas>'
$script:edgeArgs = $null
$profile = Join-Path $env:TEMP 'edge-hel793-enc'
$script:proc = Start-Process $script:edge -ArgumentList '--headless=new', '--remote-debugging-port=9336', "--user-data-dir=$profile", '--allow-file-access-from-files', '--autoplay-policy=no-user-gesture-required', 'about:blank' -PassThru
for ($i = 0; $i -lt 50; $i++) { try { $targets = Invoke-RestMethod 'http://127.0.0.1:9336/json' -TimeoutSec 2; break } catch { Start-Sleep -Milliseconds 200 } }
$page = $targets | Where-Object { $_.type -eq 'page' } | Select-Object -First 1
$script:ws = New-Object System.Net.WebSockets.ClientWebSocket
$script:ws.ConnectAsync([Uri]$page.webSocketDebuggerUrl, [Threading.CancellationToken]::None).Wait()
try {
Open-Page ('file:///' + ($encoder -replace '\\', '/'))
$js = @"
(async () => {
const list = $manifest, base = location.href.replace(/encode\.html.*$/, '');
const load = f => new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = no; i.src = base + f; });
const first = await load(list[0].f), c = document.getElementById('c');
c.width = first.naturalWidth; c.height = first.naturalHeight; const x = c.getContext('2d');
x.drawImage(first, 0, 0);
const stream = c.captureStream(30), chunks = [];
const rec = new MediaRecorder(stream, {mimeType: 'video/webm;codecs=vp9', videoBitsPerSecond: 3000000});
rec.ondataavailable = e => e.data.size && chunks.push(e.data);
const done = new Promise(ok => rec.onstop = ok);
rec.start(1000);
const start = performance.now(); let next = await load(list[1] ? list[1].f : list[0].f);
for (let i = 1; i < list.length; i++) {
const img = next; const pre = list[i + 1] ? load(list[i + 1].f) : null;
const wait = list[i].t - (performance.now() - start); if (wait > 0) await new Promise(r => setTimeout(r, wait));
x.drawImage(img, 0, 0); if (pre) next = await pre;
}
await new Promise(r => setTimeout(r, 400)); rec.stop(); await done;
const blob = new Blob(chunks, {type: 'video/webm'});
return await new Promise(ok => { const fr = new FileReader(); fr.onload = () => ok(fr.result.split(',')[1]); fr.readAsDataURL(blob); });
})()
"@
$b64 = Invoke-Js $js -await
[IO.File]::WriteAllBytes((Join-Path $out "$name.webm"), [Convert]::FromBase64String($b64))
"wrote $name.webm ($([math]::Round((Get-Item (Join-Path $out "$name.webm")).Length / 1MB, 2)) MB)"
} finally { Stop-Browser }
+30
View File
@@ -0,0 +1,30 @@
param([string]$out = "$PWD\shots", [string]$only = '')
. "$PSScriptRoot\cdp.ps1"
New-Item -ItemType Directory -Force $out | Out-Null
$url = 'file:///' + ((Resolve-Path "$PSScriptRoot\..\proto\prototypes\hel-793\index.html").Path -replace '\\', '/')
$null = Start-Browser 9334
try {
$plans = @(
@{ n = 'desktop'; w = 1440; h = 900; m = $false },
@{ n = 'mobile'; w = 390; h = 844; m = $true }
)
foreach ($p in $plans) {
if ($only -and $p.n -ne $only) { continue }
Set-View $p.w $p.h -mobile:$p.m -dpr ($(if ($p.m) { 2 } else { 1 }))
foreach ($theme in 'moon', 'fog') {
Open-Page "${url}?theme=$theme"
Wait-Ms 3000
Save-Shot "$out\$($p.n)-home-$theme.png"
[void](Invoke-Js "__proto.runScenario('long')")
Wait-Ms 9000
Save-Shot "$out\$($p.n)-talk-$theme.png"
[void](Invoke-Js "__proto.showDays()")
Wait-Ms 1500
Save-Shot "$out\$($p.n)-days-$theme.png"
[void](Invoke-Js "__proto.showHistory()")
Wait-Ms 1500
Save-Shot "$out\$($p.n)-history-$theme.png"
}
}
Invoke-Js 'JSON.stringify(__proto.atmos())'
} finally { Stop-Browser }
+107
View File
@@ -0,0 +1,107 @@
param([string]$out = "$PWD\verify")
# Behavioural checks in a real browser. Each check records pass/fail with observed values.
. "$PSScriptRoot\cdp.ps1"
New-Item -ItemType Directory -Force $out | Out-Null
$url = 'file:///' + ((Resolve-Path "$PSScriptRoot\..\proto\prototypes\hel-793\index.html").Path -replace '\\', '/')
$results = New-Object System.Collections.ArrayList
function Check([string]$name, [bool]$ok, $detail) { [void]$results.Add([ordered]@{ check = $name; pass = $ok; detail = $detail }); "{0} {1} {2}" -f ($(if ($ok) { 'PASS' } else { 'FAIL' })), $name, ($detail | ConvertTo-Json -Compress) }
function J($e) { Invoke-Js $e | ConvertFrom-Json }
$null = Start-Browser 9338
try {
Set-View 1440 900
Open-Page "${url}?theme=moon"
Wait-Ms 1500
# 1. Long reply flows by itself to the end, no clicks.
[void](Invoke-Js "__proto.runScenario('long')")
$seen = @()
for ($i = 0; $i -lt 60; $i++) { Wait-Ms 1000; $s = J 'JSON.stringify({c:__proto.state.cursor,n:__proto.pages().length,busy:__proto.state.busy})'; $seen += "$($s.c + 1)/$($s.n)"; if (-not $s.busy -and $s.c -eq $s.n - 1) { break } }
$s = J 'JSON.stringify({c:__proto.state.cursor,n:__proto.pages().length,busy:__proto.state.busy,flow:__proto.state.flow})'
Check 'auto-continues-long-reply-to-last-line' ($s.c -eq $s.n - 1 -and -not $s.busy -and $s.flow) @{ seconds = $i + 1; final = $s; trail = ($seen -join ' ') }
# Full text completeness: concatenated pages equal the message.
$c = J 'JSON.stringify((()=>{const p=__proto.pages();const m=p[p.length-1].message;return {same:p.filter(x=>x.message===m).map(x=>x.text).join("")===m.text.replace(/\n/g,""),lines:p.length}})())'
Check 'all-text-preserved-in-lines' $c.same $c
Save-Shot "$out\desktop-long-end.png"
# 2. Pause stops advancing; back shows the return chip; return resumes.
[void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 4500
[void](Invoke-Js "document.querySelector('#toggle-flow').click()"); $a = J '__proto.state.cursor'; Wait-Ms 6000; $b = J '__proto.state.cursor'
Check 'pause-holds-position' ($a -eq $b) @{ before = $a; after6s = $b }
[void](Invoke-Js "document.querySelector('#previous').click()"); Wait-Ms 800
$l = J 'JSON.stringify({hidden:document.querySelector("#latest").hidden,text:document.querySelector("#latest").textContent,c:__proto.state.cursor,head:__proto.state.head})'
Check 'review-back-offers-return' (-not $l.hidden) $l
Save-Shot "$out\desktop-review.png"
[void](Invoke-Js "document.querySelector('#latest').click()"); Wait-Ms 9000
$r = J 'JSON.stringify({c:__proto.state.cursor,flow:__proto.state.flow})'
Check 'return-resumes-flow' ($r.flow -and $r.c -gt $l.head) @{ head = $l.head; now = $r }
# 3. Sending while paused resumes auto presentation.
[void](Invoke-Js "__proto.setFlow(false,true)")
[void](Invoke-Js "(()=>{const d=document.querySelector('#draft');d.value='你在做什么?';document.querySelector('#composer').requestSubmit();return 1})()"); Wait-Ms 6000
$r = J 'JSON.stringify({c:__proto.state.cursor,n:__proto.pages().length,flow:__proto.state.flow,toast:document.querySelector("#toast").textContent})'
Check 'send-resumes-auto' ($r.flow -and $r.c -ge 1) $r
# 4. Identity labels on every visible line.
$who = J 'JSON.stringify([...document.querySelectorAll("#stage .line:not(.leaving)")].map(l=>({role:l.dataset.role,label:l.querySelector(".who b").textContent})))'
Check 'identity-labelled' (@($who | Where-Object { ($_.role -eq 'user' -and $_.label -eq '你') -or ($_.role -eq 'her' -and $_.label -eq '晚晚') }).Count -eq @($who).Count) $who
# 5. Cancel keeps partial text marked incomplete.
[void](Invoke-Js "__proto.runScenario('waiting')"); Wait-Ms 2500; [void](Invoke-Js "document.querySelector('#stop').click()"); Wait-Ms 600
$x = J 'JSON.stringify((()=>{const m=__proto.state&&window.__proto.pages().map(p=>p.message).pop();return {status:m.status,len:m.text.length,busy:__proto.state.busy}})())'
Check 'cancel-marks-incomplete' ($x.status -eq 'cancelled' -and -not $x.busy) $x
# 6. History open/close keeps the reading position.
[void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 7000; $a = J '__proto.state.cursor'
[void](Invoke-Js "document.querySelector('#history').click()"); Wait-Ms 4000; $b = J '__proto.state.cursor'
$mode = J 'JSON.stringify(document.body.dataset.mode)'
[void](Invoke-Js "document.querySelector('#close-sheet').click()"); Wait-Ms 1200
$c = J 'JSON.stringify({c:__proto.state.cursor,mode:document.body.dataset.mode})'
Check 'history-holds-then-returns' ($a -eq $b -and $c.mode -eq 'talk' -and $mode -eq 'record') @{ before = $a; whileOpen = $b; after = $c }
# 7. Space depth changes per mode.
$z = J 'JSON.stringify(__proto.atmos())'
Check 'camera-depth-talk' ([math]::Abs($z.z - 1) -lt .05) $z
# 8. Long name & phone keyboard viewport.
Set-View 390 844 -mobile -dpr 2
Open-Page "${url}?theme=fog"; Wait-Ms 1200
[void](Invoke-Js "__proto.state.createdNew=false")
[void](Invoke-Js "document.querySelector('#rehearse').click()"); Wait-Ms 600
[void](Invoke-Js "document.querySelector('[data-action=create]').click()"); Wait-Ms 600
[void](Invoke-Js "(()=>{document.querySelector('#new-name').value='星河与晚风之间慢慢醒来的小月亮';document.querySelector('#new-relation').value='朋友';document.querySelector('#create-form').requestSubmit();return 1})()")
Wait-Ms 2500
Save-Shot "$out\mobile-long-name.png"
$ov = J 'JSON.stringify((()=>{const b=document.querySelector("#stage .is-current .who b");return {name:b.textContent,overflow:b.scrollWidth>b.clientWidth,fits:b.getBoundingClientRect().right<=innerWidth}})())'
Check 'long-name-contained' $ov.fits $ov
# Simulated keyboard: shrink the visual viewport to 390x430 with the composer focused.
[void](Invoke-Js "document.querySelector('#draft').focus()")
Set-View 390 430 -mobile -dpr 2; Wait-Ms 1500
[void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 6000
[void](Invoke-Js "document.querySelector('#draft').focus()"); Wait-Ms 800
Save-Shot "$out\mobile-keyboard-430.png"
$k = J 'JSON.stringify((()=>{const r=e=>e.getBoundingClientRect();const cur=document.querySelector("#stage .is-current");const comp=r(document.querySelector("#composer"));const p=r(document.querySelector("#portal"));const c=cur&&r(cur);return {compact:document.body.classList.contains("compact"),composerBottom:comp.bottom,vh:innerHeight,lineTop:c&&c.top,lineBottom:c&&c.bottom,portalBottom:p.bottom,composerTop:comp.top}})())'
Check 'keyboard-viewport-no-overlap' ($k.compact -and $k.composerBottom -le $k.vh -and $k.portalBottom -le $k.lineTop -and $k.lineBottom -le $k.composerTop) $k
# 9. Small phone 320x568.
Set-View 320 568 -mobile -dpr 2; Wait-Ms 1500
Save-Shot "$out\mobile-320.png"
$k = J 'JSON.stringify((()=>{const r=e=>e.getBoundingClientRect();const cur=document.querySelector("#stage .is-current");const comp=r(document.querySelector("#composer"));const nav=r(document.querySelector(".navigation"));const c=r(cur);return {lineBottom:c.bottom,composerTop:comp.top,composerBottom:comp.bottom,navTop:nav.top,w:innerWidth,lineRight:c.right}})())'
Check 'small-phone-layout' ($k.lineBottom -le $k.composerTop -and $k.composerBottom -le $k.navTop -and $k.lineRight -le $k.w) $k
# 10. Reduced motion: emulate and confirm camera snaps / no CSS animations.
[void](Send-Cdp 'Emulation.setEmulatedMedia' @{ features = @(@{ name = 'prefers-reduced-motion'; value = 'reduce' }) })
Set-View 1440 900; Open-Page "${url}?theme=moon"; Wait-Ms 1000
[void](Invoke-Js "document.querySelector('#start-talk').click()"); Wait-Ms 300
$rm = J 'JSON.stringify({z:__proto.atmos().z,anims:document.getAnimations().filter(a=>a.playState==="running"&&!["opacity","color","background-color","box-shadow"].includes(a.transitionProperty)).length})'
Check 'reduced-motion-respected' ($rm.z -eq 1 -and $rm.anims -eq 0) $rm
# 11. Tier fallback works (forced).
[void](Send-Cdp 'Emulation.setEmulatedMedia' @{ features = @() })
Open-Page "${url}?theme=fog&tier=2"; Wait-Ms 1500
$t = J 'JSON.stringify(__proto.atmos())'
Check 'low-tier-fallback' ($t.tier -eq 2 -and $t.scale -eq .5) $t
Save-Shot "$out\desktop-tier2-fog.png"
# 12. Phone, normal height: while a long reply flows, lines never overlap her window or the composer.
foreach ($size in @(@(390, 844), @(360, 740), @(1440, 900), @(1280, 720))) {
$mob = $size[0] -lt 720
Set-View $size[0] $size[1] -mobile:$mob -dpr 1; Open-Page "${url}?theme=moon"; Wait-Ms 1000
[void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 1500
$worst = 999
for ($i = 0; $i -lt 24; $i++) {
Wait-Ms 1500
$g = J 'JSON.stringify((()=>{const ls=[...document.querySelectorAll("#stage .line:not(.leaving)")];if(!ls.length)return null;const top=Math.min(...ls.map(l=>l.getBoundingClientRect().top));const bottom=Math.max(...ls.map(l=>l.getBoundingClientRect().bottom));const p=document.querySelector("#portal").getBoundingClientRect();const c=document.querySelector("#composer").getBoundingClientRect();return {gapTop:top-p.bottom,gapBottom:c.top-bottom}})())'
if ($g) { $worst = [math]::Min($worst, [math]::Min($g.gapTop, $g.gapBottom)) }
}
Check "no-overlap-while-flowing-$($size[0])x$($size[1])" ($worst -ge 0) @{ minGapPx = [math]::Round($worst, 1) }
}
} finally { Stop-Browser }
$results | ConvertTo-Json -Depth 6 | Set-Content "$out\verification.json" -Encoding utf8