Compare commits

..
Author SHA1 Message Date
总工andmultica-agent 63799f0247 HEL-816: record dynamic sample v2 effect acceptance as v1.10 (VIS-011, CG-004)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-23 17:38:57 +08:00
总管andmultica-agent e04e258cc0 HEL-811: 同步需求入口与全文的 v1.9 版本说明
Co-authored-by: multica-agent <github@multica.ai>
2026-10-23 15:53:38 +08:00
白栖知andmultica-agent ad6a3b19ae HEL-811: 保全 HEL-799 v2 原稿并升版 v1.9(交流静图认可入正式来源)
- prototypes/hel-799/ 逐字节保全(23 文件+PROVENANCE 哈希清单;独立重建与交付 HTML 逐字节一致)
- sources 新增 comment-evidence-shared-space-20261023.json 与 shared-space-static-acceptance-20261023.md(原话经平台逐条核验)
- baseline/current/changes/coverage 升版 v1.9:VIS-011 边界注明交流静图认可,严格限定范围
- 新增 docs/requirements/handoff-HEL-811.json(总工机械校验版;原作者 handoff 原样保留)
- check_requirements --base-ref origin/main 与 --handoff 通过;git diff --check 干净

Co-authored-by: multica-agent <github@multica.ai>
2026-10-23 15:47:04 +08:00
41 changed files with 3911 additions and 22 deletions
-2
View File
@@ -11,8 +11,6 @@
# 二进制
*.wav binary
*.mp3 binary
*.mp4 binary
*.webp binary
*.png binary
*.jpg binary
*.onnx binary
+25 -1
View File
@@ -10,7 +10,31 @@
## 先看一眼
<video src="docs/promo.mp4" controls width="100%"></video>
![晚晚 · 介绍动画](docs/preview.webp)
[**▶ 打开完整动画**](docs/intro.html) — 22 秒,纯代码生成,无任何外部资源
```
docs/intro.html 单文件动画,双击即播
docs/preview.webp 渲染好的预览(800×450 / 10fps / 22s / 0.5MB)
tools/render_preview.py 重新渲染、换分辨率、导出静帧
```
动画里有一条贯穿的主线:**强调色 = 当前效价 `v`**。
情绪低落时画面整体转冷,而不是靠字幕告诉你"她现在很难过"。
| 幕 | 时间 | 讲什么 |
|---|---|---|
| 膜 | 0–6s | 物质撞在膜上弹回,信息穿过去 |
| 心跳 | 6–12s | 没有输入时它也在跳;难过恢复得慢 |
| 记忆 | 12–17s | 记得的会沉下去,但沉不到底 |
| 名字 | 17–22s | — |
四张静帧:`docs/01-membrane.png` `02-heartbeat.png` `03-memory.png` `04-title.png`
动画本身的技术取舍:时间轴是纯函数 `render(t)`,不做 RAF 累加。
所以任意一帧都能独立渲染——这也是 `render_preview.py` 能逐帧截图、
而不需要真实录屏的原因。
---
BIN
View File
Binary file not shown.
+7 -1
View File
@@ -1,6 +1,6 @@
# CyberGril 需求入口
当前完整基准:**v1.8**。批准需求继承办法的主卡评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
当前完整基准:**v1.10**。批准需求继承办法的主卡评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
所有新任务、续办、设计、施工、审核先读完整当前需求,再读本次相关来源。只有白栖知明确修改的要求才可变更,阶段范围不能覆盖产品要求。
@@ -33,4 +33,10 @@
v1.8新增来源见 `sources/comment-evidence-visual-role-20261022.json`,最新分工见GOV-005。
v1.9新增来源见 `sources/comment-evidence-shared-space-20261023.json` 与 `sources/shared-space-static-acceptance-20261023.md`;仅更新VIS-011交流静图认可边界,相处、过渡、雾光、手机与真实人物动态仍待验收。
v1.10新增来源见 `sources/comment-evidence-dynamic-sample-20261023.json` 与 `sources/dynamic-sample-effect-acceptance-20261023.md`;对坐动态小样 v2 整体效果(演示层面)记入 VIS-011 与 CG-004 边界,手机真机、候选容器启用、真实人物动态与完整功能仍待验收。
main 当前仍为 v1.8(提交 `3a986726d8593e0ed2274f79db6cc5d807d2ebf0`);v1.9 与 v1.10 在待合并分支线(`agent/agent/54df81e610fd` → `agent/hel-811-handoff-f59f74dc4256` → 后续分支)上递进,合并入 main 由总管与白栖知决定。
后续以该入口所记录的最新版本为准,不把v1.7永久钉死为永不可新增的文档。每次新版本必须记录用户明确裁定,保留旧版来源与覆盖关系。
+4 -4
View File
@@ -1,7 +1,7 @@
{
"schema_version": 1,
"version": "1.8",
"approval_comment_id": "01a163a5-e2a7-7ac8-afdb-991868e721bc",
"version": "1.10",
"approval_comment_id": "01a16810-3b46-7345-8966-dd87b76efe07",
"owner_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"scope": "完整要求;v1.8按用户明确指令新增视觉总监接手及必要代码职责,原58项和已批准边界继续有效,不证明功能已实现或已验收。",
"requirements": [
@@ -48,7 +48,7 @@
"category": "产品",
"topic": "交流镜头",
"conclusion": "日常可看她在环境中生活;交谈时允许自然切近到同一人物、同一房间、同一套衣服的半身或脸部。",
"boundary": "具体切镜方式和衔接效果仍要通过小样确认,不能据此认定用户接受突兀跳切。",
"boundary": "具体切镜方式和衔接效果仍要通过小样确认,不能据此认定用户接受突兀跳切。对坐动态小样 v2 的推近/退回过渡已按评论 01a16810-3b46-7345-8966-dd87b76efe07 获认可(演示层面,人物为参考静帧);真实视频的切近衔接仍待验收。",
"source_comment_ids": [
"01a15724-dcaa-7d2b-ae23-430f24ca36b9",
"01a15726-e178-72b0-9a3c-8b2041538730"
@@ -501,7 +501,7 @@
"category": "视觉",
"topic": "人物独立视窗",
"conclusion": "人物平时作为空间中独立视窗;交流时自然放大。已认可的人物位置、分量与首页文字分量沿用。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。交流状态静图已按评论 01a1668f-5810-7524-b72f-33574a32c7d6 认可为 HEL-798 共同空间构图;对坐动态小样 v2 的整体效果已按评论 01a16810-3b46-7345-8966-dd87b76efe07 认可(同室相处、推近/退回过渡、交流呈现与两主题,演示层面);手机候选容器默认关闭未获批启用,真实人物动态、语音口型、真实生活与完整功能仍待验收。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
+21
View File
@@ -77,6 +77,27 @@
"GOV-005"
],
"impact": "本项目本轮视觉设计、动效与相应代码责任转给视觉总监;不改现网、数据、全局模型或认证。"
},
{
"version": "1.9",
"approval_comment_id": "01a1668f-5810-7524-b72f-33574a32c7d6",
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"summary": "交流状态静态视觉基准更新:白栖知认可 HEL-798 共同空间交流静图(1600×1000,SHA256 a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a),在 VIS-011 边界注明。",
"affected_ids": [
"VIS-011"
],
"impact": "仅交流状态静图获认可:不用独立人物框,环境铺满四边,前景为用户一侧桌沿/茶杯/烛灯,对话在视线下方。相处状态、过渡与动态、雾光、手机、真实人物动态与完整功能仍待验收;不永久取消 VIS-011 在其他状态的原规则,不把完整前端锁定为全屏照片或视频(CG-031 继续有效)。来源:sources/comment-evidence-shared-space-20261023.json 与 sources/shared-space-static-acceptance-20261023.md(原话经总工 HEL-811 对照平台逐条核验)。"
},
{
"version": "1.10",
"approval_comment_id": "01a16810-3b46-7345-8966-dd87b76efe07",
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"summary": "对坐动态小样 v2 整体效果获认可:白栖知体验离线 HTML(SHA256 826bfc045d941c2114b16a198626ca952ff3d95dff02cfc6e32ee09858bcf80e)后回复“这个效果可以”;在 VIS-011 与 CG-004 边界注明演示层面的相处、推近/退回过渡、交流呈现与两主题认可。",
"affected_ids": [
"VIS-011",
"CG-004"
],
"impact": "仅已交对坐动态小样的效果(电脑端、演示数据)。不视为完整产品、其余页面或真实功能验收;手机候选默认关闭未获批启用;相处静帧中的书本不恢复已否定的默认阅读(01a166aa);真实人物动态、语音口型、真实生活与完整功能仍待验收;不授权替换正式界面或合并 PR。来源:sources/comment-evidence-dynamic-sample-20261023.json 与 sources/dynamic-sample-effect-acceptance-20261023.md。"
}
]
}
+20
View File
@@ -11,3 +11,23 @@ v1.7:把原始31项、已确认视觉和后续用户裁定整理到同一入
白栖知明确裁定:整体视觉交给视觉总监接手,而不是把关,甚至若施工员无法写出相应代码,直接让视觉总监写代码
来源评论:`01a163a5-e2a7-7ac8-afdb-991868e721bc`。旧分工为总管因环境失败接手制作、后续视觉总监只把关;现按GOV-005交视觉总监亲自设计与落地,必要时直接写代码。本次不改变产品功能与原验收边界。
## v1.9:交流状态静态视觉基准更新
白栖知看过 HEL-798 共同空间样图后明确认可:**“静态效果我认可”**
来源评论:`01a1668f-5810-7524-b72f-33574a32c7d6`(获认可图 `hel-798-face-to-face-moon.png`,1600×1000,SHA256 `a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a`)。
旧要求(VIS-011):人物平时作为空间中独立视窗;交流时自然放大。新认可:交流状态(靛蓝月光、电脑 1600×1000)的静态画面不再用独立人物框,环境铺满四边,前景为用户一侧桌沿/茶杯/烛灯,对话在近处视线下方;VIS-011 条文保留,边界注明该更新。
影响范围:仅交流状态静图。相处状态、过渡与动态、雾光、手机、真实人物动态与完整功能仍待验收;不永久取消 VIS-011 其他状态原规则,不把完整前端锁定为全屏照片或视频(CG-031 继续有效)。原话与适用范围见 `sources/comment-evidence-shared-space-20261023.json`、`sources/shared-space-static-acceptance-20261023.md`(原话经总工 HEL-811 对照平台逐条核验)。
## v1.10:对坐动态小样整体效果认可
白栖知体验《对坐动态小样 v2》(HEL-799,附件 `01a167c1-455b-751a-ac37-9fa984a1ed10`,SHA256 `826bfc045d941c2114b16a198626ca952ff3d95dff02cfc6e32ee09858bcf80e`)后明确认可:**“这个效果可以”**
来源评论:`01a16810-3b46-7345-8966-dd87b76efe07`(2026-10-23 09:20:56 UTC,回应总管交付评论 `01a167c1-458d-795b-81e1-584089bf3667`)。
旧边界(v1.9):仅交流静图获认可,相处、过渡、雾光、手机与整体动态仍待验收。新认可:该小样的整体效果(同室相处、推近/退回过渡、交流呈现与阅读方式、月光/雾光两主题,电脑端演示数据)在演示层面获认可;VIS-011 与 CG-004 条文保留,边界注明。
影响范围:不视为完整产品、其余页面或真实功能验收;手机候选容器默认关闭未获批启用,真机未验证;相处静帧中的书本不恢复已否定的默认阅读(`01a166aa-5f37-77b2-a2bd-ceb62f665813`);真实人物动态、语音口型、真实生活与完整功能仍待验收;不授权替换正式界面或合并 PR。原话与适用范围见 `sources/comment-evidence-dynamic-sample-20261023.json`、`sources/dynamic-sample-effect-acceptance-20261023.md`(原话经总工 HEL-816 对照平台核验)。
+13 -7
View File
@@ -1,5 +1,5 @@
{
"baseline_version": "1.8",
"baseline_version": "1.10",
"items": [
{
"requirement_id": "CG-001",
@@ -21,9 +21,11 @@
},
{
"requirement_id": "CG-004",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
"status": "小样演示层面的推近/退回切镜已获用户认可(01a16810);真实视频切近衔接仍待验收",
"evidence": [
"docs/requirements/changes.json v1.10(01a16810)"
],
"next_step": "真实视频入场/退出/往返衔接按 CG-030 首批小队与 HEL-803 条件承接,验证后另行验收"
},
{
"requirement_id": "CG-005",
@@ -249,9 +251,13 @@
},
{
"requirement_id": "VIS-011",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
"status": "交流静图(HEL-798月光构图)与对坐动态小样 v2 整体效果(演示层面)已获用户认可;手机真机、候选容器启用、真实人物动态与完整功能仍待验收",
"evidence": [
"docs/requirements/changes.json v1.9(01a1668f)",
"docs/requirements/changes.json v1.10(01a16810)",
"prototypes/hel-799/(HEL-811 独立复核通过;v2 效果经白栖知体验认可)"
],
"next_step": "新素材(多时段天气穿搭、无书近景)按 HEL-802/803 承接;接入正式前端按 HEL-816 方案另行验收"
},
{
"requirement_id": "VIS-012",
+2 -2
View File
@@ -7,7 +7,7 @@
| CG-001 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-002 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-003 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-004 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-004 | 小样演示层面的推近/退回切镜已获用户认可(01a16810);真实视频切近衔接仍待验收 | 真实视频入场/退出/往返衔接按 CG-030 首批小队与 HEL-803 条件承接,验证后另行验收 |
| CG-005 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-006 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-007 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
@@ -45,7 +45,7 @@
| VIS-008 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-009 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-010 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-011 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-011 | 交流静图(HEL-798月光构图)与对坐动态小样 v2 整体效果(演示层面)已获用户认可;手机真机、候选容器启用、真实人物动态与完整功能仍待验收 | 新素材(多时段天气穿搭、无书近景)按 HEL-802/803 承接;接入正式前端按 HEL-816 方案另行验收 |
| VIS-012 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-013 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| AM-001 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
+4 -4
View File
@@ -1,6 +1,6 @@
# CyberGril 完整当前需求 · v1.8
# CyberGril 完整当前需求 · v1.10
完整要求;版本1.8保留原完整要求并新增用户明确的视觉总监接手分工,不证明已实现或已验收。
完整要求;版本1.10在v1.9(交流静图认可)之上,把白栖知已体验的对坐动态小样 v2 整体效果(演示层面)记入 VIS-011 与 CG-004 边界;不证明其余页面、真实功能或完整产品已验收。
## 阅读与效力
@@ -34,7 +34,7 @@
日常可看她在环境中生活;交谈时允许自然切近到同一人物、同一房间、同一套衣服的半身或脸部。
边界:具体切镜方式和衔接效果仍要通过小样确认,不能据此认定用户接受突兀跳切。
边界:具体切镜方式和衔接效果仍要通过小样确认,不能据此认定用户接受突兀跳切。对坐动态小样 v2 的推近/退回过渡已按评论 01a16810-3b46-7345-8966-dd87b76efe07 获认可(演示层面,人物为参考静帧);真实视频的切近衔接仍待验收。
来源评论:`01a15724-dcaa-7d2b-ae23-430f24ca36b9`、`01a15726-e178-72b0-9a3c-8b2041538730`。
@@ -354,7 +354,7 @@
人物平时作为空间中独立视窗;交流时自然放大。已认可的人物位置、分量与首页文字分量沿用。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。交流状态静图已按评论 01a1668f-5810-7524-b72f-33574a32c7d6 认可为 HEL-798 共同空间构图;对坐动态小样 v2 的整体效果已按评论 01a16810-3b46-7345-8966-dd87b76efe07 认可(同室相处、推近/退回过渡、交流呈现与两主题,演示层面);手机候选容器默认关闭未获批启用,真实人物动态、语音口型、真实生活与完整功能仍待验收。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
+44
View File
@@ -0,0 +1,44 @@
{
"task": "HEL-811 总工独立复核 HEL-799 共同空间动态小样 v2,并机械保全原稿入库;本文件为总工交接(原作者 handoff 原样保全于 prototypes/hel-799/handoff-hel-799.json)",
"baseline_version": "1.9",
"read_commit": "3a986726d8593e0ed2274f79db6cc5d807d2ebf0",
"read_notes": [
"v1.8(59项)全文与 sources/ 于提交 3a986726 完整读取;总工本轮以平台原话核验静态认可来源后,按治理规则升版 v1.9(仅 VIS-011 边界注明交流静图认可),来源原话进 sources/ 并写入 source-manifest。",
"与原作者 handoff 的差异仅在 baseline_version(原作者交付时为 1.8,当时静态认可评论尚未进入正式 sources,handoff 机械检查因此失败);内容性字段未改动原作者判断,验收结论以本文件与 HEL-811 审核记录为准。"
],
"requirement_ids": ["CG-002", "CG-003", "CG-004", "CG-008", "CG-017", "CG-018", "CG-023", "CG-024", "CG-025", "CG-026", "CG-028", "CG-029", "CG-030", "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": "(1)HEL-811 审核记录(附件):41 项独立复测、逐字节重建核验、像素级对照、素材与母版核验、用户原话平台核验;(2)分支 agent/agent/54df81e610fd:prototypes/hel-799/ 23 个交付文件逐字节保全+PROVENANCE.md(来源附件、zip 与逐文件 SHA256、独立重建结果);docs/requirements 升版 v1.9(sources 两件新增、source-manifest、baseline/current/changes/coverage 同步);未动 web/public/assets(哈希与仓库一致)、未动 PR#4/#6/#8、未合并、未部署。",
"acceptance": [
"独立构建:以仓库资产用独立实现的构建脚本重建 index.html,662,233 字节,SHA256 826bfc045d941c2114b16a198626ca952ff3d95dff02cfc6e32ee09858bcf80e,与交付 HTML 逐字节一致;zip 内 HTML/上传附件/重建结果三者相同。",
"UI 为真实元素:2 张获批静帧图层层叠 4 坌 canvas 与 24 个按钮、表单、浮层,非整图贴底。",
"行为独立复测 40/41 通过(无头 Chromium 151,Playwright,真实键鼠事件):镜头渐进、无黑帧、照片仅等比缩放平移、中途反向不跳回、Esc/←/→ 14–36ms(<80ms)且有限动画为 0、自动续读到末句、全文与原文一致、暂停/回看/回到进度、发送恢复自动、身份标记、停止保留并标未完成、记录打开她留在桌对面且关闭回原句、入口均打开带演示/尚未接入标记、语音演示不申请麦克风、两主题结构一致、七种视口长回复无遮挡、长名字以省略号收纳、减少动效原位交叉淡化且雨停一帧、档 2 无逐帧遮罩、页面隐藏暂停(body.suspended、CSS 动画 paused、阅读计时冻结并于恢复后续走;合成 visibilitychange 事件,非 OS 级后台节流)。",
"像素级对照:我的运行 vs 作者证据(同状态均值差 9.5,差异集中于雨/热气动画区);我的交流态 vs 获认可静图(均值差 9.9,差异集中于文案与阅读控制区,场景区一致);母版几何核验(16:9 候选底板=16:10 底板等比高度铺满右移 112px,与声明的 ~113px 一致;含前景母版 vs 获认可静图均值差 5.0;无前景底板差异集中在底部前景区)。",
"手机 4:5 候选容器:390×844=390×487.5、320×568=320×328、390×430=390×374 与 HEL-802 v2 容器公式一致;脸中心实测 (170,157)/(143,105)/(176,120) 复现作者数字,与 16:9 公式差 7–11px(源为 16:10 获认可构图,可见宽 50% vs 45%,非等价,待新 CT 素材后重算);键盘态最近回复覆盖容器底部 80–128px(内容长度相关),脸与下巴不被遮;默认关闭、桌面不受影响、界面未把候选升格为用户选择。",
"用户原话核验:records 五条与 01a166aa/01a16699/01a1669d/01a166ad 均经 CLI 对照平台逐条一致(作者、时间、原话、父评论)。"
],
"deferred": [
"Firefox 未测:本运行时无法启动(缺 GTK3 链库且系统 NSS 最高 3.98 < 所需 3.101,无 root 无法补装);Safari/WebKit 无浏览器。真机软键盘、真实 GPU 帧率、低端手机未测;帧率参考值来自无头软件光栅,不代表真机。",
"候选容器底部露出旧书与居家裤(获认可构图 y≥0.80 原被页面桌沿遮挡)与推近途中远景静帧的书/腿:属明确素材依赖(按 HEL-802 v2 生成新 CT),非界面遮盖问题;320 偏挤为已预判的已知密度;真实软键盘未知。",
"相处、过渡、雾光、手机与整体动态均未经白栖知验收;真实视频入场/退出、循环三圈、同脸同衣同光、LIVE 接管与端到端延迟未验证(CG-030 首批小队承接)。本审核通过不等于用户验收或项目完成。",
"总工无法人眼审图(运行时模型不支持图片输入):视觉判断基于像素统计、几何复算与代码核对,截图证据供总管与用户人眼复核。"
],
"requirement_changes": [
{
"approval_comment_id": "01a1668f-5810-7524-b72f-33574a32c7d6",
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"quote": "静态效果我认可",
"kind": "交流静态视觉基准的更新",
"affected_ids": ["VIS-011"],
"old": "VIS-011:人物平时作为空间中独立视窗;交流时自然放大。",
"new": "交流状态(靛蓝月光、电脑 1600×1000)的获认可静态画面为 HEL-798 共同空间构图:无独立人物框,环境连续到四边,前景桌沿/茶杯/烛灯,中间是她,远处雨夜窗景,对话在近处视线下方。",
"scope": "仅交流状态静图。相处状态、过渡与动态、雾光、手机、真实人物动态和完整功能仍待验收;不永久取消 VIS-011 在其他状态中的原规则(CG-031 继续有效)。",
"evidence_file": "docs/requirements/sources/comment-evidence-shared-space-20261023.json",
"image_sha256": "a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a"
}
],
"verification": [
"python3 scripts/check_requirements.py --base-ref origin/main:通过(v1.9 升版、来源追加、历史保留)。",
"python3 scripts/check_requirements.py --base-ref origin/main --handoff docs/requirements/handoff-HEL-811.json:通过。原作者 handoff(baseline 1.8)按预期报『交接使用过期基准』,原件未改动,差异见 read_notes。",
"git diff --check:通过。逐字节重建与哈希清单见 prototypes/hel-799/PROVENANCE.md;行为复测明细见 HEL-811 审核记录附件。"
]
}
+42
View File
@@ -0,0 +1,42 @@
{
"task": "HEL-816 总工:记录对坐动态小样 v2 效果认可(升版 v1.10),核对现有真实功能与 PR 状态,交完整界面与真实功能接续方案;本文件为总工交接",
"baseline_version": "1.10",
"read_commit": "e04e258cc0c6ae097f57a4bd2c41a95a1bc6baa8",
"read_notes": [
"完整需求 v1.9(59项)与 sources/ 于待合分支线提交 e04e258(agent/hel-811-handoff-f59f74dc4256,含总工 HEL-811 保全 ad6a3b1 与总管版本说明修正)完整读取;origin/main 仍为 3a986726(v1.8),两者关系已在 README.md 说明。",
"总工本轮以 CLI 对照平台核验新认可原话(01a16810,作者、时间、原话逐字一致)后,按治理规则升版 v1.10(仅 VIS-011 与 CG-004 边界注明小样演示层面认可),来源原话进 sources/ 并写入 source-manifest;未改任何条文结论、历史来源与产品代码。"
],
"requirement_ids": ["CG-001", "CG-002", "CG-003", "CG-004", "CG-005", "CG-006", "CG-007", "CG-008", "CG-009", "CG-010", "CG-011", "CG-012", "CG-013", "CG-014", "CG-015", "CG-016", "CG-017", "CG-018", "CG-019", "CG-020", "CG-021", "CG-022", "CG-023", "CG-024", "CG-025", "CG-026", "CG-027", "CG-028", "CG-029", "CG-030", "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", "AM-001", "AM-002", "AM-003", "UX-001", "UX-002", "UX-003", "UX-004", "UX-005", "UX-006", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "DEL-001"],
"deliverable": "(1)需求治理:sources/ 新增 comment-evidence-dynamic-sample-20261023.json 与 dynamic-sample-effect-acceptance-20261023.md(原话、附件 ID、SHA256、旧/新范围、待实证项),升版 v1.10(VIS-011、CG-004 边界,changes/coverage/README 同步),main 与待合分支关系写入 README;(2)核对:main=3a98672(v1.8,无 R1 代码);PR#4=db571ba(HEL-780 真实档案+流式文字、HEL-783 修复,基点 4984293,未合);PR#6=ca5ddd6(HEL-790 旧小样,基点 4242f83,未合);PR#8=21170f9(HEL-793/795 沉浸对话原型,基点 81d7cb5,未合);8310 实际运行 release r2-20261022-1808-db571ba(=PR#4 head),/api/health 正常、仅 text 能力为真、companion 已创建(未读取档案内容);(3)接续方案《HEL-816 接续方案》(本卡附件):接缝清单、可机械复用/须视觉总监亲自的划分、下一批有限施工提案与验收样例、备份回退、须白栖知拍板项。未写产品代码、未部署、未合并 PR、未动 8300/8310 与用户数据。",
"acceptance": [
"原话核验:01a16810-3b46-7345-8966-dd87b76efe07(member e5c6c938,2026-10-23T09:20:56Z,根评论,内容『这个效果可以』)与交付评论 01a167c1-458d(2026-10-23T07:54:41Z,附件 01a167c1-455b)经 multica CLI 对照平台一致;HTML 附件下载后 SHA256=826bfc045d941c2114b16a198626ca952ff3d95dff02cfc6e32ee09858bcf80e,与仓库 prototypes/hel-799/index.html 及 HEL-811 重建记录逐字节一致。",
"PR 状态核验:以 git 提交图核实 pr4=db571ba(2 提交,merge-base 4984293)、pr6=ca5ddd6(merge-base 4242f83)、pr8=21170f9(2 提交,merge-base 81d7cb5)均不在 main 历史内(merge-base --is-ancestor 复核);Multica 主卡/旧主卡链接表无关联 PR;本环境无 Gitea 令牌,PR 开合状态以平台页面为准,未擅合。",
"服务只读核查(2026-10-23T09:33:44–55Z UTC,经既有授权 SSH 最小读取):.12 端口 8300/8310/8090/8010 均 LISTEN;/opt/cybergril-api/current→releases/r2-20261022-1808-db571ba;systemd active;GET /api/health 返回 ok、version R1、epoch 3、companion_created true、llm ok(halogen-qwen3.8-flash-next)、capabilities 仅 text=true。未读聊天正文与档案内容,未输出凭据。",
"方案边界:提案不把外观换版与新增后端绑成巨型施工;不以口型未成阻塞完整前端;R1→R2/R3→R4、R5/R6 依赖 R1、R7 需授权的顺序保持;AM-001/002/003 与出生底色/记忆/关系守卫、演示与真实数据隔离逐条列入各批验收。"
],
"deferred": [
"真实生活循环、记忆、主动联系、语音(R5 复用 /opt/voice-agent 底座需实测)、天气素材(R6)、全量备份、授权视觉观察(R7):均未实现未验证,按批次承接;R1 人格当前仅浅模板(系统提示组装),CG-014/015 的诠释自由与成长分层不足,保留为后续批次要求。",
"视频日常→回应→交谈→退出→日常的同人同衣同光首尾对齐:无任何真实验证;缺生成工具与首尾可控能力,最低输入清单已在方案中列出(按 HEL-802 v2 母版/提示词、HEL-803 衔接条件),首批责任在小队(CG-030)不转给用户;未安装/付费/换模型。",
"手机候选 4:5 容器与手机真机体验:默认关闭未获批启用,真机键盘/流畅度未测;Firefox/Safari 未测(HEL-811 已记录精确原因)。",
"v1.9+v1.10 均在待合分支线(ad6a3b1→e04e258→本分支),main 仍 v1.8;本环境无 Gitea 令牌无法开 PR,合并与否由总管与白栖知决定。"
],
"requirement_changes": [
{
"approval_comment_id": "01a16810-3b46-7345-8966-dd87b76efe07",
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"quote": "这个效果可以",
"kind": "对坐动态小样整体效果认可",
"affected_ids": ["VIS-011", "CG-004"],
"old": "v1.9:仅交流静图获认可;相处状态、过渡、雾光、手机与真实人物动态仍待验收。",
"new": "对坐动态小样 v2 整体效果(同室相处、推近/退回过渡、交流呈现与阅读方式、月光/雾光两主题;电脑端、演示数据)在演示层面获认可。",
"scope": "不视为完整产品、其余页面或真实功能验收;手机候选默认关闭未获批启用;相处静帧中的书本不恢复已否定的默认阅读(01a166aa);真实人物动态、语音口型、真实生活与完整功能仍待验收;不授权替换正式界面或合并 PR。",
"evidence_file": "docs/requirements/sources/comment-evidence-dynamic-sample-20261023.json",
"artifact_sha256": "826bfc045d941c2114b16a198626ca952ff3d95dff02cfc6e32ee09858bcf80e"
}
],
"verification": [
"python3 scripts/check_requirements.py --base-ref origin/main:通过(59项;v1.10 升版、来源追加、历史保留、新批准评论未复用)。",
"python3 scripts/check_requirements.py --base-ref origin/main --handoff docs/requirements/handoff-HEL-816.json:通过。",
"git diff --check:通过。产品代码零改动(diff 仅 docs/requirements 与本文件)。"
]
}
+5 -1
View File
@@ -7,5 +7,9 @@
"product-decisions-v1.6.json": "ccc5c50c5604385208eb054caac01e27fc63367dac7731e1be9775a1e5cf46ab",
"visual-confirmation-v2.md": "6e0229aaf693f215cc9dcc647be694aeafb3770683b21bb7e3e30291af60255a",
"integration-plan-v1.md": "454af1d4a3b0360d33aea9c0e97f738b320968ba7be68e012a5fcec6f6ed0169",
"comment-evidence-visual-role-20261022.json": "ecf30eb323129dca2a0d9c2ff2e0163884ee94d3fc7828b0540cd45f03103797"
"comment-evidence-visual-role-20261022.json": "ecf30eb323129dca2a0d9c2ff2e0163884ee94d3fc7828b0540cd45f03103797",
"comment-evidence-shared-space-20261023.json": "e56299db726e64fbeea9e07d352066ef2b578889cc9a33cf1789e3234862130d",
"shared-space-static-acceptance-20261023.md": "9aec04ab40e40ce275c75104f004cd3d1473ff8fbef428767afbbe5fafd09cb9",
"comment-evidence-dynamic-sample-20261023.json": "fe7528a2b68aca99968734b200d9d27a17d85f5110e60c6f52d7c569755c260d",
"dynamic-sample-effect-acceptance-20261023.md": "f5e9fde65a58ebc365de47569053ad7c30ce77ae82bb78773cc070e3cbffd90e"
}
@@ -0,0 +1,10 @@
[
{
"id": "01a16810-3b46-7345-8966-dd87b76efe07",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "这个效果可以",
"created_at": "2026-10-23T09:20:56Z",
"parent_id": null
}
]
@@ -0,0 +1,42 @@
[
{
"id": "01a16650-0355-72c4-b291-f1b4494cde91",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "主要是A和B,但是之前没注意到C的问题,你一说倒是有点",
"created_at": "2026-10-23T01:11:22Z",
"parent_id": "01a1664d-f242-71fa-a4ba-295b6c347478"
},
{
"id": "01a16654-5fc5-74be-a704-99b76b48b0b1",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "对就是B",
"created_at": "2026-10-23T01:16:08Z",
"parent_id": "01a1664d-f242-71fa-a4ba-295b6c347478"
},
{
"id": "01a16655-fe53-73a5-905f-4c9e71a1ed30",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "这个我不太能想象得到,我和她待在同一空间是什么概念",
"created_at": "2026-10-23T01:17:54Z",
"parent_id": "01a1664d-f242-71fa-a4ba-295b6c347478"
},
{
"id": "01a16659-fdfa-72ce-a4a5-801e0762c0a9",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "这个能否现在就做个样图让我看一下",
"created_at": "2026-10-23T01:22:16Z",
"parent_id": "01a1664d-f242-71fa-a4ba-295b6c347478"
},
{
"id": "01a1668f-5810-7524-b72f-33574a32c7d6",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "静态效果我认可",
"created_at": "2026-10-23T02:20:32Z",
"parent_id": null
}
]
@@ -0,0 +1,54 @@
## 对坐动态小样整体效果的认可(2026-10-23)
总工(HEL-816)按主卡交付与用户原话整理入库。本记录只写明用户已经说过的话及其适用范围,不改动任何已有来源文件,不扩大授权。
### 用户原话(主卡 HEL-797,作者白栖知 `e5c6c938-db48-4ff9-954f-4fb8b063a132`)
原文逐字保存在同目录 `comment-evidence-dynamic-sample-20261023.json`。
| 评论 | 时间(UTC) | 原话 | 回应的问题 |
| --- | --- | --- | --- |
| `01a16810-3b46-7345-8966-dd87b76efe07` | 2026-10-23 09:20:56 | 这个效果可以 | 总管交付评论 `01a167c1-458d-795b-81e1-584089bf3667`(2026-10-23 07:54:41)请其下载体验《对坐动态小样 v2》并回答「整体是否更像自己坐在她面前」 |
交付评论(总管撰写,不属于用户原话)列明的体验方式:走近她/结束交流、月光/雾光切换、长回复自动续读、暂停回看与全文、手机打开查看;并已声明人物仍是参考静帧、聊天生活天气均为演示、真实视频语音口型尚未接入、手机候选默认关闭。
### 被认可的作品
- 文件:`CyberGril-HEL-799-对坐动态小样-v2.html`,662,233 字节,SHA256 `826bfc045d941c2114b16a198626ca952ff3d95dff02cfc6e32ee09858bcf80e`。
- 附件:主卡附件 `01a167c1-455b-751a-ac37-9fa984a1ed10`(随交付评论 `01a167c1-458d-795b-81e1-584089bf3667`)。
- 仓库内同 hash 原稿:`prototypes/hel-799/index.html`(分支 `agent/agent/54df81e610fd` 提交 `ad6a3b1` 起保全;总工 HEL-811 独立重建逐字节一致,总管 2026-10-23 复下载核对一致)。
- 作者:视觉总监(HEL-799 v2 续作);独立审核:总工 HEL-811(40 项行为通过,无必须返修项,Firefox/真机未测原因见该记录)。
### 认可范围(已交小样的效果)
对坐动态小样 v2 的**整体效果**(电脑端、演示数据):同室相处画面;点她或「和她说话」推近到获认可的交流构图(HEL-798 月光构图);「结束交谈」沿原路退回,中途可反向;Esc 立即回到相处画面;交流呈现与阅读方式(自动续读、暂停、‹ › 回看、全文、完整记录、身份标记);月光 ⇄ 雾光两主题切换(布局与操作位置不变);日子/设置浮层。
### 不在范围内(仍待验收或承接)
| 项 | 状态 |
| --- | --- |
| 手机真机 | 未验证;4:5 候选容器 `?frame=media` 默认关闭,本认可不视为批准启用;真机键盘与流畅度未测 |
| 真实人物动态 | 人物仍是参考静帧;真实视频、眨眼/呼吸/口型/语音未接入、未验证 |
| 真实生活、记忆、主动联系、天气、备份 | 均为演示数据,未接入 |
| Firefox/Safari/真机/系统级后台节流 | HEL-811 因环境未测,不能用等效视口或合成事件代替 |
| 新素材 | 多时段天气穿搭、无书无居家裤的新近景与远景、首尾衔接素材按 HEL-802/HEL-803 承接,尚未生成 |
### 不能推出的结论
- 不视为完整产品、整套界面或任何其余页面(生活、回忆、创建、管理)验收通过。
- 相处静帧中她在看书:用户已否定以阅读作为默认日常(`01a166aa-5f37-77b2-a2bd-ceb62f665813`),本认可不恢复看书默认,也不把该静帧定为最终相处素材。
- 不授权替换正式界面:8300/8310 及已有档案、聊天、记忆保持原状;PR#4/#6/#8 合并与否仍由总管与白栖知决定。
- 不代表 CG-024 的真实动态验证(真实视频选片、动作循环衔接、实时口型语音)完成;小样通过只表示该演示阶段通过。
### 对其他要求的影响
- CG-004「交流镜头」:推近/退回的切镜方式与衔接在小样演示层面(两静帧对齐+镜头推近)获认可;真实视频的切近衔接仍待验收。
- VIS-007「空间动效」、VIS-013「交流呈现」、VIS-004「两套首轮主题」:小样是该要求获认可的演示呈现;条文与边界不变。
- VIS-011:v1.9 已认可交流静图,本次扩展到该小样的整体动态效果(演示层面);条文保留,边界更新。
- 其余已确认要求不变。
### 入库操作(总工 HEL-816,2026-10-23)
1. 新增本文件与 `comment-evidence-dynamic-sample-20261023.json`,`source-manifest.json` 追加两者 SHA256;历史来源未动。
2. 升版 v1.10:`changes.json`/`changes.md` 追加条目(批准评论 `01a16810-3b46-7345-8966-dd87b76efe07`,`affected_ids`=VIS-011、CG-004);两者边界在 `baseline.json` 与 `current.md` 同步更新;`coverage.json`/`coverage.md` 版本与对应项同步;`README.md` 入口版本更新。
3. 检查:`python3 scripts/check_requirements.py --base-ref origin/main` 与 `--handoff docs/requirements/handoff-HEL-816.json` 通过;`git diff --check` 干净。结果见各自运行记录。
@@ -0,0 +1,46 @@
## 交流静态视觉基准的更新(2026-10-23)
视觉总监在 HEL-799 整理,供总管入库核验。本记录只写明用户已经说过的话及其适用范围,不改动任何已有来源文件,也不改写 `current.md`、`baseline.json` 的现行条文。
### 用户原话(主卡 HEL-797,作者白栖知 `e5c6c938-db48-4ff9-954f-4fb8b063a132`)
原文逐字保存在同目录 `comment-evidence-shared-space-20261023.json`。
| 评论 | 时间(UTC) | 原话 | 回应的问题 |
| --- | --- | --- | --- |
| `01a16650-0355-72c4-b291-f1b4494cde91` | 2026-10-23 01:11 | 主要是A和B,但是之前没注意到C的问题,你一说倒是有点 | 总管问最妨碍沉浸的是哪种感觉:A 空间太空泛;B 和她有距离,像窗口里的照片;C 操作出戏 |
| `01a16654-5fc5-74be-a704-99b76b48b0b1` | 01:16 | 对就是B | 总管问:她即使能眨眼、抬头、回应,但仍在人物窗口里,是否主要的不满意还在 |
| `01a16655-fe53-73a5-905f-4c9e71a1ed30` | 01:17 | 这个我不太能想象得到,我和她待在同一空间是什么概念 | 总管问更接近「同一个空间」还是「通向她的世界」 |
| `01a16659-fdfa-72ce-a4a5-801e0762c0a9` | 01:22 | 这个能否现在就做个样图让我看一下 | 总管用第一人称镜头解释「同一空间」后 |
| `01a1668f-5810-7524-b72f-33574a32c7d6` | 02:20 | 静态效果我认可 | 总管交付 HEL-798 样图(评论 `01a16663-3ad2-727c-8888-e116eca13116`),问是否更像「自己坐在她面前」 |
问题原文由总管撰写,在主卡根线程 `01a1664d-f242-71fa-a4ba-295b6c347478` 内,不属于用户原话。
### 被认可的图
- 文件:`hel-798-face-to-face-moon.png`,1600×1000,SHA256 `a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a`。
- 主卡附件 `01a16663-3a5a-76eb-ac3c-1ef93165617c`(随交付评论 `01a16663-3ad2-727c-8888-e116eca13116`);HEL-799 卡附件 `01a16691-b0f0-73a9-aae4-9aba2146899c`,两者字节相同。
- 同一评论另附的标注图 `hel-798-face-to-face-annotated.png` 和新旧对照图 `hel-798-compare-pr8.png` 是说明材料。
- 作者:视觉总监(HEL-798)。源码在 `prototypes/hel-799/reference/hel-798-static/`。
### 旧要求、新认可与适用范围
| | 内容 |
| --- | --- |
| 旧(VIS-011,v1.8 仍有效) | 「人物平时作为空间中独立视窗;交流时自然放大。已认可的人物位置、分量与首页文字分量沿用。」来源 `01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。 |
| 新认可 | **交流状态、靛蓝月光、电脑 1600×1000 的静态画面**:不再用独立人物框;她身边的环境连续铺到屏幕四边;前景有你这一侧的圆桌沿、茶杯和烛灯,中间是她,远处是雨夜窗景;当前一两句对话落在近处、视线下方,底部保留输入和此刻/交谈/日子/设置导航。 |
| 适用范围 | 仅以上交流状态静图。用户认可的是「静态效果」。 |
| 不在范围内(仍待验收) | 相处(平时)状态的构图;进入与退出交流的过渡和整体动态;蓝紫雾光主题;手机版;真实人物动态、眨眼、口型与语音;生活、回忆、创建、管理等完整功能的最终页面。 |
| 不能推出的结论 | 不能据此永久取消 VIS-011 在全部状态中的原规则;不能把完整前端锁定为全屏卧室照片或视频(CG-031 继续有效);不能视为整套界面或任何功能已经验收。 |
### 对其他要求的影响
- VIS-001、VIS-002、VIS-006、VIS-013:这张图是「两人的数字空间」「空间深度」「交流以人物为主、当前一两句」的一种获认可呈现。
- CG-004「交谈时允许自然切近」:图只证明切近后的静态画面,切近过程要在 HEL-799 动态小样中另行验收。
- 其余已确认要求不变。
### 入库建议(由总管决定,视觉总监未改动正式基准)
1. 将 `comment-evidence-shared-space-20261023.json` 加入 `docs/requirements/sources/`,并在 `source-manifest.json` 追加它的 SHA256;历史来源不动。
2. 如决定升版为 v1.9:在 `changes.json` / `changes.md` 追加一条,`approval_comment_id` 为 `01a1668f-5810-7524-b72f-33574a32c7d6`,`affected_ids` 至少含 `VIS-011`,摘要写「交流状态静态视觉基准更新为 HEL-798 共同空间样图」,影响写明上表的适用范围与不在范围内;VIS-011 的结论原文保留,只在边界中补一句「交流状态静图已按上述评论更新,平时状态、过渡、雾光、手机待验收」,`current.md` 与 `baseline.json` 同步;`coverage.json` 的版本跟着改。
3. 入库后运行 `python3 scripts/check_requirements.py --base-ref origin/main` 及 `--handoff prototypes/hel-799/handoff-hel-799.json`。本运行环境没有 git 和 Python,这两项都未执行。
+42
View File
@@ -0,0 +1,42 @@
# HEL-799 原稿保全记录(机械入库,总工 HEL-811)
本目录是视觉总监 HEL-799 v2 交付原件的逐字节保全,由总工于 2026-10-23 机械入库;未改动任何作者文件内容。
## 来源
- 交付评论:HEL-799(01a16691-b114-7880-bf13-51b6201f6871)根评论 01a1679c-4178-7d65-8867-cd2716d91a13,作者为视觉总监(agent `d6da7655-1462-43b7-9b9c-227dcdf21bba`)。
- 源码包附件 `01a1679a-4bfd-7651-bf62-176c025894d5`(HEL-799-v2-源码与离线HTML.zip,5,856,837 字节,SHA256 `ce302111150597793ef942d343583a3152e7b99489587a230d7fafc6c836bae7`)。zip 条目为 Windows 反斜杠分隔,入库前已规范为正斜杠;除路径分隔外未做任何内容改动。
- `web/public/assets/her-room.jpg`、`her-talk.jpg` 与仓库现有文件 SHA256 逐一相同(51ca450a…、0b9e91b5…),正式 web 资产未改动、未覆盖。
- 离线 HTML 附件 `01a1679a-25d1-74d9-aac8-80102716e895` 与包内 `index.html` 字节相同(SHA256 826bfc045d941c2114b16a198626ca952ff3d95dff02cfc6e32ee09858bcf80e)。
## 独立重建核验(HEL-811)
总工用独立实现的构建脚本(Python 复刻 build.ps1 的模板替换与 base64 内嵌规则,输入为本仓库 `web/public/assets/` 两张获批静帧)重建 `index.html`,结果 662,233 字节,SHA256 826bfc045d941c2114b16a198626ca952ff3d95dff02cfc6e32ee09858bcf80e,与交付 HTML 逐字节一致。
原中断运行(01a16691-b121-725d-a2e3-8d1f0ce57c02)的完整保全包为附件 `01a1679c-3ba9-7b62-a0a5-e828cb6e5d41`(26 个文件:代码、handoff、records、verification.json、两张总览图与 hel-798 静图参考),未随本目录入库;其与本目录续作差异见 HEL-811 审核记录。
## 文件清单与 SHA256
- `prototypes/hel-799/README.md` `8269d688ffbda2dd34608778204f5074079c254dbf69072e7a2fe78e793f3093`
- `prototypes/hel-799/build.ps1` `8a5d8cba2c60eb0b4999a482635c317cf8f1df54bb295eb5fca4b5f9464b43a1`
- `prototypes/hel-799/handoff-hel-799.json` `812d4eab8f1b4f57b8378ff0a1f810e8b2dce5342cbfc686cf430c03bd2e1452`
- `prototypes/hel-799/index.html` `826bfc045d941c2114b16a198626ca952ff3d95dff02cfc6e32ee09858bcf80e`
- `prototypes/hel-799/records/comment-evidence-shared-space-20261023.json` `e56299db726e64fbeea9e07d352066ef2b578889cc9a33cf1789e3234862130d`
- `prototypes/hel-799/records/shared-space-static-acceptance-20261023.md` `9aec04ab40e40ce275c75104f004cd3d1473ff8fbef428767afbbe5fafd09cb9`
- `prototypes/hel-799/reference/hel-798-static/assets/her-room.jpg` `51ca450a454c108d304feee2745070fbbbd3232863096126badf279ee53a925a`
- `prototypes/hel-799/reference/hel-798-static/assets/her-talk.jpg` `0b9e91b58d75afb836efdad3d8e8f0aab0c44df64b2c4ddd59d011fd499aeb33`
- `prototypes/hel-799/reference/hel-798-static/hel-798-compare-pr8.png` `6ec7b3d454af4404eafb0b0fa86c17baa10e6aa622456c2ef8689d9011bec764`
- `prototypes/hel-799/reference/hel-798-static/hel-798-face-to-face-annotated.png` `8d5092b722d653d0217ed758635d0be61b70a5ebdc79da449a280a8bed43d52d`
- `prototypes/hel-799/reference/hel-798-static/hel-798-face-to-face-moon.png` `a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a`
- `prototypes/hel-799/reference/hel-798-static/index.html` `8ee3fba1e62409f06d0ee8b42cee735a1d5eb64965ea3a027b997352bcce3d7c`
- `prototypes/hel-799/src/ambient.js` `6a9b5edf204364a31e855fa3554643715b91d1b2ef88175a0bc2be2cb0792e27`
- `prototypes/hel-799/src/room.css` `a3daf8ac189969e8dab1de8f8139bf6fc2426b5c15d14d332f40532b3fe50515`
- `prototypes/hel-799/src/scene.js` `d51302195996d6ec75c2044dfa01ec81ad2f17b11676f143ede965f0d282e2f6`
- `prototypes/hel-799/src/shell.html` `53b468896e6b8d722fc8ded1d7a3f83b8f8e6ab0b027d7d0b0a2ba4bec70b292`
- `prototypes/hel-799/src/talk.js` `55af813358f83c5e0f99948056489b4387138c1464c51bafaa1359ec6072f73a`
- `prototypes/hel-799/tools/cdp.ps1` `e7b872ee6915bbfd4c6bb528c7b5593f25e60bde0bc66c79f3c63f0efddd22fb`
- `prototypes/hel-799/tools/contact.ps1` `f91761ec80dc44d1746238667da47df291f43f7bb14d6137002d73777612fd0c`
- `prototypes/hel-799/tools/record.ps1` `b6ed8344b125e739664c2a31b735d18238577f5d591ec9ad3e011bb20be99d37`
- `prototypes/hel-799/tools/snap.ps1` `ecb8dc017bfcb3883942ac7b01e160037c8261749ddc5759b4048d85ad6020e6`
- `prototypes/hel-799/tools/strip.ps1` `fe37c21270feeb64f810aef8f6aead21bdc3b79fc57ed99cff8435e35896817d`
- `prototypes/hel-799/tools/verify.ps1` `796739f6d079cc500d6221a6155cc1fb4545f246c3cb753007b8d4b0b969d557`
+161
View File
@@ -0,0 +1,161 @@
# 对坐 · HEL-799 共同空间动态小样
视觉总监亲自设计并编写。直接用浏览器打开 `index.html` 即可:两张参考静帧、样式和脚本都已内嵌,不用安装,也不联网。这是独立小样,没有接入产品入口,也不替换正式界面。
基准:白栖知在主卡 HEL-797 评论 `01a1668f-5810-7524-b72f-33574a32c7d6` 中认可的 HEL-798 交流静图 `hel-798-face-to-face-moon.png`(1600×1000,SHA256 `a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a`)。原图及其 HTML/Canvas 源码原样保存在 `reference/hel-798-static/`。
人物仍是两张获批参考静帧,界面上标为「人物为参考静帧」。走近她的过程是镜头在两张照片之间对齐她的脸后推近,不是人物视频:她不会眨眼、呼吸、抬头或说话,也没有口型。文字、生活、回忆和天气都是虚构示例。小样没有连接模型、麦克风、摄像头、生活、天气或备份服务。
## 一句话方向
雨夜里,你坐在自己的小桌旁,她在窗边那张沙发上做她自己的事。想说话时,房间向你靠近,她就坐在桌子对面;说完,房间舒展开,她回到自己的事里。
> 相处画面用的仍是旧参考静帧,静帧里她在看书——这只示意你们在同一间房里的位置。用户已否定以阅读作为默认日常(主卡 01a166aa),按时段、天气、穿搭准备的新日常素材尚未生成;小样里的界面文字已不再写“看书”。
## 怎样试
1. **相处(此刻)**:先停留十几秒,不要操作。窗外的雨在落,城市的灯一盏盏亮灭,水珠沿玻璃滑下,月光下有薄云掠过。她身边和你桌上的烛光在跳,你的茶在冒热气,灯下有细小的光尘。电脑上移动鼠标,远处的房间和你面前的桌子会朝相反方向轻微移动。
2. **走近她**:点她本人,或点「和她说话」。镜头从房间推到桌前:她从远处的沙发一路变近,近景从她脸部周围柔和地展开,直到成为获认可的交流画面;你面前的桌沿、茶杯和烛灯一直都在。全程是同一个人、同一身衣服、同一间房。
3. **退回原处**:点右上「结束交谈」或底部「此刻」,镜头沿原路拉回。中途随时可以反向点击,镜头会从当前位置转向,不会跳回起点或闪黑。键盘 Esc 立刻回到相处画面(不播放过渡)。
4. **交流**:保留 PR#8 已验证的阅读方式。回复默认自动一句接一句呈现,一直读到最后一句,中间不用点击。屏幕上只保留上一句(变淡、与身份同行)和当前一句。计数下的细线表示这句还会停留多久,鼠标停在文字上会先停住计时。「暂停」后可用 ‹ › 或键盘 ← → 回看,会出现「回到读到的地方 · 后面还有 N 句」;「全文」看整段;输入框左侧的按钮打开完整记录。发送新消息会自动恢复呈现,「停止回复」保留已出现的内容并标为未完成。
5. **完整记录、全文**:从左侧展开,她留在桌对面不动;关闭后回到刚才那一句。**日子、设置**:房间退回远景并暗下来,浮层从下方升起;生活条目可进入详情,再「看那时说的话」。设置里的关系、城市、外观、备份、语音与视觉观察、空间与显示都能打开对应的演示或说明。
6. **月光 ⇄ 雾光**:右上切换。布局和操作位置不变,窗外天色、房间色调、云或雾、雨和灯光颜色、桌面反光、茶杯与浮层材质约 1 秒内过渡。月光有月亮和冷蓝夜色;雾光没有月亮,窗外有缓慢折叠的紫粉雾带,整体偏紫。
7. **手机**:同一文件。相处时她在上方的窗边,文字和入口在下方;交流时她的脸在上部,对话、输入框和导航在下方,彼此不遮挡。键盘弹出(可视高度变小)时先收上一句,正在输入时导航让开。
8. 右上「独立小样 · 演示数据」里有长回复、连续两段话、生成中与停止、初次创建与长名字等试用片段。
可用的地址参数(评审和录屏用):`?theme=fog`、`?mode=talk`、`?scene=long`、`?tier=0|1|2`;候选与导出:`?frame=media`(手机 4:5 容器候选)、`?clean=1`(无界面)、`&fore=0`(去前景与暗角)、`&fit=height`(16:9 母版取景)。
## 2026-10-23 续作:恢复、候选取景与母版
原运行 `01a16691-b121-725d-a2e3-8d1f0ce57c02` 因会话额度中断,未交付。本轮由同一作者在原配置下恢复:先把原目录 `hel-799-8d1f0ce57c02/workdir/proto/prototypes/hel-799/` 整体打包保全(`hel-799-original-run-8d1f0ce57c02.zip`,随本卡附件交付,未删改原目录),再在副本上续作。原稿的 CSS 夜色层顺序修正保留。`evidence/` 根目录与 `evidence/media-candidate/` 下的截图、录屏和 `verification.json` 由本轮构建重新生成;上一构建(仍写“看书”文案)的总览图、过渡帧和录屏移到 `evidence/previous-build-20261022/`,只作历史对照。
### 本轮改动
1. **去掉“看书”作为日常的文字**:顶部状态、此刻标题、演示对话里的“这一页我念给你听”“读到……”改成不指向阅读的说法;参考说明改为「人物为旧参考静帧 · 不代表新日常」。交流态示例句因此与获认可静图的示例文字不同(静图只认可画面,不认可演示文案)。
2. **手机 4:5 媒体容器候选**(`?frame=media`,默认关闭):按 HEL-802 v2 §2.3 实现,见下表。桌面不受影响。
3. **无界面母版导出**(`?clean=1`,可加 `fore=0`、`fit=height`),见“母版与前景分工”。
### 手机 4:5 媒体容器:实际 DOM 与 HEL-802 v2 公式对照
规则:容器高 `H = min(1.25·宽, max(宽, 可视高 − 240), 可视高 − 56)`(后一项即键盘态的“可视高减输入条”),cover,底部 12% 渐隐到页面底色,容器本身不随镜头缩放,手机不画前景桌杯。数据来自无头 Edge 实测(`evidence/media-candidate/`)。
| 视口 | 容器 公式 → 实测 | 她的脸中心 公式 → 实测 | 对话/输入实测位置 | 差异与原因 |
| --- | --- | --- | --- | --- |
| 390×844 | 390×487.5 → 390×487.5 | (178,146) → (170,157) | 当前句 619–698,输入 736–784,导航 790–838;对话区从 566 起,在容器下方 | 公式按 16:9 源(s=0.4514,可见 45%);小样用的是 16:10 获认可构图(s=0.4875,可见 50%),且该图脸在 y=0.315 而非 0.30,故偏 8/11px。相处态“此刻”文字从 462 起,压进容器底部渐隐带 26px,未压到人物 |
| 320×568 | 320×328 → 320×328 | (148,98) → (143,105) | 当前句 359–432,输入 466–514,导航 518–562 | 同上;此高度触发紧凑排版(隐藏上一句),对话与容器不重叠,但整体偏挤,与 HEL-802 预判一致 |
| 390×430 键盘 | 390×374 → 390×374 | (182,112) → (176,120) | 最近回复 294–342 盖在容器底部 80px(公式 88px),输入 376–424,导航让开 | 回复只压在开衫上,脸与下巴不被遮 |
| 1600×1000 | 不启用 | (856,321) 与默认一致 | — | 候选只作用于 ≤720px |
**用现有静帧放进候选容器时暴露的问题(须用新素材解决,不能靠界面遮):** 获认可构图底部约 20%(y≥0.80)原本被页面绘制的桌沿挡住,里面是旧素材的书和深色居家裤。手机候选容器不画前景、整高可见,这一块落在容器底部(约 390–463px),一部分在渐隐带外仍看得见;推近途中两张静帧交叉时也能看到远景静帧的书和腿。HEL-802 v2 已要求近景 CT 的 y≥0.78 只出现衣服和沙发、不出现手部动作,新母版按此生成即可消除;现阶段如实保留,不另加遮挡。
**仍待验证**:真实视频放入容器后的推近是否保持人物中线(HEL-802 §2.4 的 ±0.03 门槛)、真机键盘弹出时容器 240ms 收缩的观感、渐隐带与真实视频暗部的衔接。用户未认可手机候选外观,默认仍是原整屏版本。
### 母版与前景分工
`masters/` 下为无界面母版(月光与雾光两套):
| 文件 | 内容 | 用途 |
| --- | --- | --- |
| `talk-*-1600x1000-with-foreground.png` | 获认可构图:照片+夜色叠层+月亮+窗内雨+灯光+页面桌沿/茶杯/近处烛灯+暗角,去掉全部界面 | 与获认可静图对照、给生成工具看“目标氛围”,**不作为视频首尾帧** |
| `talk-*-1600x1000-plate.png` | 同上但去掉桌沿、茶杯、近处烛灯和暗角 | 16:10 路线的视频底板参考 |
| `talk-*-1920x1080-plate-16x9-candidate.png` | 按高度铺满、左右各外延,原构图约占中间 90%(因原图右侧只多 74px,构图整体右移约 16px:x16:9 ≈ 0.058 + 0.9·x16:10);脸中心实测 (0.540, 0.317),HEL-802 v2 写 (0.54, 0.30) | 16:9 路线候选,**新取景待核对**,未裁脸、未拉伸 |
| `room-moon-1600x1000-plate-reference-only.png` | 相处远景静帧 | 只作空间参考;相处远景未获认可,不是必备母版 |
分工(与 HEL-802 v2 §2.6 一致,均为候选):
- **烘焙进视频**:她、房间、窗外城市、窗内的雨、月亮(月光组)、她那一侧的落地灯和花旁小烛光、时段光线。
- **页面另绘**:你这一侧的桌沿、茶杯与热气、近处烛灯、暗角、主题浮层。页面现有的窗内雨和月亮在接入真实视频后关闭,以视频里的天气为准,避免重复。
- 前景只在镜头静止的近景交谈态淡入,运镜时不出现;手机候选不画前景。
**这些母版都不能直接拿去当最终首尾帧**:人物仍是旧参考素材(黄昏调原图经页面调成夜色、手里有书、穿居家裤),而 HEL-802 v2 的夜间造型是奶白开衫配百褶短裙和过膝袜、y≥0.78 不出现手。它们的作用是钉住构图、光色和脸的位置;正式 S/T 母版需按 HEL-802 v2 重新生成。原始 `her-talk.jpg`(1600×900 黄昏无字)仍只适合做人物稳定性试片。
## 与 HEL-798 静图、PR#8 的关系
| 项目 | 做法 |
| --- | --- |
| 交流状态 | 1600×1000 下与获认可静图使用同一张人物素材、同一组夜色叠层(月亮、雨、光束、灯光)和同一套桌沿、茶杯、烛灯绘制代码,对照见 `evidence/compare-approved-vs-talk.png`。差别:雨、灯、热气、光尘现在会动;文字区多了一行很轻的阅读控制(暂停、回看、计数、全文),所以当前句比静图高约 30px;短句会并成一行,与静图一致。 |
| 相处状态(新增,待验收) | 同一间房的远景(第二张获批静帧),按静图的夜色重新调色,窗外同样下雨、有月亮。前景仍是你这一侧的桌沿、茶杯和烛灯,只是略低一些。左侧放「此刻」的状态和入口。她不在任何框里,房间铺满屏幕四边。 |
| 过渡(新增,待验收) | 不是两张图硬切,也不是把人物框放大:两张静帧按她的脸对齐(脸的大小差约 3.04 倍),镜头在两者之间连续推近,近景从她脸部周围以柔和边缘展开,画面边缘一起变柔。月亮作为远处的物体,比房间移动得少,在两扇窗之间交接。 |
| PR#8 保留的部分 | 两套主题 token、阅读节奏和所有阅读控制、身份标记、记录与全文、未分享不显示、初次创建与起名、语音界面演示、键盘即时规则(HEL-795)、减少动效和低档设备降级。 |
| PR#8 去掉的部分 | 拱形人物窗与它的几何动画、Canvas 抽象光雾背景。空间改由房间本身承担。 |
## 视觉规范(施工依据)
**配色 Token**(两套主题结构相同;与 PR#8 相同的不再重复列出)
| Token | 靛蓝月光 | 蓝紫雾光 | 用途 |
| --- | --- | --- | --- |
| `--accent` | `#a9dcff` | `#ebbcff` | 关键操作、选中、她的身份点 |
| `--text-1/2/3` | `#eef2ff / #b9c6e6 / #94a5c8` | `#f6efff / #dccbee / #bba7d6` | 正文/次要/说明 |
| `--glass` / `--glass-read` | `#16244e66` / `#0b1534c4` | `#36226666` / `#211444c8` | 轻浮层/阅读浮层 |
| `--input-a/b/c` | `#16244e70 → #1c264c70 → #3a2c3a60` | `#3a245e70 → #3a265470 → #5a2c5260` | 输入框横向渐变(静图原样) |
| `--pool` | `#050918` | `#0e0820` | 文字背后的暗池与字影 |
| 房间夜色(multiply) | `#16225c → #2a3a8a` | `#2c1f5e → #4a3a8a` | 远景调色;她和灯的位置保留原暖色 |
| 窗外天色(color) | `#24357c` | `#5a3a92` | 只染窗和天空 |
| 雨色 | `rgb(190,210,255)` | `rgb(228,204,255)` | 1 秒混色 |
| 桌面 | `#3b2c30 → #221a24 → #0b0a14` | `#3a2a3e → #231a30 → #0e0a18` | 你这一侧的桌子 |
**字体**:PingFang SC / HarmonyOS Sans SC / MiSans / 微软雅黑。
| 场景 | 电脑 | 手机 | 键盘弹出 |
| --- | --- | --- | --- |
| 她的当前句 | 23px / 350 / 行高 1.5 | 18px | 16px |
| 你的当前句 | 20px / 300 | 16px | 16px |
| 上一句(与身份同行) | 15px / 300,透明度 .6,单行省略 | 13px | 隐藏 |
| 身份标签 | 12px,字距 .08em | 12px | 12px |
| 名字(顶部) | 22px / 400 | 16px | 16px |
| 此刻标题 | 30px / 300 | 22px(矮屏 19px) | 18px |
**身份**:她的话前面是实心发光点和她的名字;你的话前面是空心圆圈和「你」。完整记录使用同样的标记。
**动效**:曲线沿用 PR#8:进入和停稳 `cubic-bezier(.22,1,.36,1)`;空间移动 `cubic-bezier(.65,0,.25,1)`;镜头本身用临界阻尼弹簧。
| 动效 | 触发 | 时长与方式 | 能否打断 |
| --- | --- | --- | --- |
| 镜头走近/退回 | 点她、和她说话、结束交谈、此刻、交谈 | 临界阻尼弹簧 ω=7.2,约 0.85 秒停稳;缩放在对数空间插值,她的脸在屏幕上走直线 | 是,保留当前速度转向 |
| 近景展开 | 随镜头 | 走到 8%–45% 时近景淡入;以她的脸为圆心、半径 150→1600px、羽化 520px 展开;画面边缘羽化 260px,在 70%–98.5% 收为 0,到终点去掉遮罩 | 是 |
| 月亮交接 | 随镜头 | 20%–80% 之间从远景窗移到近景窗,位移小于房间 | 是 |
| 你的桌子 | 模式切换 | 850ms:相处时下移 3.5vh,交流时归位,浮层打开时下移 7vh 并变暗 | 是 |
| 此刻文字 | 离开/回来 | 离开 220ms 淡出加模糊;回来延迟 240ms,360–520ms 淡入 | 是 |
| 交流区 | 进入/离开 | 进入延迟 300ms,520–620ms;离开 220–420ms | 是 |
| 结束交谈按钮 | 进入交流 | 420ms,延迟 200ms | — |
| 新句、换句、逐字 | 自动或点击 | 与 PR#8 相同:680ms 进入;固定字号的两份副本交叉 520/620ms;逐字错开 14ms,最多 48 字 | 是 |
| 浮层 | 打开/关闭 | 460ms/240ms,从下方升起并由模糊变清晰 | 是 |
| 导航亮块 | 切换 | 460ms,只移动 transform | 是 |
| 按钮反馈 | 按下 | 160ms,缩放 .97 | 是 |
| **键盘** | Esc 退出交流或关闭浮层;← / → 回看、前进 | **0ms**:镜头、文字、界面直接到目标状态,不重播逐字;进行中的有限动画直接跳到终点 | — |
| 主题 | 切换 | 房间调色 0.9s,雨色、月亮 1s,界面 0.6s | 是 |
| 环境循环 | 一直运行 | 雨每秒 520–940px;城市灯慢闪;水珠下滑;云/雾 34s、47s;灯光呼吸 9s;烛光 2.8–3.2s 跳动;热气 7s、9s;光尘 17–24s | 页面隐藏时暂停 |
| 指针视差 | 鼠标移动(仅精确指针) | 远景 ±16/10px,桌面反向 ±22/10px,1.4s/0.85s 缓出 | 是 |
**断点**
- **电脑(>1100px)**:相处时「此刻」在左侧约 6.5vw,她在右侧窗边;交流时她居中,对话落在桌沿上方,输入框宽 640px,导航贴底。
- **720–1100px**:隐藏天气和右下说明。
- **手机(≤720px)**:相处时她在上方,「此刻」文字和入口在下方;交流时她的脸在上部 27% 处,对话、输入框、导航在下方;结束交谈只留图标。宽度 ≤360px 时,演示标签缩成「演示」。
- **可视高度 <610px**(键盘或横屏):隐藏上一句和顶部说明,正在输入时导航让开。**<420px**:再隐藏计数和「全文」,入口仍在记录中。
**性能与降级**
- 每帧只改两张静帧的 transform、近景的透明度和走近途中的遮罩。照片只随镜头整体等比缩放、平移,不做局部变形,也不单独做呼吸、眨眼、抬头之类的动画。远景、近景、月亮各自合成。
- 雨只在两扇窗的玻璃范围内绘制;看不见的那张静帧不绘制雨。桌面、茶杯、烛灯按视口尺寸只在尺寸变化或换档时重画。
- 自动降档:镜头静止时,最近 120 帧平均帧间隔超过 26ms 就降一档。档 1:雨降为半分辨率,桌面画布按 1 倍像素。档 2:走近时不用逐帧遮罩,改为两张静帧直接交叉淡化;去掉毛玻璃、光尘和雾带;雨约 30fps。
- 系统「减少动态效果」或设置里的「减少动效」:镜头不推近,两张静帧原位 200ms 交叉淡化;雨停在一帧;环境循环、视差和逐字都关闭。
- 页面隐藏时,绘制和 CSS 动画暂停。
- 本机只有无头 Edge 软件光栅:走近过程中实测约 16–22fps(evidence 里的录屏同样来自软件光栅)。**真实 GPU 帧率未测**,不据此推断真机表现。
## 复现
```powershell
powershell -File prototypes/hel-799/build.ps1 # 生成 index.html(只需 PowerShell 5.1)
powershell -File prototypes/hel-799/tools/verify.ps1 # 行为检查,写入 evidence/verification.json
```
`build.ps1` 读取 `src/` 下的 `shell.html`、`room.css`、`ambient.js`(雨、窗、桌)、`scene.js`(镜头)、`talk.js`(对话、浮层、模式),以及仓库 `web/public/assets/` 里的两张获批静帧。`tools/` 是本次在 Windows 上实际使用的无头 Edge 驱动:`cdp.ps1`(沿用 PR#8)、`snap.ps1` 截图、`verify.ps1` 行为检查、`record.ps1` 录屏、`contact.ps1` 录屏抽帧、`strip.ps1` 拼图。没有新增产品依赖。
## 验证范围与未验证项
- 已实测(无头 Edge,Chromium 内核):电脑 1600×1000、1440×900、1280×720;手机等效视口 390×844、360×740、320×568;键盘等效 390×430;两套主题;系统减少动效;强制档 2。结果见 `evidence/verification.json` 和截图、录屏。
- **未验证**:真机软键盘、iOS Safari 和 Safari 桌面版、Firefox、真实 GPU 帧率、低端手机。等效视口不能代替真机验收。`mask-composite` 在旧版 Safari 上可能退化为只有圆形展开、没有边缘羽化。
- 回复是逐句模拟生成的,不是真实 SSE。
- 相处构图、走近与退回的过渡、雾光主题和手机版都还没有经过白栖知验收;本次认可只针对 HEL-798 那张月光交流静图。
+17
View File
@@ -0,0 +1,17 @@
# Builds the single offline index.html (Windows PowerShell 5.1; no Python or Node needed).
# Inputs: src/shell.html, src/room.css, src/ambient.js, src/scene.js, src/talk.js and the two approved stills in web/public/assets.
$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 'room.css'))
$html = $html.Replace('{{SCRIPT}}', (& $read 'ambient.js') + "`n" + (& $read 'scene.js') + "`n" + (& $read 'talk.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)"
+61
View File
@@ -0,0 +1,61 @@
{
"task": "HEL-799 视觉总监沿获认可的 HEL-798 交流静图,做共同空间(相处 ⇄ 交流)可操作动态小样;2026-10-23 中断恢复续作:保全原稿、去掉阅读日常文案、实现并实测 HEL-802 v2 手机 4:5 媒体容器候选、导出无界面母版与前景分工",
"baseline_version": "1.8",
"read_commit": "3a986726d8593e0ed2274f79db6cc5d807d2ebf0",
"read_notes": [
"原运行(01a16691-b121-725d-a2e3-8d1f0ce57c02)经 Gitea API 核对 main = 3a986726d8593e0ed2274f79db6cc5d807d2ebf0(v1.8,59 项),下载该提交归档并完整读取 AGENTS.md、docs/requirements/README.md、current.md、coverage.md、changes.md、source-manifest.json、task-handoff-template.md、scripts/check_requirements.py 及 sources/visual-confirmation-v2.md、design-acceptance-v1.md、comment-evidence-visual-role-20261022.json。",
"本轮(恢复运行)未能独立复核 main:本机无 git,Gitea API 匿名访问返回 404。以总管在本卡写明的 origin/main 3a986726 为准,本轮未见到更新提交的证据;需总管入库时机械复核。",
"本轮读取:本卡最新描述(恢复交接、HEL-800 素材核对、母版与镜头约束、原完整验收);附件 HEL-802 v2 HTML(§1–§3 全文、§2.3 容器公式与 §2.6 前景分工)、handoff-HEL-802-v2-manager-check.json、HEL-803 v2 衔接规格全文。用户最新原话来源 01a166aa(否定阅读试片、按时段/天气/穿搭准备完整素材)、01a166ad(基于前端给视频参数)、01a16699(效果取决于实际生成结果、未选远景路线)按本卡与 HEL-803 v2 记录引用。",
"PR#8 仍未合并,head 21170f9fc9ab4666c4e9016ea80a1fabdd1f76aa;阅读逻辑、主题 token 与 HEL-795 键盘即时规则沿用其 prototypes/hel-793/src。"
],
"requirement_ids": ["CG-002", "CG-003", "CG-004", "CG-008", "CG-017", "CG-018", "CG-023", "CG-024", "CG-025", "CG-026", "CG-028", "CG-029", "CG-030", "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-799/(视觉总监亲自设计并编写,未改正式产品、main、PR#4/#6/#8 或需求基准文件):离线单文件 index.html;源码 src/shell.html、room.css、ambient.js、scene.js、talk.js;build.ps1;tools/ 无头 Edge 截图、行为检查、录屏、抽帧与拼图脚本;README.md(方向、token、字体、动效表、断点、性能降级、验证范围,新增“2026-10-23 续作”:手机 4:5 容器候选的 DOM 与公式对照、母版与前景分工);evidence/(重新生成的截图、verification.json 29 项、walkthrough-desktop-v2.webm 及抽帧)与 evidence/media-candidate/(候选容器 390×844、320×568、390×430 键盘态截图、进出与 Esc 检查、录屏);masters/ 无界面母版 7 张(月光/雾光 × 含前景对照/无前景底板 1600×1000、16:9 1920×1080 候选底板、相处远景仅参考);reference/hel-798-static/ 获认可静图与其 HTML/Canvas 源码原样;records/ 用户原话来源与交流静态视觉基准更新记录。另附原运行目录完整打包 hel-799-original-run-8d1f0ce57c02.zip(保全原稿与原证据,未删改原目录)。",
"acceptance": [
"交流状态:1600×1000 下与获认可静图同素材、同夜色叠层、同一套桌沿/茶杯/烛灯绘制,环境铺满四边、无独立人物框,对话落在视线下方,底部保留输入与四个导航(evidence/compare-approved-vs-talk.png 为原运行生成;本轮 desktop-talk-moon.png 重新生成)。演示句已改为不指向阅读,静图认可只针对画面。",
"相处状态(待用户验收):同一间房远景静帧调成同一雨夜,前景仍是你这一侧的桌沿、茶杯和烛灯;界面文案改为“她在窗边,雨声不大”,参考说明标“人物为旧参考静帧 · 不代表新日常”。静帧里的看书姿态只示意空间,不代表新日常。",
"往返与快速开关:点她/和她说话/交谈进入,结束交谈/此刻退回;两张静帧按她的脸对齐由一台临界阻尼镜头连续推拉,中途反向点击从当前位置转向,不跳回、不闪黑(verification: click-enter-*、enter-no-jump-no-black-frame、rapid-toggle-redirects-smoothly 均 PASS)。",
"键盘即时:Esc、←/→ 在 80ms 内到达目标、有限动画 0 个、不重播逐字(HEL-795 规则);候选容器模式下 Esc 同样 80ms 内回到相处、有限动画 0。",
"照片不变形:两张静帧只随镜头整体等比缩放和平移,不用缩放冒充眨眼、呼吸、抬头或口型(photos-never-warped-or-animated PASS)。",
"交流能力沿用 PR#8:长回复自动续读到最后、全文与原文一致、暂停/回看/回到进度、发送恢复自动、身份标记、停止保留已生成内容并标未完成、记录打开时她仍在桌对面且关闭回原句。",
"功能入口:日子→生活详情→看那时说的话、设置→关系/备份/语音与视觉观察等均打开带“演示/尚未接入”标记的内容;语音只演示界面,未申请麦克风。",
"两主题:月光与雾光切换后输入框、导航、对话区、结束按钮位置完全相同,房间、窗外、雨、桌面与材质不同(themes-keep-structure PASS)。",
"布局:1600×1000、1440×900、1280×720、390×844、360×740、320×568、390×430 长回复流动全程无遮挡、长名字不溢出(no-overlap-* 7 项 PASS)。",
"减少动效与降级:系统减少动效时原位约 200ms 交叉淡化、环境循环停止;强制档 2 无逐帧遮罩、无毛玻璃、雨半分辨率。",
"手机 4:5 媒体容器候选(?frame=media,默认关闭):390×844 容器 390×487.5、320×568 为 320×328、390×430 键盘态 390×374,三者与 HEL-802 v2 公式一致;脸中心实测 (170,157)/(143,105)/(176,120),公式 (178,146)/(148,98)/(182,112),差异来自小样用 16:10 获认可构图而非 16:9 源;键盘态最近回复覆盖容器底部 80px(公式 88px),只压在开衫上;桌面不受影响。",
"无界面母版:masters/ 下母版不含任何界面;含前景版与获认可构图对照,无前景底板与 16:9 候选底板用于视频取景参考;README 写明烘焙进视频与页面另绘的分工。",
"行为检查 29/29 通过(evidence/verification.json,本轮构建重新运行)。"
],
"deferred": [
"用户体验验收:相处构图、走近与退回的过渡、雾光主题、手机版(含 4:5 容器候选)、整体动态均未经白栖知验收;本次认可只针对 HEL-798 月光交流静图。认可前不替换正式界面(8300/8310),不合并 PR#4/#6/#8。",
"现有静帧放进候选容器时暴露获认可构图底部(y≥0.80)原本被页面桌沿挡住的书和居家裤,推近途中也能看到远景静帧的书和腿;需按 HEL-802 v2 重新生成近景 CT(y≥0.78 只有衣服和沙发)与新造型母版,不靠界面遮挡。",
"母版仍是旧参考人物(黄昏原图经页面调成夜色、手里有书、居家裤),只钉构图、光色与脸位置,不能当最终首尾帧;正式 S/T 母版、真实视频、同脸/循环/推拉中线(±0.03)与 LIVE 接管几何由主卡与首批小队制作承接(CG-030)。",
"需求入库:records/ 中的来源与静态基准更新记录需总管放入 docs/requirements/sources/ 并更新 source-manifest;是否升版及 VIS-011 边界注明由总管按 records/shared-space-static-acceptance-20261023.md 核定。视觉总监未改 baseline/current/changes/coverage。",
"工具:本运行时无 git 与 Python,未能运行 python3 scripts/check_requirements.py --base-ref origin/main、--handoff,也未能运行 git diff --check;已用 PowerShell 检查本 JSON 必填键、改动文本无行尾空白。须在入库环境补跑。",
"未验证:真机软键盘与容器 240ms 收缩观感、iOS/macOS Safari(mask-composite 可能退化)、Firefox、真实 GPU 帧率与低端手机;本机为无头 Edge 软件光栅。页面隐藏暂停已实现但无头环境未实测。",
"emil-skills 在本运行时不可用,未能查阅 animate/MAIN.md、review-animations/MAIN.md 与 STANDARDS.md;按本卡第 6 条明确约束执行。",
"完整生活/回忆/创建/管理状态、R1 个性成长、R2–R7 真实接入由主卡 HEL-797 按获批依赖推进;小样通过不等于项目完成。"
],
"requirement_changes": [
{
"approval_comment_id": "01a1668f-5810-7524-b72f-33574a32c7d6",
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"quote": "静态效果我认可",
"kind": "交流静态视觉基准的更新",
"affected_ids": ["VIS-011"],
"old": "VIS-011:人物平时作为空间中独立视窗;交流时自然放大。",
"new": "交流状态(靛蓝月光、电脑 1600×1000)的获认可静态画面改为 HEL-798 共同空间样图:无独立人物框,环境连续到四边,前景桌沿/茶杯/烛灯,中间是她,远处雨夜窗景,对话在近处视线下方。",
"scope": "仅交流状态静图。相处状态、过渡与动态、雾光、手机、真实人物动态和完整功能仍待验收;不永久取消 VIS-011 在其他状态中的原规则,不把完整前端锁定为全屏卧室照片或视频(CG-031)。",
"context_comment_ids": ["01a16650-0355-72c4-b291-f1b4494cde91", "01a16654-5fc5-74be-a704-99b76b48b0b1", "01a16655-fe53-73a5-905f-4c9e71a1ed30", "01a16659-fdfa-72ce-a4a5-801e0762c0a9"],
"evidence_file": "prototypes/hel-799/records/comment-evidence-shared-space-20261023.json(入库时放入 docs/requirements/sources/)",
"image_sha256": "a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a"
}
],
"verification": [
"build.ps1 本轮实际运行,生成离线 index.html(662,233 字节,无残留占位符)。",
"evidence/verification.json:tools/verify.ps1 本轮在无头 Edge 用 CDP 真实鼠标、键盘事件执行 29 项检查,29/29 通过。",
"evidence/media-candidate/:候选容器 390×844(月光/雾光、相处/交流)、320×568、390×430 键盘态截图,现行整屏 390 与键盘态对照截图,进出中途帧 m390-enter-mid.png;DOM 实测数字见 README 续作表;进入 200ms 时镜头 0.47、无黑帧,退出与 Esc 后有限动画 0。",
"evidence/walkthrough-desktop-v2.webm(37 秒,1440×900)与 evidence/media-candidate/walkthrough-mobile-media-candidate.webm(37 秒,390×844)本轮录制,并各有抽帧拼图;录屏圆圈为点击位置标记,非小样内容。原运行录屏仍在原稿打包中。",
"masters/ 7 张母版本轮导出(tier 0、无界面),逐张目视:含前景版与获认可构图一致;无前景底板与 16:9 底板露出书与居家裤(已列 deferred)。16:9 底板脸中心实测 (0.540, 0.317)。",
"未运行 Python 需求检查与 git diff --check(见 deferred)。"
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,42 @@
[
{
"id": "01a16650-0355-72c4-b291-f1b4494cde91",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "主要是A和B,但是之前没注意到C的问题,你一说倒是有点",
"created_at": "2026-10-23T01:11:22Z",
"parent_id": "01a1664d-f242-71fa-a4ba-295b6c347478"
},
{
"id": "01a16654-5fc5-74be-a704-99b76b48b0b1",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "对就是B",
"created_at": "2026-10-23T01:16:08Z",
"parent_id": "01a1664d-f242-71fa-a4ba-295b6c347478"
},
{
"id": "01a16655-fe53-73a5-905f-4c9e71a1ed30",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "这个我不太能想象得到,我和她待在同一空间是什么概念",
"created_at": "2026-10-23T01:17:54Z",
"parent_id": "01a1664d-f242-71fa-a4ba-295b6c347478"
},
{
"id": "01a16659-fdfa-72ce-a4a5-801e0762c0a9",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "这个能否现在就做个样图让我看一下",
"created_at": "2026-10-23T01:22:16Z",
"parent_id": "01a1664d-f242-71fa-a4ba-295b6c347478"
},
{
"id": "01a1668f-5810-7524-b72f-33574a32c7d6",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "静态效果我认可",
"created_at": "2026-10-23T02:20:32Z",
"parent_id": null
}
]
@@ -0,0 +1,46 @@
## 交流静态视觉基准的更新(2026-10-23)
视觉总监在 HEL-799 整理,供总管入库核验。本记录只写明用户已经说过的话及其适用范围,不改动任何已有来源文件,也不改写 `current.md`、`baseline.json` 的现行条文。
### 用户原话(主卡 HEL-797,作者白栖知 `e5c6c938-db48-4ff9-954f-4fb8b063a132`)
原文逐字保存在同目录 `comment-evidence-shared-space-20261023.json`。
| 评论 | 时间(UTC) | 原话 | 回应的问题 |
| --- | --- | --- | --- |
| `01a16650-0355-72c4-b291-f1b4494cde91` | 2026-10-23 01:11 | 主要是A和B,但是之前没注意到C的问题,你一说倒是有点 | 总管问最妨碍沉浸的是哪种感觉:A 空间太空泛;B 和她有距离,像窗口里的照片;C 操作出戏 |
| `01a16654-5fc5-74be-a704-99b76b48b0b1` | 01:16 | 对就是B | 总管问:她即使能眨眼、抬头、回应,但仍在人物窗口里,是否主要的不满意还在 |
| `01a16655-fe53-73a5-905f-4c9e71a1ed30` | 01:17 | 这个我不太能想象得到,我和她待在同一空间是什么概念 | 总管问更接近「同一个空间」还是「通向她的世界」 |
| `01a16659-fdfa-72ce-a4a5-801e0762c0a9` | 01:22 | 这个能否现在就做个样图让我看一下 | 总管用第一人称镜头解释「同一空间」后 |
| `01a1668f-5810-7524-b72f-33574a32c7d6` | 02:20 | 静态效果我认可 | 总管交付 HEL-798 样图(评论 `01a16663-3ad2-727c-8888-e116eca13116`),问是否更像「自己坐在她面前」 |
问题原文由总管撰写,在主卡根线程 `01a1664d-f242-71fa-a4ba-295b6c347478` 内,不属于用户原话。
### 被认可的图
- 文件:`hel-798-face-to-face-moon.png`,1600×1000,SHA256 `a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a`。
- 主卡附件 `01a16663-3a5a-76eb-ac3c-1ef93165617c`(随交付评论 `01a16663-3ad2-727c-8888-e116eca13116`);HEL-799 卡附件 `01a16691-b0f0-73a9-aae4-9aba2146899c`,两者字节相同。
- 同一评论另附的标注图 `hel-798-face-to-face-annotated.png` 和新旧对照图 `hel-798-compare-pr8.png` 是说明材料。
- 作者:视觉总监(HEL-798)。源码在 `prototypes/hel-799/reference/hel-798-static/`。
### 旧要求、新认可与适用范围
| | 内容 |
| --- | --- |
| 旧(VIS-011,v1.8 仍有效) | 「人物平时作为空间中独立视窗;交流时自然放大。已认可的人物位置、分量与首页文字分量沿用。」来源 `01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。 |
| 新认可 | **交流状态、靛蓝月光、电脑 1600×1000 的静态画面**:不再用独立人物框;她身边的环境连续铺到屏幕四边;前景有你这一侧的圆桌沿、茶杯和烛灯,中间是她,远处是雨夜窗景;当前一两句对话落在近处、视线下方,底部保留输入和此刻/交谈/日子/设置导航。 |
| 适用范围 | 仅以上交流状态静图。用户认可的是「静态效果」。 |
| 不在范围内(仍待验收) | 相处(平时)状态的构图;进入与退出交流的过渡和整体动态;蓝紫雾光主题;手机版;真实人物动态、眨眼、口型与语音;生活、回忆、创建、管理等完整功能的最终页面。 |
| 不能推出的结论 | 不能据此永久取消 VIS-011 在全部状态中的原规则;不能把完整前端锁定为全屏卧室照片或视频(CG-031 继续有效);不能视为整套界面或任何功能已经验收。 |
### 对其他要求的影响
- VIS-001、VIS-002、VIS-006、VIS-013:这张图是「两人的数字空间」「空间深度」「交流以人物为主、当前一两句」的一种获认可呈现。
- CG-004「交谈时允许自然切近」:图只证明切近后的静态画面,切近过程要在 HEL-799 动态小样中另行验收。
- 其余已确认要求不变。
### 入库建议(由总管决定,视觉总监未改动正式基准)
1. 将 `comment-evidence-shared-space-20261023.json` 加入 `docs/requirements/sources/`,并在 `source-manifest.json` 追加它的 SHA256;历史来源不动。
2. 如决定升版为 v1.9:在 `changes.json` / `changes.md` 追加一条,`approval_comment_id` 为 `01a1668f-5810-7524-b72f-33574a32c7d6`,`affected_ids` 至少含 `VIS-011`,摘要写「交流状态静态视觉基准更新为 HEL-798 共同空间样图」,影响写明上表的适用范围与不在范围内;VIS-011 的结论原文保留,只在边界中补一句「交流状态静图已按上述评论更新,平时状态、过渡、雾光、手机待验收」,`current.md` 与 `baseline.json` 同步;`coverage.json` 的版本跟着改。
3. 入库后运行 `python3 scripts/check_requirements.py --base-ref origin/main` 及 `--handoff prototypes/hel-799/handoff-hel-799.json`。本运行环境没有 git 和 Python,这两项都未执行。
Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 850 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1,314 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>对坐 · 共同空间样图</title>
<style>
:root {
--accent: #a9dcff;
--accent-glow: #96d2ff55;
--text-1: #eef2ff;
--text-2: #b9c6e6;
--text-3: #94a5c8;
--glass: #16244e66;
--glass-line: #b8d4ff26;
--user: #d7e2ff;
--warm: #ffcf9a;
--font: "PingFang SC", "HarmonyOS Sans SC", "MiSans", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 1600px; height: 1000px; overflow: hidden; background: #050918; font-family: var(--font); color: var(--text-1); }
.stage { position: relative; width: 1600px; height: 1000px; overflow: hidden; }
/* 她那一侧:参考静帧,靛蓝月光调色 */
.her {
position: absolute; left: -118px; top: -10px; width: 1792px; height: 1008px;
background: url(assets/her-talk.jpg) center / cover no-repeat;
filter: saturate(.92) brightness(.9) contrast(1.04);
}
/* 窗外换成月夜:只染窗与远景,她和灯保持暖 */
.night {
position: absolute; inset: 0; pointer-events: none;
background:
radial-gradient(ellipse 420px 520px at 905px 360px, transparent 0 55%, #0000 100%),
linear-gradient(100deg, #1c2f7fcc 0%, #1b2c78aa 28%, #1a2a7044 46%, transparent 60%);
mix-blend-mode: multiply;
}
.night2 {
position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(180deg, #24357ccc 0%, #24357c66 18%, transparent 34%);
mix-blend-mode: color;
-webkit-mask: linear-gradient(90deg, #000 0 40%, transparent 56%);
}
.moon {
position: absolute; left: 382px; top: 70px; width: 44px; height: 44px; border-radius: 50%;
background: radial-gradient(circle at 42% 40%, #fbfdff 0%, #e4ecff 55%, #c9d6f5 100%);
filter: blur(.6px); opacity: .92; box-shadow: 0 0 22px 6px #cfe0ff88, 0 0 90px 40px #8fb0ff33, 0 0 220px 120px #5f7fe022;
}
.beam {
position: absolute; left: 120px; top: 40px; width: 560px; height: 1040px; pointer-events: none;
background: linear-gradient(180deg, #a9c8ff22, #a9c8ff10 55%, transparent);
transform: skewX(18deg); filter: blur(30px); mix-blend-mode: screen;
}
/* 灯光从她那边一路落到你这边 */
.lampspill {
position: absolute; inset: 0; pointer-events: none; mix-blend-mode: screen;
background:
radial-gradient(ellipse 520px 380px at 1290px 40px, #ffcf8a30, transparent 70%),
radial-gradient(ellipse 760px 260px at 1180px 900px, #ffb97a2a, transparent 70%);
}
.vignette {
position: absolute; inset: 0; pointer-events: none;
background:
radial-gradient(ellipse 980px 720px at 860px 420px, transparent 52%, #050918aa 100%),
linear-gradient(180deg, #050918aa 0%, transparent 13%);
}
canvas { position: absolute; inset: 0; }
/* 界面 */
.top { position: absolute; left: 40px; right: 40px; top: 28px; display: flex; align-items: center; justify-content: space-between; }
.who { display: flex; align-items: baseline; gap: 12px; text-shadow: 0 1px 12px #050918; }
.who .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 10px var(--accent); align-self: center; }
.who b { font-size: 22px; font-weight: 400; letter-spacing: .04em; }
.who span { font-size: 13px; color: var(--text-2); letter-spacing: .04em; }
.meta { display: flex; align-items: center; gap: 12px; font-size: 12px; color: var(--text-2); }
.chip {
height: 30px; padding: 0 13px; display: inline-flex; align-items: center; gap: 7px; border-radius: 15px;
background: var(--glass); border: 1px solid var(--glass-line); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
}
.chip.demo { border-style: dashed; color: var(--text-3); }
.theme { padding: 3px; gap: 2px; }
.theme i { font-style: normal; height: 24px; padding: 0 11px; border-radius: 12px; display: inline-flex; align-items: center; gap: 6px; color: var(--text-3); }
.theme i.on { background: #a9dcff22; color: var(--text-1); box-shadow: inset 0 0 0 1px #a9dcff44; }
.theme i::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.theme i.on::before { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
.theme i.fog::before { background: #ebbcff; }
.end { color: var(--text-1); }
.talk { position: absolute; left: 0; right: 0; top: 816px; text-align: center; }
.line { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.tag { font-size: 12px; letter-spacing: .08em; color: var(--text-2); display: inline-flex; align-items: center; gap: 7px; }
.tag .me { width: 7px; height: 7px; border-radius: 50%; border: 1px solid var(--user); }
.tag .she { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 8px var(--accent); }
.prev { opacity: .6; margin-bottom: 6px; flex-direction: row; justify-content: center; gap: 10px; }
.prev p { font-size: 15px; color: var(--user); font-weight: 300; text-shadow: 0 1px 10px #050918; }
.cur { gap: 4px; }
.cur p {
font-size: 23px; font-weight: 350; line-height: 1.5; letter-spacing: .02em; max-width: 900px;
text-shadow: 0 2px 18px #050918, 0 0 2px #05091899;
}
.meter { width: 72px; height: 2px; border-radius: 1px; background: #ffffff1c; margin-top: 8px; overflow: hidden; }
.meter::before { content: ""; display: block; width: 38%; height: 100%; background: var(--accent); box-shadow: 0 0 6px var(--accent); }
.input {
position: absolute; left: 50%; top: 914px; transform: translateX(-50%); width: 640px; height: 50px; border-radius: 25px;
display: flex; align-items: center; gap: 12px; padding: 0 6px 0 18px;
background: linear-gradient(90deg, #16244e70, #1c264c70 70%, #3a2c3a60);
border: 1px solid var(--glass-line); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
box-shadow: 0 10px 40px #02051466, inset 0 1px 0 #ffffff12;
}
.input .ph { flex: 1; font-size: 15px; color: var(--text-3); }
.ico { width: 18px; height: 18px; stroke: var(--text-2); fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.btn { width: 38px; height: 38px; border-radius: 50%; display: grid; place-items: center; }
.btn.mic { background: var(--accent); box-shadow: 0 0 18px var(--accent-glow); }
.btn.mic .ico { stroke: #0b1534; }
.nav {
position: absolute; left: 50%; top: 966px; transform: translateX(-50%); display: flex; gap: 4px; padding: 3px;
border-radius: 16px; background: #0b153455; border: 1px solid #b8d4ff1a; backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
}
.nav a { height: 26px; padding: 0 12px; border-radius: 13px; display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-3); letter-spacing: .04em; }
.nav a .ico { width: 14px; height: 14px; stroke: var(--text-3); }
.nav a.on { color: var(--text-1); background: #a9dcff1f; box-shadow: 0 0 14px #96d2ff22; }
.nav a.on .ico { stroke: var(--accent); }
.ref { position: absolute; right: 40px; bottom: 26px; font-size: 11px; color: var(--text-3); opacity: .8; letter-spacing: .04em; }
/* 标注版 */
.notes { display: none; }
body.annot .notes { display: block; }
.note { position: absolute; display: flex; align-items: center; gap: 10px; font-size: 14px; color: #fff; }
.note b { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: #ffd27a; color: #1a1430; font-size: 14px; font-weight: 700; box-shadow: 0 0 0 4px #ffd27a33; }
.note span { padding: 6px 12px; border-radius: 8px; background: #0b1534e6; border: 1px solid #ffd27a88; line-height: 1.5; }
.band { position: absolute; left: 0; right: 0; border-top: 1px dashed #ffd27a99; }
</style>
</head>
<body>
<div class="stage">
<div class="her"></div>
<div class="night"></div>
<div class="night2"></div>
<div class="beam"></div>
<div class="moon"></div>
<canvas id="rain" width="1600" height="1000"></canvas>
<div class="lampspill"></div>
<canvas id="near" width="1600" height="1000"></canvas>
<div class="vignette"></div>
<div class="top">
<div class="who"><i class="dot"></i><b>晚晚</b><span>在窗边看书 · 抬头看着你</span></div>
<div class="meta">
<span class="chip demo">独立样图 · 演示内容</span>
<span>上海 · 小雨 14° 示例</span>
<span class="chip theme"><i class="on">月光</i><i class="fog">雾光</i></span>
<span class="chip end"><svg class="ico" viewBox="0 0 24 24" style="width:14px;height:14px"><path d="M6 6l12 12M18 6L6 18"/></svg>结束交谈</span>
</div>
</div>
<div class="talk">
<div class="line prev">
<span class="tag"><i class="me"></i>你</span>
<p>外面下雨了,你那边听得见吗?</p>
</div>
<div class="line cur">
<span class="tag"><i class="she"></i>晚晚</span>
<p>听得见,打在玻璃上很轻。你也坐一会儿吧,这一页我念给你听。</p>
<i class="meter"></i>
</div>
</div>
<div class="input">
<svg class="ico" viewBox="0 0 24 24"><path d="M4 7h16M4 12h10M4 17h13"/></svg>
<span class="ph">想和她说点什么…</span>
<span class="btn"><svg class="ico" viewBox="0 0 24 24"><path d="M12 19V5M6 11l6-6 6 6"/></svg></span>
<span class="btn mic"><svg class="ico" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></svg></span>
</div>
<nav class="nav">
<a><svg class="ico" viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/></svg>此刻</a>
<a class="on"><svg class="ico" viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 1 3.5 6.6L4 20l1.2-3.6A8 8 0 0 1 4 12z"/></svg>交谈</a>
<a><svg class="ico" viewBox="0 0 24 24"><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M9 8h6M9 12h6M9 16h4"/></svg>日子</a>
<a><svg class="ico" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1"/></svg>设置</a>
</nav>
<div class="ref">人物为参考静帧 · 未接入真实模型与动态</div>
<div class="notes">
<div class="band" style="top:250px"></div>
<div class="band" style="top:780px"></div>
<div class="note" style="left:470px;top:150px"><b>3</b><span>远处:窗外雨夜、月光和城市<br>月光与雨从这里进到房间</span></div>
<div class="note" style="left:1150px;top:470px"><b>2</b><span>中间:她就坐在对面<br>与你视线同高,抬头看你</span></div>
<div class="note" style="left:60px;top:700px"><b>1</b><span>近处:你这一侧的小桌和茶杯<br>她的灯光一直落到你面前</span></div>
<div class="note" style="left:1200px;top:800px"><b>✦</b><span>对话在视线下方、落在你这边<br>只留当前一两句</span></div>
</div>
</div>
<script>
// 窗外细雨:只落在左侧窗玻璃范围内
(function () {
const c = document.getElementById('rain').getContext('2d');
c.save();
c.beginPath(); c.rect(0, 0, 640, 600); c.clip();
let s = 7;
const rnd = () => (s = (s * 16807) % 2147483647) / 2147483647;
for (let i = 0; i < 260; i++) {
const x = rnd() * 700, y = rnd() * 620, l = 10 + rnd() * 22, a = .08 + rnd() * .16;
c.strokeStyle = `rgba(190,210,255,${a})`; c.lineWidth = .8;
c.beginPath(); c.moveTo(x, y); c.lineTo(x - l * .18, y + l); c.stroke();
}
// 玻璃上的水珠
for (let i = 0; i < 70; i++) {
const x = rnd() * 640, y = rnd() * 560, r = 1 + rnd() * 2.4;
const g = c.createRadialGradient(x - r * .3, y - r * .3, 0, x, y, r);
g.addColorStop(0, 'rgba(230,240,255,.45)'); g.addColorStop(1, 'rgba(160,190,255,0)');
c.fillStyle = g; c.beginPath(); c.arc(x, y, r, 0, 7); c.fill();
}
c.restore();
})();
// 你这一侧:小圆桌边缘、茶杯、一盏小烛灯、近景光斑(前景虚化)
(function () {
const c = document.getElementById('near').getContext('2d');
const W = 1600, H = 1000;
// 桌面:大圆桌的一段弧,离你最近
c.save();
c.filter = 'blur(2.5px)';
c.beginPath();
c.ellipse(800, 1585, 1300, 775, 0, Math.PI, 2 * Math.PI);
c.lineTo(W + 100, H + 100); c.lineTo(-100, H + 100); c.closePath();
const top = c.createLinearGradient(0, 780, 0, H);
top.addColorStop(0, '#3b2c30'); top.addColorStop(.3, '#221a24'); top.addColorStop(1, '#0b0a14');
c.fillStyle = top; c.fill();
c.clip();
// 桌面上的月光(左,冷)与灯光(右,暖)反射
let g = c.createRadialGradient(300, 860, 0, 300, 860, 520);
g.addColorStop(0, 'rgba(150,180,255,.22)'); g.addColorStop(1, 'rgba(150,180,255,0)');
c.fillStyle = g; c.fillRect(0, 760, W, 260);
g = c.createRadialGradient(1260, 840, 0, 1260, 840, 560);
g.addColorStop(0, 'rgba(255,190,130,.26)'); g.addColorStop(1, 'rgba(255,190,130,0)');
c.fillStyle = g; c.fillRect(0, 760, W, 260);
// 她那边烛光和花在桌面的倒影
g = c.createRadialGradient(300, 830, 0, 300, 830, 60);
g.addColorStop(0, 'rgba(255,214,150,.25)'); g.addColorStop(1, 'rgba(255,214,150,0)');
c.fillStyle = g; c.fillRect(200, 790, 200, 90);
// 木纹
c.globalAlpha = .06;
for (let i = 0; i < 26; i++) {
c.strokeStyle = i % 2 ? '#c9b8ff' : '#ffd9b0'; c.lineWidth = 1;
c.beginPath(); c.ellipse(800, 1585, 1300 - i * 9, 775 - i * 6 - 14, 0, Math.PI * 1.08, Math.PI * 1.92); c.stroke();
}
c.restore();
// 桌沿受光的细边:左冷右暖
c.save();
c.filter = 'blur(1px)';
const rim = c.createLinearGradient(0, 0, W, 0);
rim.addColorStop(0, 'rgba(170,200,255,.05)'); rim.addColorStop(.25, 'rgba(170,200,255,.45)');
rim.addColorStop(.55, 'rgba(240,230,255,.3)'); rim.addColorStop(.8, 'rgba(255,205,150,.55)'); rim.addColorStop(1, 'rgba(255,205,150,.08)');
c.strokeStyle = rim; c.lineWidth = 2;
c.beginPath(); c.ellipse(800, 1585, 1300, 775, 0, Math.PI * 1.03, Math.PI * 1.97); c.stroke();
c.restore();
// 你的茶杯(最近,最虚)
c.save();
c.filter = 'blur(5px)';
c.fillStyle = '#0d0c18';
c.beginPath(); c.ellipse(205, 985, 150, 34, 0, 0, 7); c.fill(); // 杯碟影
const cup = c.createLinearGradient(100, 0, 320, 0);
cup.addColorStop(0, '#5a6aa0'); cup.addColorStop(.35, '#d9def2'); cup.addColorStop(.7, '#9ea6c8'); cup.addColorStop(1, '#3a3f66');
c.fillStyle = cup;
c.beginPath(); c.moveTo(108, 830); c.bezierCurveTo(108, 950, 140, 985, 210, 985); c.bezierCurveTo(280, 985, 312, 950, 312, 830); c.closePath(); c.fill();
c.strokeStyle = '#aeb6d8'; c.lineWidth = 16;
c.beginPath(); c.arc(320, 885, 34, -Math.PI / 2.2, Math.PI / 2.2); c.stroke();
c.fillStyle = '#3a2416'; c.beginPath(); c.ellipse(210, 830, 102, 18, 0, 0, 7); c.fill();
c.fillStyle = 'rgba(255,200,140,.35)'; c.beginPath(); c.ellipse(240, 828, 46, 7, 0, 0, 7); c.fill();
c.restore();
// 热气
c.save();
c.filter = 'blur(10px)';
c.strokeStyle = 'rgba(210,225,255,.16)'; c.lineWidth = 16; c.lineCap = 'round';
c.beginPath(); c.moveTo(195, 815); c.bezierCurveTo(160, 760, 250, 720, 210, 650); c.stroke();
c.beginPath(); c.moveTo(235, 812); c.bezierCurveTo(270, 760, 200, 710, 250, 640); c.stroke();
c.restore();
// 你这边的小烛灯,和她身边那支相呼应
c.save();
c.filter = 'blur(3.5px)';
const glass = c.createLinearGradient(1350, 0, 1450, 0);
glass.addColorStop(0, 'rgba(255,190,130,.25)'); glass.addColorStop(.5, 'rgba(255,225,180,.55)'); glass.addColorStop(1, 'rgba(255,170,110,.22)');
c.fillStyle = glass; c.beginPath(); c.moveTo(1356, 850); c.lineTo(1444, 850); c.lineTo(1440, 952); c.quadraticCurveTo(1400, 966, 1360, 952); c.closePath(); c.fill(); c.fillStyle = 'rgba(255,240,215,.35)'; c.beginPath(); c.ellipse(1400, 850, 44, 9, 0, 0, 7); c.fill();
c.restore();
c.save();
c.globalCompositeOperation = 'screen';
g = c.createRadialGradient(1400, 850, 0, 1400, 850, 200);
g.addColorStop(0, 'rgba(255,220,160,.85)'); g.addColorStop(.12, 'rgba(255,190,120,.45)'); g.addColorStop(1, 'rgba(255,170,100,0)');
c.fillStyle = g; c.fillRect(1180, 640, 440, 360);
c.restore();
// 近景光斑(景深)
c.save();
c.globalCompositeOperation = 'screen';
const bok = [[60, 120, 70, '160,190,255', .1], [1530, 300, 90, '255,200,140', .09], [1480, 980, 120, '255,190,130', .1],
[560, 990, 80, '150,180,255', .08], [30, 560, 60, '150,180,255', .08]];
for (const [x, y, r, col, a] of bok) {
g = c.createRadialGradient(x, y, r * .6, x, y, r);
g.addColorStop(0, `rgba(${col},${a})`); g.addColorStop(.9, `rgba(${col},${a * .8})`); g.addColorStop(1, `rgba(${col},0)`);
c.fillStyle = g; c.beginPath(); c.arc(x, y, r, 0, 7); c.fill();
}
c.restore();
})();
if (location.hash === '#annot') document.body.classList.add('annot');
</script>
</body>
</html>
+163
View File
@@ -0,0 +1,163 @@
'use strict';
// ── Ambient life of the room (drawn, never taken from her photo). ──
// Rain: falls only inside each still's window panes; city windows blink; a few drops slide down the glass.
const Rain = (() => {
const html = document.documentElement;
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
const still = () => reduced.matches || html.classList.contains('no-motion');
const COLORS = {moon: [190, 210, 255], fog: [228, 204, 255]};
// Plate px. clip: the glass; city: where lit windows may blink.
const CFG = {
room: {clip: [655, 0, 545, 545], city: [660, 215, 530, 320], n: 110, seed: 11},
talk: {clip: [0, 0, 640, 600], city: [0, 200, 640, 360], n: 150, seed: 7}
};
const S = {};
let mix = 0, mixFrom = 0, mixTo = 0, mixStart = 0, visible = {room: true, talk: false}, dirty = true, lastDraw = 0, res = 0;
const rng = seed => () => (seed = (seed * 16807) % 2147483647) / 2147483647;
function build(name, scale) {
const c = CFG[name], cv = document.querySelector(`#plate-${name} canvas.rain`), r = rng(c.seed);
cv.width = 1600 * scale; cv.height = 1000 * scale;
const ctx = cv.getContext('2d');
const [x, y, w, h] = c.clip;
const streaks = Array.from({length: c.n}, () => ({x: x + r() * (w + 60), y: y + r() * h, len: 10 + r() * 24, spd: 520 + r() * 420, a: .07 + r() * .17}));
const lights = Array.from({length: 30}, () => ({x: c.city[0] + r() * c.city[2], y: c.city[1] + r() * c.city[3], p: r() * 6.28, f: .25 + r() * .7, warm: r() > .35}));
const slides = Array.from({length: 4}, () => ({x: x + r() * w, y: y + r() * h, spd: 6 + r() * 14, r: 1.6 + r() * 1.4}));
// Static droplets on the glass, rendered once.
const drops = document.createElement('canvas'); drops.width = cv.width; drops.height = cv.height;
const d = drops.getContext('2d'); d.scale(scale, scale);
for (let i = 0; i < 70; i++) {
const px = x + r() * w, py = y + r() * h, rr = 1 + r() * 2.4, g = d.createRadialGradient(px - rr * .3, py - rr * .3, 0, px, py, rr);
g.addColorStop(0, 'rgba(230,240,255,.45)'); g.addColorStop(1, 'rgba(160,190,255,0)');
d.fillStyle = g; d.beginPath(); d.arc(px, py, rr, 0, 7); d.fill();
}
S[name] = {cv, ctx, c, streaks, lights, slides, drops, scale};
}
function paint(name, dt, now) {
const s = S[name]; if (!s) return;
const {ctx, c, scale} = s, [x, y, w, h] = c.clip;
const col = COLORS.moon.map((m, i) => Math.round(m + (COLORS.fog[i] - m) * mix));
ctx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.clearRect(x - 2, y - 2, w + 4, h + 4);
ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
// City windows: slow blink, warm or cool.
for (const l of s.lights) {
const a = Math.max(0, Math.sin(l.p + now / 1000 * l.f)) * .55;
if (a < .02) continue;
ctx.fillStyle = l.warm ? `rgba(255,214,150,${a.toFixed(3)})` : `rgba(${col[0]},${col[1]},${col[2]},${(a * .8).toFixed(3)})`;
ctx.fillRect(l.x, l.y, 2.2, 2.6);
}
ctx.lineWidth = .8; ctx.lineCap = 'round';
for (const k of s.streaks) {
k.y += k.spd * dt; k.x -= k.spd * dt * .18;
if (k.y > y + h) { k.y = y - k.len; k.x = x + Math.random() * (w + 60); }
ctx.strokeStyle = `rgba(${col[0]},${col[1]},${col[2]},${k.a.toFixed(3)})`;
ctx.beginPath(); ctx.moveTo(k.x, k.y); ctx.lineTo(k.x - k.len * .18, k.y + k.len); ctx.stroke();
}
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.drawImage(s.drops, 0, 0); ctx.setTransform(scale, 0, 0, scale, 0, 0);
for (const q of s.slides) {
q.y += q.spd * dt; if (q.y > y + h) { q.y = y; q.x = x + Math.random() * w; }
ctx.fillStyle = 'rgba(225,236,255,.35)'; ctx.beginPath(); ctx.arc(q.x, q.y, q.r, 0, 7); ctx.fill();
ctx.fillStyle = 'rgba(225,236,255,.08)'; ctx.fillRect(q.x - .5, q.y - 26, 1, 26);
}
ctx.restore();
}
function draw(now, tier) {
const want = tier > 0 ? .5 : 1;
if (want !== res) { res = want; build('room', res); build('talk', res); dirty = true; }
if (mixStart) { const k = Math.min(1, (now - mixStart) / 1000); mix = mixFrom + (mixTo - mixFrom) * (k * k * (3 - 2 * k)); if (k >= 1) mixStart = 0; dirty = true; }
if (still()) { if (!dirty) return; dirty = false; paint('room', 0, 0); paint('talk', 0, 0); return; }
if (document.hidden) return;
if (tier > 1 && now - lastDraw < 32) return; // ~30fps on slow devices
const dt = lastDraw ? Math.min(.05, (now - lastDraw) / 1000) : 0; lastDraw = now;
if (visible.room) paint('room', dt, now);
if (visible.talk) paint('talk', dt, now);
}
function view(cam, talkOpacity) { visible.room = talkOpacity < .998; visible.talk = talkOpacity > .002; dirty = true; }
function setTheme(name, instant) {
mixTo = name === 'fog' ? 1 : 0;
if (instant) { mix = mixTo; mixStart = 0; dirty = true; return; }
mixFrom = mix; mixStart = performance.now();
}
document.addEventListener('visibilitychange', () => { lastDraw = 0; });
return {draw, view, setTheme};
})();
// Your side of the table: drawn per theme at the viewport's own size, so the cup and candle never stretch.
const Near = (() => {
const host = document.getElementById('near');
const PAL = {
moon: {top: ['#3b2c30', '#221a24', '#0b0a14'], cool: '150,180,255', warm: '255,190,130', grain: ['#c9b8ff', '#ffd9b0'], rim: ['170,200,255', '240,230,255', '255,205,150'], cup: ['#5a6aa0', '#d9def2', '#9ea6c8', '#3a3f66'], handle: '#aeb6d8', bokeh: ['160,190,255', '255,200,140']},
fog: {top: ['#3a2a3e', '#231a30', '#0e0a18'], cool: '200,170,255', warm: '255,180,175', grain: ['#e2c4ff', '#ffd2d8'], rim: ['205,180,255', '246,226,255', '255,192,176'], cup: ['#7a62a8', '#e6dcf2', '#b0a2c8', '#4a3a66'], handle: '#c2b4d8', bokeh: ['205,170,255', '255,186,200']}
};
function anchors(W, H) {
const sx = W / 1600, sy = H / 1000, u = Math.min(1.35, Math.max(.42, Math.min(sx, sy))), phone = W <= 720 || W / H < .9;
return {sx, sy, u, phone, cup: phone ? [34, H - 8] : [210 * sx, 985 * sy], candle: phone ? [W - 26, H - 30] : [1400 * sx, 966 * sy]};
}
function paint(cv, pal, W, H, dpr) {
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
const c = cv.getContext('2d'), A = anchors(W, H), {sx, sy, u} = A, px = n => n * dpr;
c.setTransform(dpr, 0, 0, dpr, 0, 0);
const ex = W / 2, ey = 1585 * sy, rx = 1300 * Math.max(sx, .32), ry = 775 * sy;
// Table top: one arc of a large round table, the part nearest to you.
c.save(); c.filter = `blur(${px(2.5 * u)}px)`;
c.beginPath(); c.ellipse(ex, ey, rx, ry, 0, Math.PI, 2 * Math.PI); c.lineTo(W + 100, H + 100); c.lineTo(-100, H + 100); c.closePath();
const top = c.createLinearGradient(0, ey - ry - 30 * sy, 0, H);
top.addColorStop(0, pal.top[0]); top.addColorStop(.3, pal.top[1]); top.addColorStop(1, pal.top[2]);
c.fillStyle = top; c.fill(); c.clip();
let g = c.createRadialGradient(300 * sx, 860 * sy, 0, 300 * sx, 860 * sy, 520 * u);
g.addColorStop(0, `rgba(${pal.cool},.22)`); g.addColorStop(1, `rgba(${pal.cool},0)`); c.fillStyle = g; c.fillRect(0, 0, W, H);
g = c.createRadialGradient(1260 * sx, 840 * sy, 0, 1260 * sx, 840 * sy, 560 * u);
g.addColorStop(0, `rgba(${pal.warm},.26)`); g.addColorStop(1, `rgba(${pal.warm},0)`); c.fillStyle = g; c.fillRect(0, 0, W, H);
c.globalAlpha = .06;
for (let i = 0; i < 26; i++) {
c.strokeStyle = pal.grain[i % 2]; c.lineWidth = 1;
c.beginPath(); c.ellipse(ex, ey, Math.max(10, rx - i * 9 * sx), Math.max(10, ry - (i * 6 + 14) * sy), 0, Math.PI * 1.08, Math.PI * 1.92); c.stroke();
}
c.restore();
c.save(); c.filter = `blur(${px(1)}px)`;
const rim = c.createLinearGradient(0, 0, W, 0);
rim.addColorStop(0, `rgba(${pal.rim[0]},.05)`); rim.addColorStop(.25, `rgba(${pal.rim[0]},.45)`);
rim.addColorStop(.55, `rgba(${pal.rim[1]},.3)`); rim.addColorStop(.8, `rgba(${pal.rim[2]},.55)`); rim.addColorStop(1, `rgba(${pal.rim[2]},.08)`);
c.strokeStyle = rim; c.lineWidth = 2;
c.beginPath(); c.ellipse(ex, ey, rx, ry, 0, Math.PI * 1.03, Math.PI * 1.97); c.stroke();
c.restore();
// Your cup (nearest, most out of focus). Drawn in HEL-798 units around its base.
c.save(); c.translate(A.cup[0], A.cup[1]); c.scale(u, u); c.translate(-210, -985); c.filter = `blur(${px(5 * u)}px)`;
c.fillStyle = '#0d0c18'; c.beginPath(); c.ellipse(205, 985, 150, 34, 0, 0, 7); c.fill();
const cup = c.createLinearGradient(100, 0, 320, 0);
cup.addColorStop(0, pal.cup[0]); cup.addColorStop(.35, pal.cup[1]); cup.addColorStop(.7, pal.cup[2]); cup.addColorStop(1, pal.cup[3]);
c.fillStyle = cup; c.beginPath(); c.moveTo(108, 830); c.bezierCurveTo(108, 950, 140, 985, 210, 985); c.bezierCurveTo(280, 985, 312, 950, 312, 830); c.closePath(); c.fill();
c.strokeStyle = pal.handle; c.lineWidth = 16; c.beginPath(); c.arc(320, 885, 34, -Math.PI / 2.2, Math.PI / 2.2); c.stroke();
c.fillStyle = '#3a2416'; c.beginPath(); c.ellipse(210, 830, 102, 18, 0, 0, 7); c.fill();
c.fillStyle = 'rgba(255,200,140,.35)'; c.beginPath(); c.ellipse(240, 828, 46, 7, 0, 0, 7); c.fill();
c.restore();
// Your small candle, answering the one beside her.
c.save(); c.translate(A.candle[0], A.candle[1]); c.scale(u, u); c.translate(-1400, -966); c.filter = `blur(${px(3.5 * u)}px)`;
const glass = c.createLinearGradient(1350, 0, 1450, 0);
glass.addColorStop(0, 'rgba(255,190,130,.25)'); glass.addColorStop(.5, 'rgba(255,225,180,.55)'); glass.addColorStop(1, 'rgba(255,170,110,.22)');
c.fillStyle = glass; c.beginPath(); c.moveTo(1356, 850); c.lineTo(1444, 850); c.lineTo(1440, 952); c.quadraticCurveTo(1400, 966, 1360, 952); c.closePath(); c.fill();
c.fillStyle = 'rgba(255,240,215,.35)'; c.beginPath(); c.ellipse(1400, 850, 44, 9, 0, 0, 7); c.fill();
c.restore();
// Near bokeh (depth of field).
c.save(); c.globalCompositeOperation = 'screen';
const bok = [[60, 120, 70, 0, .1], [1530, 300, 90, 1, .09], [1480, 980, 120, 1, .1], [560, 990, 80, 0, .08], [30, 560, 60, 0, .08]];
for (const [x, y, r0, k, a] of bok) {
const r = r0 * u, X = x * sx, Y = y * sy, col = pal.bokeh[k];
g = c.createRadialGradient(X, Y, r * .6, X, Y, r);
g.addColorStop(0, `rgba(${col},${a})`); g.addColorStop(.9, `rgba(${col},${a * .8})`); g.addColorStop(1, `rgba(${col},0)`);
c.fillStyle = g; c.beginPath(); c.arc(X, Y, r, 0, 7); c.fill();
}
c.restore();
return A;
}
function draw() {
const W = innerWidth, H = innerHeight, tier = +(document.documentElement.dataset.tier || 0);
const dpr = Math.min(tier > 0 ? 1 : 2, window.devicePixelRatio || 1);
let A;
for (const cv of host.querySelectorAll('canvas')) A = paint(cv, PAL[cv.dataset.themeLayer], W, H, dpr);
host.style.setProperty('--u', A.u.toFixed(3));
host.style.setProperty('--steam-x', (A.cup[0] + 5 * A.u).toFixed(1) + 'px'); host.style.setProperty('--steam-y', (A.cup[1] - 330 * A.u).toFixed(1) + 'px');
host.style.setProperty('--candle-x', A.candle[0].toFixed(1) + 'px'); host.style.setProperty('--candle-y', (A.candle[1] - 116 * A.u).toFixed(1) + 'px');
}
return {draw};
})();
+297
View File
@@ -0,0 +1,297 @@
/* HEL-799 · 对坐:共同空间动态小样
Based on the approved HEL-798 still (talk state, 1600×1000) and the HEL-793/795 tokens.
Motion budget: UI feedback 160ms · text 420–680ms · camera spring ≈0.85s · 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:850ms;
--view-h:100dvh;--view-top:0px;--safe-bottom:env(safe-area-inset-bottom,0px);
--stage-h:128px;--nav-space:52px;
}
html[data-theme="moon"]{--bg-0:#050918;--text-1:#eef2ff;--text-2:#b9c6e6;--text-3:#94a5c8;--accent:#a9dcff;--accent-soft:#a9dcff1f;--accent-glow:#96d2ff55;--on-accent:#0b1534;--user:#d7e2ff;--glass:#16244e66;--glass-read:#0b1534c4;--glass-line:#b8d4ff26;--surface:#0b1330;--input-a:#16244e70;--input-b:#1c264c70;--input-c:#3a2c3a60;--pool:#050918;--glass-blur:20px}
html[data-theme="fog"]{--bg-0:#0e0820;--text-1:#f6efff;--text-2:#dccbee;--text-3:#bba7d6;--accent:#ebbcff;--accent-soft:#ebbcff24;--accent-glow:#e2aaff5c;--on-accent:#2a0f40;--user:#f1e2ff;--glass:#36226666;--glass-read:#211444c8;--glass-line:#ecd8ff2e;--surface:#24154a;--input-a:#3a245e70;--input-b:#3a265470;--input-c:#5a2c5260;--pool:#0e0820;--glass-blur:28px}
*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--bg-0);transition:background-color .9s}
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:3px}
button svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;flex:none}
h1,h2,h3,p{margin:0}h2{font-size:22px;font-weight:350}h3{font-size:18px;font-weight:400}strong{font-weight:400}[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:40px;height:40px;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:12px 22px;border-radius:30px;box-shadow:0 0 30px var(--accent-glow),inset 0 1px 0 #ffffff60}
.small-link{color:var(--text-2);padding:10px 0;font-size:13px;text-align:left}
.eyebrow{font-size:12px;letter-spacing:.18em;color:var(--text-3);margin-bottom:14px}.quiet{color:var(--text-2);font-size:14px}.quiet span{margin:0 10px}.subtle{font-size:13px;color:var(--text-2)}
/* ── The room: two reference stills on one camera ── */
#scene{position:fixed;inset:0;overflow:hidden;background:#04060f}
.cam{position:absolute;inset:0;transition:transform 1.4s var(--ease-out)}
/* Each plate box covers its whole picture (so a mask never clips it to 1600×1000); .po is the 1600×1000 design origin. */
.plate{position:absolute;will-change:transform;contain:layout style}
#plate-room{left:-640px;top:-112px;width:2240px;height:1260px;transform-origin:640px 112px}
#plate-talk{left:-118px;top:-10px;width:1792px;height:1008px;transform-origin:118px 10px}
#plate-room .po{left:640px;top:112px}#plate-talk .po{left:118px;top:10px}
.po{position:absolute;width:1600px;height:1000px}
.plate .photo{position:absolute;max-width:none;user-select:none;-webkit-user-drag:none}
#plate-room .photo{left:-640px;top:-112px;width:2240px;height:1260px;filter:saturate(.9) brightness(.8) contrast(1.05)}
#plate-talk .photo{left:-118px;top:-10px;width:1792px;height:1008px;filter:saturate(.92) brightness(.9) contrast(1.04)}
#plate-talk{visibility:hidden}
.tint{position:absolute;inset:0;transition:opacity .9s var(--ease-in-out)}
.tint i,.po>i{position:absolute;pointer-events:none;display:block}
html[data-theme="moon"] .tint.fog,html[data-theme="fog"] .tint.moon{opacity:0}
/* Her side, close: the HEL-798 still, unchanged (stage px = plate px). */
#plate-talk .tint .night{inset:0;mix-blend-mode:multiply}
#plate-talk .tint .night::before{content:"";position:absolute;left:-118px;top:-10px;width:118px;bottom:0}
#plate-talk .tint .night2{inset:-10px 0 0;mix-blend-mode:color;-webkit-mask:linear-gradient(90deg,#000 0 40%,transparent 56%);mask:linear-gradient(90deg,#000 0 40%,transparent 56%)}
#plate-talk .tint .beam{left:120px;top:40px;width:560px;height:1040px;transform:skewX(18deg);filter:blur(30px);mix-blend-mode:screen}
#plate-talk .moon .night{background:linear-gradient(100deg,#1c2f7fcc 0%,#1b2c78aa 28%,#1a2a7044 46%,transparent 60%)}
#plate-talk .moon .night::before{background:#1c2f7fcc}
#plate-talk .moon .night2{background:linear-gradient(180deg,#24357ccc 0%,#24357c66 18%,transparent 34%)}
#plate-talk .moon .beam{background:linear-gradient(180deg,#a9c8ff22,#a9c8ff10 55%,transparent)}
#plate-talk .fog .night{background:linear-gradient(100deg,#3b2a7ccc 0%,#3a2874aa 28%,#38266a44 46%,transparent 60%)}
#plate-talk .fog .night::before{background:#3b2a7ccc}
#plate-talk .fog .night2{background:linear-gradient(180deg,#5a3a92cc 0%,#5a3a9266 18%,transparent 34%)}
#plate-talk .fog .beam{background:linear-gradient(180deg,#e6c4ff1c,#e6c4ff0c 55%,transparent);transform:skewX(-8deg)}
/* Her side, far: the room still turned to the same rainy night. Coordinates are plate px. */
#plate-room .tint{inset:auto;left:-640px;top:-112px;width:2240px;height:1260px}
#plate-room .tint .night{inset:0;mix-blend-mode:multiply}
#plate-room .tint .night2{inset:0;mix-blend-mode:color;-webkit-mask:radial-gradient(ellipse 150px 210px at 1915px 459px,transparent 70%,#000 100%),linear-gradient(180deg,#000 0 27%,transparent 40%);mask:radial-gradient(ellipse 150px 210px at 1915px 459px,transparent 70%,#000 100%),linear-gradient(180deg,#000 0 27%,transparent 40%);-webkit-mask-composite:source-in;mask-composite:intersect}
#plate-room .moon .night{background:radial-gradient(ellipse 330px 420px at 1930px 560px,#ffffff 0 30%,#ffffff00 100%),linear-gradient(97deg,#16225cf2 0%,#1a2768e0 30%,#1c2c74b0 50%,#2a3a8a70 62%,#ffffff00 80%),linear-gradient(180deg,#00000000 50%,#1a204c66 100%)}
#plate-room .moon .night2{background:linear-gradient(180deg,#24357ce6 0%,#24357ccc 22%,#24357c55 40%)}
#plate-room .fog .night{background:radial-gradient(ellipse 330px 420px at 1930px 560px,#ffffff 0 30%,#ffffff00 100%),linear-gradient(97deg,#2c1f5ef2 0%,#32246ae0 30%,#3a2a78b0 50%,#4a3a8a70 62%,#ffffff00 80%),linear-gradient(180deg,#00000000 50%,#2a1c4c66 100%)}
#plate-room .fog .night2{background:linear-gradient(180deg,#5a3a92e6 0%,#5a3a92cc 22%,#5a3a9255 40%)}
.plate canvas.rain{position:absolute;left:0;top:0;width:1600px;height:1000px;pointer-events:none}
/* Weather in the window: a slow cloud veil (moon) or folding violet mist (fog). */
.mist{border-radius:50%;filter:blur(38px);mix-blend-mode:screen;opacity:0;transition:opacity 1.2s var(--ease-in-out);animation:mist 34s var(--ease-in-out) infinite alternate}
.mist.m2{animation-duration:47s;animation-delay:-20s}
#plate-talk .mist{left:-60px;top:120px;width:560px;height:120px}#plate-talk .mist.m2{top:250px;left:120px;width:460px}
#plate-room .mist{left:660px;top:20px;width:420px;height:90px}#plate-room .mist.m2{top:120px;left:760px;width:360px}
html[data-theme="moon"] .mist{background:#b9c8ff;opacity:.07}
html[data-theme="fog"] .mist{background:linear-gradient(90deg,#c69cff,#ff9fd8 60%,#9cc0ff);opacity:.32}
@keyframes mist{from{transform:translateX(-60px) scaleY(.8)}to{transform:translateX(90px) scaleY(1.25)}}
.spill{inset:0;mix-blend-mode:screen;animation:breathe-light 9s var(--ease-in-out) infinite alternate}
#plate-talk .spill{background:radial-gradient(ellipse 520px 380px at 1290px 40px,#ffcf8a30,transparent 70%),radial-gradient(ellipse 760px 260px at 1180px 900px,#ffb97a2a,transparent 70%)}
#plate-room .spill{background:radial-gradient(ellipse 360px 300px at 1420px 150px,#ffcf8a38,transparent 70%),radial-gradient(ellipse 600px 240px at 1250px 760px,#ffb97a22,transparent 70%)}
html[data-theme="fog"] .spill{filter:hue-rotate(-28deg) saturate(1.1)}
@keyframes breathe-light{from{opacity:.75}to{opacity:1}}
.flame-glow{width:90px;height:90px;margin:-45px 0 0 -45px;border-radius:50%;background:radial-gradient(circle,#ffd9a0aa,#ffb46a44 35%,transparent 70%);mix-blend-mode:screen;animation:flicker 3.2s steps(1,end) infinite,flame 2.4s var(--ease-in-out) infinite alternate}
.flame-glow+.flame-glow{animation-delay:-1.1s,-.7s}
@keyframes flicker{0%,100%{opacity:.9}17%{opacity:.72}18%{opacity:.95}46%{opacity:.8}47%{opacity:1}79%{opacity:.78}}
@keyframes flame{to{transform:scale(1.12) translateY(-3px)}}
.mote{left:var(--x);top:var(--y);width:4px;height:4px;border-radius:50%;background:#ffe2b8;box-shadow:0 0 8px 2px #ffd59a88;opacity:0;animation:mote var(--d) linear var(--dl) infinite}
@keyframes mote{0%{transform:translate(0,0);opacity:0}15%{opacity:.7}80%{opacity:.5}100%{transform:translate(-40px,-160px);opacity:0}}
.moon-disc{position:absolute;left:0;top:0;width:44px;height:44px;margin:-22px 0 0 -22px;border-radius:50%;background:radial-gradient(circle at 42% 40%,#fbfdff 0%,#e4ecff 55%,#c9d6f5 100%);filter:blur(.6px);box-shadow:0 0 22px 6px #cfe0ff88,0 0 90px 40px #8fb0ff33,0 0 220px 120px #5f7fe022;transform-origin:50% 50%;will-change:transform;transition:opacity 1s var(--ease-in-out)}
html[data-theme="fog"] .moon-disc{opacity:0}
.vignette{position:absolute;inset:0;pointer-events:none;background:radial-gradient(ellipse 62% 74% at 54% 42%,transparent 52%,#050918aa 100%),linear-gradient(180deg,#050918aa 0%,transparent 13%)}
html[data-theme="fog"] .vignette{background:radial-gradient(ellipse 62% 74% at 54% 42%,transparent 52%,#0e0820aa 100%),linear-gradient(180deg,#0e0820aa 0%,transparent 13%)}
/* Your side: the table edge, a cup and a small candle. Drawn for the viewport, never stretched. */
.near{position:absolute;inset:0;pointer-events:none;--near-y:0px;transform:translate3d(var(--px,0px),calc(var(--py,0px) + var(--near-y)),0);transition:transform var(--dur-space) var(--ease-space),opacity var(--dur-space) var(--ease-out),filter var(--dur-space) var(--ease-out)}
.near-c{position:absolute;inset:0;width:100%;height:100%;transition:opacity .9s var(--ease-in-out)}
html[data-theme="moon"] .near-c[data-theme-layer="fog"],html[data-theme="fog"] .near-c[data-theme-layer="moon"]{opacity:0}
.steam{position:absolute;left:var(--steam-x);top:var(--steam-y);width:calc(var(--u) * 40px);height:calc(var(--u) * 170px);margin-left:calc(var(--u) * -20px);border-radius:50%;background:radial-gradient(ellipse at 50% 80%,#d2e1ff33,transparent 70%);filter:blur(calc(var(--u) * 9px));transform-origin:50% 100%;animation:steam 7s var(--ease-in-out) infinite}
.steam.s2{margin-left:calc(var(--u) * 14px);animation-duration:9s;animation-delay:-4s}
@keyframes steam{0%{transform:translateY(20%) scaleX(.7) skewX(0);opacity:0}30%{opacity:.9}70%{opacity:.5}100%{transform:translateY(-30%) scaleX(1.3) skewX(-14deg);opacity:0}}
.candle{position:absolute;left:var(--candle-x);top:var(--candle-y);width:calc(var(--u) * 380px);height:calc(var(--u) * 380px);margin:calc(var(--u) * -190px) 0 0 calc(var(--u) * -190px);border-radius:50%;background:radial-gradient(circle,#ffdca0cc 0,#ffbe7866 12%,#ffaa6420 40%,transparent 70%);mix-blend-mode:screen;animation:flicker 2.8s steps(1,end) infinite,flame 2s var(--ease-in-out) infinite alternate}
.veil{position:absolute;inset:0;background:radial-gradient(ellipse at 50% 40%,#02061433,#020614cc 90%);opacity:0;pointer-events:none;transition:opacity var(--dur-space) var(--ease-out)}
body[data-mode="pane"] .veil{opacity:.75}body[data-mode="record"] .veil{opacity:.35}
body[data-mode="home"] .near{--near-y:3.5vh}
body[data-mode="pane"] .near,body[data-mode="record"] .near{--near-y:7vh;opacity:.7}
#viewport{position:absolute;top:var(--view-top);left:0;right:0;height:var(--view-h);min-height:260px;overflow:hidden}
#world{position:absolute;inset:0}
/* ── Top ── */
.topbar{position:relative;z-index:4;height:84px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 40px}
.identity{display:flex;align-items:baseline;gap:12px;min-width:0;text-shadow:0 1px 12px var(--pool)}
.identity strong{font-size:22px;font-weight:400;letter-spacing:.04em;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.presence{color:var(--text-2);font-size:13px;letter-spacing:.04em;white-space:nowrap;transition:opacity var(--dur-ui) var(--ease-out)}
.glint{flex:none;align-self:center;width:7px;height:7px;background:var(--accent);border-radius:50%;box-shadow:0 0 10px 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;font-size:12px;color:var(--text-2)}
.sample-tag{height:30px;padding:0 13px;border-radius:15px;border:1px dashed var(--glass-line);background:var(--glass);color:var(--text-3);font-size:12px;white-space:nowrap;backdrop-filter:blur(18px)}
.weather small{color:var(--text-3)}
.theme{display:flex;gap:2px;border:1px solid var(--glass-line);padding:3px;border-radius:16px;background:var(--glass);backdrop-filter:blur(18px)}
.theme button{font-size:12px;color:var(--text-3);height:24px;padding:0 11px;border-radius:12px;display:flex;align-items:center;gap:6px;white-space:nowrap}
.theme button i{width:5px;height:5px;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:inset 0 0 0 1px var(--accent-glow)}
.end-talk{display:none;height:30px;padding:0 13px;border-radius:15px;align-items:center;gap:7px;background:var(--glass);border:1px solid var(--glass-line);color:var(--text-1);font-size:12px;white-space:nowrap;backdrop-filter:blur(18px)}
.end-talk svg{width:14px;height:14px}
body[data-mode="talk"] .end-talk,body[data-mode="record"] .end-talk{display:inline-flex;animation:chip-in 420ms var(--ease-out) 200ms both}
@keyframes chip-in{from{opacity:0;transform:translateY(-4px)}}
/* ── Being here (home) ── */
.home-copy{position:absolute;z-index:2;left:6.5vw;top:50%;width:min(420px,32vw);transform:translateY(-44%);transition:opacity 360ms var(--ease-out),transform 520ms var(--ease-out),filter 360ms var(--ease-out);text-shadow:0 2px 18px var(--pool)}
.home-copy h1{font-size:30px;font-weight:300;line-height:1.6;letter-spacing:.02em;margin-bottom:14px}
.left-note{display:block;width:100%;text-align:left;padding:16px 18px;border-radius:20px;margin-top:28px;line-height:1.85;font-size:14px;text-shadow:none}
.note-meta{display:flex;justify-content:space-between;gap:8px;font-size:12px;color:var(--accent);margin-bottom:8px}.note-meta time{color:var(--text-3)}
.home-actions{display:flex;align-items:center;gap:12px;margin-top:14px}.home-actions .round{width:48px;height:48px}
body:not([data-mode="home"]) .home-copy{opacity:0;transform:translate(-24px,-44%);filter:blur(6px);pointer-events:none;transition-duration:220ms,420ms,220ms}
body[data-mode="home"] .home-copy{transition-delay:240ms,200ms,240ms}
.her-hit{position:absolute;z-index:1;left:0;top:0;width:10px;height:10px;border-radius:40% 40% 30% 30%;display:flex;align-items:flex-start;justify-content:center}
.her-hit span{margin-top:-34px;padding:5px 12px;border-radius:14px;font-size:12px;background:var(--glass);border:1px solid var(--glass-line);backdrop-filter:blur(14px);opacity:0;transform:translateY(4px);transition:opacity var(--dur-ui) var(--ease-out),transform var(--dur-ui) var(--ease-out);pointer-events:none;white-space:nowrap}
.her-hit:focus-visible span{opacity:1;transform:none}
body:not([data-mode="home"]) .her-hit{visibility:hidden}
/* ── Talking: the lines fall on your side of the table, under her gaze ── */
.conversation{position:absolute;z-index:3;left:50%;bottom:calc(var(--nav-space) + var(--safe-bottom));width:min(940px,88vw);transform:translate(-50%,14px);opacity:0;pointer-events:none;transition:opacity 220ms var(--ease-out),transform 420ms var(--ease-out);display:flex;flex-direction:column;align-items:center}
body[data-mode="talk"] .conversation{opacity:1;transform:translate(-50%,0);pointer-events:auto;transition-duration:520ms,620ms;transition-delay:300ms}
.stage{position:relative;width:100%;height:var(--stage-h);display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:6px;text-align:center}
.stage::before{content:"";position:absolute;left:4%;right:4%;top:0;bottom:-40%;background:radial-gradient(ellipse 50% 55% at 50% 64%,color-mix(in srgb,var(--pool) 62%,transparent),transparent 100%);pointer-events:none;z-index:-1}
.line{width:100%;display:flex;flex-direction:column;align-items:center;gap:3px}
.line .who{display:inline-flex;align-items:center;gap:7px;font-size:12px;letter-spacing:.08em;color:var(--text-2);max-width:100%}
.line .who b{font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:16em}
.line .who i{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px var(--accent);flex:none}
.line[data-role="user"] .who i{background:transparent;box-shadow:none;border:1px solid var(--user)}
.line .state{font-style:normal;color:var(--text-3);font-size:11px;letter-spacing:0}
.line p{font-size:23px;font-weight:350;line-height:1.5;letter-spacing:.02em;max-width:900px;overflow-wrap:anywhere;text-shadow:0 2px 18px var(--pool),0 0 2px color-mix(in srgb,var(--pool) 60%,transparent);color:var(--text-1)}
.line[data-role="user"] p{color:var(--user);font-size:20px;font-weight:300}
/* Previous line: tag and words on one row, quiet (HEL-798). Fixed size; the hand-off is a crossfade of two copies. */
.line.is-prev{flex-direction:row;justify-content:center;gap:10px;opacity:.6;margin-bottom:4px}
.line.is-prev p{font-size:15px;font-weight:300;color:var(--user);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:min(680px,72vw)}
.line.is-prev[data-role="her"] p{color:var(--text-2)}
.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)}}
.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}}
.reader-bar{display:flex;align-items:center;justify-content:center;gap:4px;height:30px;margin:4px 0 6px;font-size:12px;color:var(--text-3)}
.reader-bar>button{height:28px;padding:0 9px;border-radius:14px;white-space:nowrap;display:inline-flex;align-items:center;gap:5px;color:var(--text-2)}
.reader-bar .icon{width:28px;padding:0;justify-content:center}.reader-bar svg{width:14px;height:14px}
.reader-bar .count{position:relative;min-width:72px;height:28px;display:inline-flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums;font-size:11px;color:var(--text-3)}
/* The dwell meter lives under the count: one quiet line, as in the approved still. */
.reader-bar .count::after{content:"";position:absolute;left:0;right:0;bottom:3px;height:2px;border-radius:1px;background:#ffffff1c}
.reader-bar .count i{position:absolute;left:0;right:0;bottom:3px;height:2px;border-radius:1px;background:var(--accent);box-shadow:0 0 6px var(--accent);transform-origin:left;transform:scaleX(0);z-index:1}
body.reading-paused .reader-bar .count i{opacity:.3}body.reading-held .reader-bar .count i{opacity:.5}
.reader-bar .flow[aria-pressed="false"]{color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent-glow)}
#stop{color:var(--accent)}
.latest{color:var(--on-accent)!important;background:var(--accent);box-shadow:0 0 16px var(--accent-glow)}
body.single-page .reader-bar .icon,body.single-page .reader-bar .count span{visibility:hidden}
.composer{width:min(640px,100%);height:50px;border-radius:25px;display:flex;align-items:center;gap:6px;padding:0 6px 0 8px;background:linear-gradient(90deg,var(--input-a),var(--input-b) 70%,var(--input-c));border:1px solid var(--glass-line);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);box-shadow:0 10px 40px #02051466,inset 0 1px 0 #ffffff12;transition:border-color var(--dur-ui),box-shadow var(--dur-ui)}
.composer .round{width:38px;height:38px;color:var(--text-2)}
.composer textarea{flex:1;min-width:0;resize:none;height:38px;max-height:88px;border:0;background:transparent;line-height:1.6;padding:8px 4px;outline:none;color:var(--text-1);font-size:15px}
.composer textarea::placeholder{color:var(--text-3)}
.composer:focus-within{border-color:var(--accent-glow);box-shadow:0 0 0 1px var(--accent-soft),0 0 30px var(--accent-soft)}
.voice-button{width:38px;height:38px;border-radius:50%;display:grid;place-items:center;background:var(--accent);color:var(--on-accent);box-shadow:0 0 18px var(--accent-glow);flex:none}
.voice-row{display:flex;align-items:center;justify-content:center;gap:12px;font-size:12px;color:var(--text-2);margin-bottom:6px}.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: small, translucent, under everything ── */
.navigation{position:absolute;z-index:5;left:50%;bottom:calc(6px + var(--safe-bottom));transform:translateX(-50%);display:flex;gap:4px;padding:3px;border-radius:17px;background:color-mix(in srgb,var(--surface) 40%,transparent);border:1px solid var(--glass-line);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);transition:transform 420ms var(--ease-out),opacity 280ms var(--ease-out)}
.nav-glow{position:absolute;left:0;top:3px;bottom:3px;width:64px;border-radius:13px;background:var(--accent-soft);box-shadow:0 0 14px var(--accent-glow);transition:transform 460ms var(--ease-out),opacity 280ms;pointer-events:none}
.navigation button{position:relative;width:64px;height:26px;display:inline-flex;align-items:center;justify-content:center;gap:6px;border-radius:13px;font-size:12px;letter-spacing:.04em;color:var(--text-3)}
.navigation svg{width:14px;height:14px}.navigation [aria-current="page"]{color:var(--text-1)}.navigation [aria-current="page"] svg{color:var(--accent)}
.ref-note{position:absolute;right:40px;bottom:calc(14px + var(--safe-bottom));font-size:11px;color:var(--text-3);opacity:.8;letter-spacing:.04em;pointer-events:none}
.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 room ── */
.sheet{position:fixed;inset:auto;margin:0;top:calc(var(--view-top) + 84px);left:5vw;width:min(900px,60vw);height:calc(var(--view-h) - 150px - var(--safe-bottom));max-height:none;max-width:calc(100vw - 24px);padding:0;border-radius:26px;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"]{width:min(470px,36vw);left:4vw}
.sheet-header{display:flex;justify-content:space-between;align-items:center;gap:20px;padding:22px 26px 16px;border-bottom:1px solid var(--glass-line);height:100px}
.sheet-header .eyebrow{font-size:11px;margin-bottom:3px}
.sheet-content{height:calc(100% - 100px);overflow:auto;overscroll-behavior:contain;padding:22px 26px 32px;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}
@keyframes rise{from{opacity:0;transform:translateY(10px);filter:blur(4px)}}
.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:20px 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:16px 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:6px;overflow-wrap:anywhere}
.message .author i{width:7px;height:7px;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(--user)}
.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}
.sheet-content .primary{color:var(--on-accent)}
*{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}
/* Hover only where a real pointer exists; touch never gets stuck hover states. */
@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),.navigation button:hover{background:#ffffff0d;opacity:1}
.her-hit{cursor:pointer}.her-hit:hover span{opacity:1;transform:none}
}
@media(max-width:1100px){
.weather,.ref-note{display:none}.identity strong{max-width:180px}
.home-copy{width:min(400px,40vw)}
.sheet{width:84vw;left:8vw}.sheet[data-kind="history"],.sheet[data-kind="full"]{width:min(440px,52vw)}
}
/* ── Phone ── */
@media(max-width:720px){
.vignette,html[data-theme="fog"] .vignette{background:radial-gradient(ellipse 95% 70% at 55% 38%,transparent 55%,color-mix(in srgb,var(--bg-0) 66%,transparent) 100%),linear-gradient(180deg,color-mix(in srgb,var(--bg-0) 66%,transparent) 0%,transparent 10%)}
:root{--stage-h:132px;--nav-space:60px}
.topbar{height:56px;padding:0 14px;gap:8px}.identity{gap:8px}.identity strong{font-size:16px;max-width:96px}.presence{display:none}
.top-actions{gap:6px}.sample-tag{height:26px;font-size:10px;padding:0 8px}.theme{padding:2px}.theme button{font-size:11px;height:22px;padding:0 8px;gap:4px}
.end-talk{height:28px;width:28px;padding:0;justify-content:center}.end-talk span{position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden}
.ref-note{display:block;top:52px;right:14px;bottom:auto;font-size:10px;opacity:.65}
.home-copy{left:20px;right:20px;top:auto;bottom:calc(var(--nav-space) + 6px + var(--safe-bottom));width:auto;transform:none}
body:not([data-mode="home"]) .home-copy{transform:translateY(16px)}
.home-copy .eyebrow{margin-bottom:8px}.home-copy h1{font-size:22px;line-height:1.55;margin-bottom:8px}.home-copy .quiet{font-size:12px}
.left-note{padding:12px 15px;font-size:13px;margin-top:14px;border-radius:18px;line-height:1.7}.note-meta{font-size:11px;margin-bottom:4px}
.home-copy .small-link{padding:8px 0;font-size:12px}.home-actions{margin-top:4px}.home-actions .primary{padding:10px 20px;font-size:14px}.home-actions .round{width:44px;height:44px}
.conversation{width:calc(100% - 20px)}
.line p{font-size:18px;line-height:1.55}.line[data-role="user"] p{font-size:16px}.line.is-prev p{font-size:13px;max-width:70vw}
.reader-bar{gap:0;margin:2px 0 6px;font-size:11px}.reader-bar>button{padding:0 7px;height:34px}.reader-bar .icon{width:34px}.reader-bar .count{min-width:56px}
.composer{height:48px;padding:0 4px}.composer textarea{font-size:16px}
.navigation{bottom:calc(6px + var(--safe-bottom));padding:3px}.navigation button{width:70px;height:40px;font-size:12px}.nav-glow{width:70px;border-radius:14px}
.toast{top:60px;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) + 60px);height:calc(var(--view-h) - 128px - var(--safe-bottom));border-radius:22px}
.sheet-header{padding:14px 18px;height:80px}.sheet-content{padding:16px 18px 26px;height:calc(100% - 80px)}h2{font-size:20px}.quote{font-size:18px}
body[data-mode="talk"] .latest span{max-width:9em;overflow:hidden;text-overflow:ellipsis}
}
@media(max-width:360px){.sample-tag{font-size:0;width:26px;padding:0}.sample-tag::before{content:"演示";font-size:10px}.identity strong{max-width:70px}}
@media(max-height:700px) and (max-width:720px){
.home-copy h1{font-size:19px}.home-copy .quiet{display:none}.home-copy .left-note{margin-top:8px;padding:9px 13px}.home-copy .left-note br{display:none}
}
/* Candidate only (?frame=media, HEL-802 v2 §2.3): on phones the room sits in a top media box of height --media-h, its
bottom 12% fading into the page; the words and the composer live underneath. No foreground table on phones. */
.frame-note{display:none}html.media-on .frame-note{display:inline}
html.media-on #scene{bottom:auto;height:var(--media-h)}
html.media-on #scene::after{content:"";position:absolute;left:0;right:0;bottom:0;height:12%;background:linear-gradient(180deg,transparent,var(--bg-0));pointer-events:none;transition:background .9s}
html.media-on .near{display:none}
/* Clean masters for image-to-video: no interface; optionally no foreground. */
html.clean #viewport,html.clean #sheet{display:none}html.clean .veil{opacity:0}html.no-fore .near,html.no-fore .vignette{display:none}
/* Short visual viewport = keyboard open or a squat window. Her face yields first, the words never do. */
body.compact{--stage-h:78px;--nav-space:54px}
body.compact .topbar{height:46px}body.compact .weather,body.compact .presence,body.compact .ref-note{display:none}
body.compact .line.is-prev{display:none}body.compact .line p{font-size:16px;line-height:1.55}
body.compact .reader-bar{margin:0 0 4px}
body.compact .navigation button{height:36px}
body.compact .sheet{top:calc(var(--view-top) + 48px);height:calc(var(--view-h) - 104px - var(--safe-bottom))}body.compact .sheet-header{height:66px;padding:8px 18px}body.compact .sheet-content{height:calc(100% - 66px)}
body.compact.typing[data-mode="talk"] .navigation{transform:translate(-50%,150%);opacity:0;pointer-events:none}
body.compact.typing{--nav-space:6px}
body.compact[data-mode="home"] .home-copy .eyebrow,body.compact[data-mode="home"] .home-copy .quiet,body.compact[data-mode="home"] .small-link,body.compact[data-mode="home"] .note-text{display:none}
body.compact[data-mode="home"] .home-copy h1{font-size:18px;margin:0}
body.compact[data-mode="home"] .left-note{margin-top:6px;padding:7px 12px}body.compact[data-mode="home"] .note-meta{margin:0}
body.micro .reader-bar .count,body.micro #read-all{display:none}
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 *,body.suspended *::before,body.suspended *::after{animation-play-state:paused!important}
/* Keyboard shortcuts (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}
.home-copy,.conversation,.near,.veil,.navigation,.nav-glow,.cam,.tint,.near-c,.moon-disc{transition:opacity 200ms linear!important}
}
/* Tier 2 (slow device): no backdrop blur, the most expensive compositor effect; no per-frame mask. */
html[data-tier="2"] .glass,html[data-tier="2"] .sheet,html[data-tier="2"] .composer,html[data-tier="2"] .navigation{backdrop-filter:none;-webkit-backdrop-filter:none;background:var(--glass-read)}
html[data-tier="2"] .mote,html[data-tier="2"] .mist{display:none}
+195
View File
@@ -0,0 +1,195 @@
'use strict';
// ── Scene: one room, one camera. ──
// Two approved reference stills of the same person, room and clothes are aligned on her face, so the camera can
// move continuously from "sitting across the room" (room still) to "sitting across the table" (talk still, = the
// approved HEL-798 composition). Only transform and opacity move per frame; the face itself is never warped.
const Scene = (() => {
const html = document.documentElement;
const plate = {room: document.getElementById('plate-room'), talk: document.getElementById('plate-talk')};
const moon = document.getElementById('moon'), cam = document.getElementById('cam'), near = document.getElementById('near'), hit = document.getElementById('her-hit');
// Plate px. FACE: centre of her face in each still. K: her face is K× larger on the talk plate than on the room plate
// (measured from eye spacing and hair width on the two stills: 3.8 native × 1.12 / 1.4).
const FACE = {room: [1275, 347], talk: [856, 315]}, K = 3.04;
const BOUNDS = {room: [-640, -112, 1600, 1148], talk: [-118, -10, 1674, 998]};
const MOON = {room: [1082, 98, 30], talk: [404, 92, 44]};
const HER = [1195, 235, 1445, 700]; // her figure on the room plate, for the tap target
let M = 12; // overscan kept for pointer parallax
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
const motionOff = () => reduced.matches || html.classList.contains('no-motion');
const sstep = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
const lerp = (a, b, t) => a + (b - a) * t;
// Candidate (HEL-802 v2 §2.3, not approved): on phones the room lives in a 4:5 media box at the top instead of the
// whole screen. H = min(1.25·w, max(w, h − 240), h − 56). Off unless ?frame=media; desktop stays full-viewport.
const q = new URLSearchParams(location.search), media = q.get('frame') === 'media';
html.dataset.frame = media ? 'media' : 'full';
// Clean master export (?clean=1): no interface at all, no parallax overscan; ?fore=0 also drops your table, cup and
// candle; ?fit=height keeps the whole 1000px height of the composition (16:9 masters widen instead of cropping).
const clean = q.get('clean') === '1', fitHeight = q.get('fit') === 'height';
html.classList.toggle('clean', clean); html.classList.toggle('no-fore', clean && q.get('fore') === '0');
if (clean) M = 0;
let vw = innerWidth, vh = innerHeight, tier = 0, boxed = false;
// t: camera (0 = across the room, 1 = across the table). b: how much of the close still shows.
let t = 0, v = 0, target = 0, b = 0, bFrom = 0, bStart = 0, settled = true, maskOn = null;
function layout() { return boxed || vw <= 720 || vw / vh < .9 ? 'phone' : 'desk'; }
function shot(kind) {
const phone = layout() === 'phone';
if (boxed) {
// Whole height of the 1600×1000 composition stays visible (cover by height); the talk still keeps its body
// midline — source x 0.56 of a 16:9 frame = x 907 of the 16:10 composition — at the centre of the box.
const s = Math.max(vw / 1600, vh / 1000);
if (kind === 'room') return {plate: 'room', ax: .6, ay: 347 * s / vh, m: 1};
return {plate: 'talk', ax: .5 + s * (856 - 907) / vw, ay: 315 * s / vh, m: 1};
}
if (kind === 'room') return {plate: 'room', ax: phone ? .6 : 1275 / 1600, ay: phone ? .25 : .347, m: 1};
return {plate: 'talk', ax: phone ? .5 : 856 / 1600, ay: phone ? .27 : .315, m: 1};
}
function cover(s, o, p) {
// How far a plate must shift so its picture still covers the viewport (plus parallax overscan).
const [x0, y0, x1, y1] = BOUNDS[p], c = [0, 0];
const fit = (pos, a0, a1, size, i) => {
const min = size + M - s * a1, max = -M - s * a0;
if (min > max) { c[i] = (min + max) / 2 - pos; return; }
if (pos < min) c[i] = min - pos; else if (pos > max) c[i] = max - pos;
};
fit(o[0], x0, x1, vw, 0); fit(o[1], y0, y1, vh, 1);
return c;
}
function rest(kind) {
const sh = shot(kind), base = (fitHeight ? vh / 1000 : Math.max(vw / 1600, vh / 1000)) * sh.m, f = FACE[sh.plate];
const o = [sh.ax * vw - base * f[0], sh.ay * vh - base * f[1]];
const c = cover(base, o, sh.plate);
return {s: base, o: [o[0] + c[0], o[1] + c[1]]};
}
// Camera at parameter x: zoom in log-space, face travels in a straight line on screen.
function at(x) {
const A = rest('room'), B = rest('talk');
const sRB = B.s * K;
const phA = [A.o[0] + A.s * FACE.room[0], A.o[1] + A.s * FACE.room[1]];
const phB = [B.o[0] + B.s * FACE.talk[0], B.o[1] + B.s * FACE.talk[1]];
const sR = A.s * Math.pow(sRB / A.s, x), sT = sR / K;
const ph = [lerp(phA[0], phB[0], x), lerp(phA[1], phB[1], x)];
const oR = [ph[0] - sR * FACE.room[0], ph[1] - sR * FACE.room[1]];
const oT = [ph[0] - sT * FACE.talk[0], ph[1] - sT * FACE.talk[1]];
const cR = cover(sR, oR, 'room'), cT = cover(sT, oT, 'talk'), w = sstep(.35, .8, x);
const c = [lerp(cR[0], cT[0], w), lerp(cR[1], cT[1], w)];
return {room: {s: sR, o: [oR[0] + c[0], oR[1] + c[1]]}, talk: {s: sT, o: [oT[0] + c[0], oT[1] + c[1]]}};
}
const put = (el, tr) => { el.style.transform = `translate3d(${tr.o[0].toFixed(2)}px,${tr.o[1].toFixed(2)}px,0) scale(${tr.s.toFixed(5)})`; };
const map = (tr, p) => [tr.o[0] + tr.s * p[0], tr.o[1] + tr.s * p[1]];
function apply() {
const off = motionOff();
// Reduced motion: both stills stay at rest and simply crossfade.
const cam0 = off ? {room: rest('room'), talk: null} : at(t);
if (off) cam0.talk = rest('talk');
put(plate.room, cam0.room); put(plate.talk, cam0.talk);
const blend = off ? b : t;
const useMask = !off && tier < 2;
const op = useMask ? sstep(.08, .45, blend) : sstep(off ? 0 : .3, off ? 1 : .8, blend);
plate.talk.style.opacity = op.toFixed(3);
plate.talk.style.visibility = op < .002 ? 'hidden' : 'visible';
plate.room.style.visibility = blend > .999 ? 'hidden' : 'visible';
// The close still opens out from her face: a soft-edged circle that grows until it reaches the corners.
// (Mask coordinates are in the plate box, which starts at the picture's corner: face + (118, 10).)
const r = 150 + 1450 * sstep(.1, .97, blend);
const wantMask = useMask && blend < .985 && op > .002;
if (wantMask) {
// ...intersected with soft picture edges, which tighten to nothing as the picture reaches the screen edges.
const e = (260 * (1 - sstep(.7, .985, blend))).toFixed(0);
const edge = d => `linear-gradient(${d},transparent 0,#000 ${e}px,#000 calc(100% - ${e}px),transparent 100%)`;
const m = `radial-gradient(circle at ${FACE.talk[0] + 118}px ${FACE.talk[1] + 10}px,#000 ${r.toFixed(0)}px,transparent ${(r + 520).toFixed(0)}px),${edge('90deg')},${edge('180deg')}`;
const s = plate.talk.style;
s.webkitMaskImage = m; s.maskImage = m;
if (!maskOn) { s.webkitMaskComposite = 'source-in, source-in'; s.maskComposite = 'intersect'; }
maskOn = true;
} else if (maskOn !== false) { plate.talk.style.webkitMaskImage = 'none'; plate.talk.style.maskImage = 'none'; maskOn = false; }
// The moon is far away: it drifts less than the room and hands over between the two windows.
const mw = off ? (b > .5 ? 1 : 0) : sstep(.2, .8, t);
const mr = map(cam0.room, MOON.room), mt = map(cam0.talk, MOON.talk);
const size = lerp(MOON.room[2] * cam0.room.s, MOON.talk[2] * cam0.talk.s, mw);
moon.style.transform = `translate3d(${lerp(mr[0], mt[0], mw).toFixed(1)}px,${lerp(mr[1], mt[1], mw).toFixed(1)}px,0) scale(${(size / 44).toFixed(4)})`;
// Tap target over her (only used while you are across the room).
const a = map(cam0.room, [HER[0], HER[1]]), z = map(cam0.room, [HER[2], HER[3]]);
Object.assign(hit.style, {left: a[0] + 'px', top: a[1] + 'px', width: (z[0] - a[0]) + 'px', height: (z[1] - a[1]) + 'px'});
Rain.view(cam0, op);
}
// Critically damped spring: interruptible, keeps its velocity when the target changes mid-way.
const W = 7.2;
function step(dt) {
if (motionOff()) {
t = target; v = 0;
const k = Math.min(1, (performance.now() - bStart) / 200);
b = lerp(bFrom, target, k);
settled = k >= 1;
return;
}
// Fixed small sub-steps keep the spring in real time even when frames arrive late.
for (let left = dt; left > 0; left -= 1 / 240) {
const h = Math.min(left, 1 / 240), a = W * W * (target - t) - 2 * W * v;
v += a * h; t += v * h;
}
if (Math.abs(target - t) < 4e-4 && Math.abs(v) < 4e-3) { t = target; v = 0; settled = true; }
b = t;
}
function go(mode, instant = false) {
const nt = mode === 'talk' || mode === 'record' ? 1 : 0;
html.dataset.camera = nt ? 'close' : 'far';
if (instant) { t = target = b = nt; v = 0; settled = true; apply(); return; }
if (nt === target && settled) return;
bFrom = b; bStart = performance.now();
target = nt; settled = false;
}
// Pointer parallax (fine pointers only): far things move a little, your table the other way.
const fine = matchMedia('(hover: hover) and (pointer: fine)');
addEventListener('pointermove', e => {
if (!fine.matches || motionOff() || e.pointerType !== 'mouse') return;
const px = e.clientX / vw - .5, py = e.clientY / vh - .5;
cam.style.transform = `translate3d(${(-px * 16).toFixed(1)}px,${(-py * 10).toFixed(1)}px,0)`;
near.style.setProperty('--px', (px * 22).toFixed(1) + 'px'); near.style.setProperty('--py', (py * 10).toFixed(1) + 'px');
}, {passive: true});
// Frame loop + automatic quality tiers (average frame interval while settled).
let last = 0, samples = [], forced = false;
function frame(now) {
const dt = last ? Math.min(.12, (now - last) / 1000) : 0; last = now;
if (!settled) { step(dt); apply(); }
Rain.draw(now, tier);
if (!forced && settled && dt && !document.hidden) {
samples.push(dt); if (samples.length > 120) samples.shift();
if (samples.length === 120) {
const avg = samples.reduce((x, y) => x + y) / 120;
if (avg > .026 && tier < 2) { setTier(tier + 1); samples = []; }
}
}
requestAnimationFrame(frame);
}
function setTier(n, force = false) { tier = n; forced = forced || force; html.dataset.tier = String(n); Near.draw(); apply(); }
function resize() {
const vv = window.visualViewport, w = innerWidth, h = vv ? vv.height : innerHeight;
boxed = media && w <= 720;
html.classList.toggle('media-on', boxed);
vw = w; vh = boxed ? Math.round(Math.min(1.25 * w, Math.max(w, h - 240), h - 56) * 10) / 10 : innerHeight;
html.style.setProperty('--media-h', boxed ? vh + 'px' : '100%');
Near.draw(); apply();
}
addEventListener('resize', resize);
document.addEventListener('visibilitychange', () => { last = 0; samples = []; });
requestAnimationFrame(frame);
return {
go, setTier, resize, apply,
// Review hook: freeze the camera at any point of the move (used for the frame strips in evidence/).
peek(x) { t = target = b = x; v = 0; settled = true; apply(); },
get tier() { return tier; },
info: () => {
// Her face on screen (forehead to chin, from the close still's transform), for overlap checks.
const m = /translate3d\(([-\d.]+)px, ?([-\d.]+)px.*scale\(([-\d.]+)\)/.exec(plate.talk.style.transform) || [0, 0, 0, 0];
const s = +m[3], cx = +m[1] + s * FACE.talk[0], cy = +m[2] + s * FACE.talk[1];
return {t: +t.toFixed(4), target, b: +b.toFixed(4), settled, tier, layout: layout(), frame: boxed ? {w: vw, h: vh} : null, face: {x: +cx.toFixed(1), y: +cy.toFixed(1)}, talkOpacity: +plate.talk.style.opacity, roomVisible: plate.room.style.visibility !== 'hidden', mask: !!maskOn,
head: {left: cx - 200 * s, right: cx + 200 * s, top: cy - 300 * s, bottom: cy + 140 * s}};
}
};
})();
+109
View File
@@ -0,0 +1,109 @@
<!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-799 共同空间动态小样</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 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/></symbol>
<symbol id="i-talk" viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 1 3.5 6.6L4 20l1.2-3.6A8 8 0 0 1 4 12z"/></symbol>
<symbol id="i-book" viewBox="0 0 24 24"><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M9 8h6M9 12h6M9 16h4"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1"/></symbol>
<symbol id="i-mic" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3"/></symbol>
<symbol id="i-arrow" viewBox="0 0 24 24"><path d="M12 19V5M6 11l6-6 6 6"/></symbol>
<symbol id="i-close" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></symbol>
<symbol id="i-lines" viewBox="0 0 24 24"><path d="M4 7h16M4 12h10M4 17h13"/></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>
<!-- One room, one camera. Far: her room and the rainy city. Middle: her. Near: your table, cup and candle. -->
<div id="scene" aria-hidden="true">
<div class="cam" id="cam">
<div class="plate" id="plate-room" data-plate="room"><div class="po">
<img class="photo" src="{{ROOM}}" alt="">
<div class="tint moon"><i class="night"></i><i class="night2"></i></div>
<div class="tint fog"><i class="night"></i><i class="night2"></i></div>
<canvas class="rain" width="800" height="500"></canvas>
<i class="mist m1"></i><i class="mist m2"></i>
<i class="flame-glow" style="left:931px;top:500px"></i>
<i class="spill"></i>
</div></div>
<div class="plate" id="plate-talk" data-plate="talk"><div class="po">
<img class="photo" src="{{TALK}}" alt="">
<div class="tint moon"><i class="night"></i><i class="night2"></i><i class="beam"></i></div>
<div class="tint fog"><i class="night"></i><i class="night2"></i><i class="beam"></i></div>
<canvas class="rain" width="800" height="500"></canvas>
<i class="mist m1"></i><i class="mist m2"></i>
<i class="flame-glow" style="left:89px;top:738px"></i><i class="flame-glow" style="left:-12px;top:704px"></i>
<i class="spill"></i>
<i class="mote" style="--x:1180px;--y:330px;--d:17s;--dl:-3s"></i><i class="mote" style="--x:1290px;--y:240px;--d:21s;--dl:-9s"></i><i class="mote" style="--x:1400px;--y:420px;--d:19s;--dl:-14s"></i><i class="mote" style="--x:1230px;--y:520px;--d:24s;--dl:-6s"></i>
</div></div>
<i class="moon-disc" id="moon"></i>
</div>
<div class="near" id="near">
<canvas class="near-c" data-theme-layer="moon"></canvas>
<canvas class="near-c" data-theme-layer="fog"></canvas>
<i class="steam s1"></i><i class="steam s2"></i>
<i class="candle"></i>
</div>
<div class="vignette"></div>
<div class="veil"></div>
</div>
<div id="viewport">
<header class="topbar">
<div class="identity"><span class="glint"></span><strong data-name>晚晚</strong><span class="presence" id="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>
<button class="end-talk" id="close-talk"><svg><use href="#i-close"/></svg><span>结束交谈</span></button>
</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>
<button class="her-hit" id="her-hit" aria-label="走近她,开始交谈"><span>和她说话</span></button>
<section class="conversation" id="conversation" aria-label="当前对话" inert>
<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>
<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>
<form class="composer" 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" 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="ref-note">人物为旧参考静帧 · 不代表新日常<span class="frame-note"> · 4:5 容器候选</span> · 未接入真实动态</p>
<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>
+456
View File
@@ -0,0 +1,456 @@
'use strict';
// Conversation, panels and modes. Reading logic carried over from the verified HEL-793/795 prototype (PR#8);
// what changed is where it lives: in the same room, across the table, instead of inside her window.
// 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');
const reducedQuery = matchMedia('(prefers-reduced-motion: reduce)');
const motionOff = () => reducedQuery.matches || html.classList.contains('no-motion');
const EASE = 'cubic-bezier(.22,1,.36,1)';
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, 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, 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: the camera moves you closer or back; the UI for each place fades in once you have arrived. ──
// Keyboard shortcuts land on the target state at once: CSS transitions are suspended for two frames
// and any finite animation already under way 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} = {}) {
if (instant) snap();
state.mode = mode; document.body.dataset.mode = mode;
Scene.go(mode, instant);
$('.home-copy').inert = mode !== 'home'; $('#conversation').inert = mode !== 'talk'; $('#her-hit').inert = mode !== 'home';
$$('.navigation button').forEach(n => n.dataset.nav === (mode === 'pane' || mode === 'record' ? state.dialogNav : mode) ? n.setAttribute('aria-current', 'page') : n.removeAttribute('aria-current'));
moveNavGlow(); updatePresence();
if (mode === 'talk') present(instant ? 'instant' : 'jump');
}
function updatePresence() {
const close = state.mode === 'talk' || state.mode === 'record';
$('#presence').textContent = state.busy && close ? '在窗边 · 正在回应(演示)' : state.voice && close ? '语音界面演示 · 没有收音' : close ? '在窗边 · 看着你' : '在窗边';
}
// The glow only slides (transform); every nav item has the same width, set in CSS.
function moveNavGlow() {
const cur = $('.navigation [aria-current]'), glow = $('.nav-glow');
if (!cur) { glow.style.opacity = 0; return; }
glow.style.opacity = 1; glow.style.transform = `translateX(${cur.offsetLeft - 3}px)`;
}
function openTalk(focus = false) { if (state.mode !== 'talk') setMode('talk'); if (focus) $('#draft').focus({preventScroll: true}); }
// ── 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, Math.min(stage.clientWidth, 900) - 24), font = compact ? 16 : innerWidth <= 720 ? 18 : 23;
const perLine = Math.floor(width / font), max = Math.max(14, Math.min(60, Math.floor(perLine * (compact ? 1.6 : 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;
// Short sentences travel together (as in the approved still); a line ends at a sentence once it has some weight.
if (/[。!?!?]/.test(ch) && n >= Math.min(22, max * .45)) { out.push(line); line = ''; }
else if (n >= max) {
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);
// The newest chunk of a reply still being written is held back (it may still grow or merge with the next
// sentence), so a line never changes or re-animates after it has appeared.
if (m.status === 'generating' && parts.length) 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>`;
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').innerHTML = `<span>${n ? `${state.cursor + 1} / ${n}${state.busy ? '…' : ''}` : '0 / 0'}</span><i></i>`;
document.body.classList.toggle('single-page', n <= 1 && !state.busy);
$('#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);
updatePresence();
}
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 = $('#page-count 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 room steps 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><p class="guide">相处画面里她在沙发上看书,那只是旧参考静帧,用来示意你们在同一间房里的位置;看书已不是新的默认日常,按时段、天气和穿搭准备的新日常素材还没有生成。</p><p class="guide">地址加 <code>?frame=media</code> 可在手机宽度下试 HEL-802 v2 的候选取景:房间放进顶部 4:5 媒体容器,对话与输入在容器下方,手机不画前景桌杯。这只是候选,未获认可。</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;
if (instant) snap();
html.dataset.theme = theme; Rain.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'); Scene.resize(); showDisplay(); },
'start-voice': () => { state.returnMode = 'talk'; finishClose(); openTalk(); state.voice = true; document.body.classList.add('has-voice'); $('#voice-row').hidden = false; 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();
$('#her-hit').onclick = () => openTalk();
$('#shared-note').onclick = () => { openTalk(); showHistory(); };
$('#days-link').onclick = () => showDays();
$('#close-talk').onclick = () => { if (sheet.open) { state.returnMode = 'home'; closeSheet(); } else 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); updateControls(); 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) { $('#draft').focus(); 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, 88) + '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({preventScroll: true}); }
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 the lines get.
function fitViewport() {
Scene.resize();
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'); 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&tier=2
const params = new URLSearchParams(location.search);
applyTheme(params.get('theme') || 'moon', true);
if (params.get('tier')) Scene.setTier(+params.get('tier'), true);
Scene.resize();
fitViewport(); present('jump'); moveNavGlow();
if (params.get('mode') === 'talk') setMode('talk', {instant: true});
if (params.get('scene')) setTimeout(() => runScenario(params.get('scene')), 600);
window.__proto = {state, pages, runScenario, setMode, applyTheme, showDays, showManage, showHistory, showFull, closeSheet, setFlow, go, scene: () => Scene.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-hel799-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 }
+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\..\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-hel799-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 }
+36
View File
@@ -0,0 +1,36 @@
param([int]$w = 1600, [int]$h = 1000, [switch]$mobile, [double]$dpr = 1, [string]$query = '', [string]$steps = '', [string]$out = "$PSScriptRoot\..\evidence", [int]$port = 9341, [switch]$reduced)
# Scripted screenshots in headless Edge. $steps: lines of
# wait <ms> | js <expr> | log <expr> | shot <file> | key <Escape|ArrowLeft|ArrowRight> | click <css> | type <css>|<text>
. "$PSScriptRoot\cdp.ps1"
New-Item -ItemType Directory -Force $out | Out-Null
$url = 'file:///' + ((Resolve-Path "$PSScriptRoot\..\index.html").Path -replace '\\', '/') + $query
$null = Start-Browser $port
try {
Set-View $w $h -mobile:$mobile -dpr $dpr
if ($reduced) { [void](Send-Cdp 'Emulation.setEmulatedMedia' @{ features = @(@{ name = 'prefers-reduced-motion'; value = 'reduce' }) }) }
[void](Send-Cdp 'Runtime.enable')
Open-Page $url
Wait-Ms 800
[void](Invoke-Js "window.__errors=[];addEventListener('error',e=>__errors.push(String(e.message)));1")
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) }
'js' { [void](Invoke-Js $arg) }
'log' { Write-Output ("{0} => {1}" -f $arg, (Invoke-Js $arg)) }
'shot' { Save-Shot (Join-Path $out $arg); Write-Output "shot $arg" }
'key' {
$codes = @{ Escape = 27; ArrowLeft = 37; ArrowRight = 39 }
[void](Send-Cdp 'Input.dispatchKeyEvent' @{ type = 'rawKeyDown'; key = $arg; code = $arg; windowsVirtualKeyCode = $codes[$arg] })
[void](Send-Cdp 'Input.dispatchKeyEvent' @{ type = 'keyUp'; key = $arg; code = $arg; windowsVirtualKeyCode = $codes[$arg] })
}
'click' {
$r = Invoke-Js "(()=>{const e=document.querySelector('$arg');const r=e.getBoundingClientRect();return JSON.stringify([r.left+r.width/2,r.top+r.height/2])})()" | ConvertFrom-Json
foreach ($t in 'mousePressed', 'mouseReleased') { [void](Send-Cdp 'Input.dispatchMouseEvent' @{ type = $t; x = $r[0]; y = $r[1]; button = 'left'; clickCount = 1 }) }
}
'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})()") }
}
}
Write-Output ("errors => " + (Invoke-Js 'JSON.stringify(window.__errors)'))
} finally { Stop-Browser }
+19
View File
@@ -0,0 +1,19 @@
param([string[]]$files, [string]$out, [int]$cols = 3, [int]$cellW = 640, [string[]]$labels = @())
# Lays screenshots out on one contact sheet (System.Drawing; no extra tools).
Add-Type -AssemblyName System.Drawing
$imgs = $files | ForEach-Object { [Drawing.Image]::FromFile((Resolve-Path $_).Path) }
$cellH = [int]($cellW * $imgs[0].Height / $imgs[0].Width)
$rows = [math]::Ceiling($imgs.Count / $cols)
$bmp = New-Object Drawing.Bitmap ($cols * $cellW + ($cols + 1) * 8), ($rows * ($cellH + 30) + 8)
$g = [Drawing.Graphics]::FromImage($bmp)
$g.Clear([Drawing.Color]::FromArgb(10, 12, 24)); $g.InterpolationMode = 'HighQualityBicubic'
$font = New-Object Drawing.Font 'Microsoft YaHei', 12
for ($i = 0; $i -lt $imgs.Count; $i++) {
$x = 8 + ($i % $cols) * ($cellW + 8); $y = 8 + [math]::Floor($i / $cols) * ($cellH + 30)
$g.DrawImage($imgs[$i], $x, $y, $cellW, $cellH)
$label = if ($labels.Count -gt $i) { $labels[$i] } else { [IO.Path]::GetFileNameWithoutExtension($files[$i]) }
$g.DrawString($label, $font, [Drawing.Brushes]::White, $x, $y + $cellH + 4)
}
$bmp.Save($out, [Drawing.Imaging.ImageFormat]::Png)
$imgs | ForEach-Object { $_.Dispose() }
"wrote $out"
+176
View File
@@ -0,0 +1,176 @@
param([string]$out = "$PSScriptRoot\..\evidence")
# Behavioural checks in headless Edge (real input events via CDP). Each check records pass/fail with observed values.
. "$PSScriptRoot\cdp.ps1"
New-Item -ItemType Directory -Force $out | Out-Null
$url = 'file:///' + ((Resolve-Path "$PSScriptRoot\..\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 -Depth 5) }
function J($e) { Invoke-Js $e | ConvertFrom-Json }
function Key($k) { $c = @{ Escape = 27; ArrowLeft = 37; ArrowRight = 39 }; foreach ($t in 'rawKeyDown', 'keyUp') { [void](Send-Cdp 'Input.dispatchKeyEvent' @{ type = $t; key = $k; code = $k; windowsVirtualKeyCode = $c[$k] }) } }
function Click($sel) {
$r = J "(()=>{const e=document.querySelector('$sel');const r=e.getBoundingClientRect();return JSON.stringify([r.left+r.width/2,r.top+r.height/2])})()"
foreach ($t in 'mouseMoved', 'mousePressed', 'mouseReleased') { [void](Send-Cdp 'Input.dispatchMouseEvent' @{ type = $t; x = $r[0]; y = $r[1]; button = 'left'; clickCount = 1 }) }
}
# Per-frame recorder of the camera, so continuity can be checked frame by frame.
$rec = "window.__f=[];(function l(ts){const i=Scene.info();__f.push([ts||performance.now(),i.t,i.talkOpacity,i.roomVisible?1:0,document.body.dataset.mode]);if(__f.length<2000)requestAnimationFrame(l)})();1"
$finite = 'document.getAnimations().filter(a=>a.playState==="running"&&a.effect&&a.effect.getTiming().iterations!==Infinity).length'
$null = Start-Browser 9351
try {
Set-View 1600 1000
Open-Page "${url}?theme=moon&tier=0"; Wait-Ms 1200
# A. Space: mouse click walks you closer (continuous), and back.
[void](Invoke-Js $rec)
Click '#start-talk'; Wait-Ms 150
$a = J 'JSON.stringify(Scene.info())'
Wait-Ms 2600
$b = J 'JSON.stringify(Object.assign(Scene.info(),{conv:getComputedStyle(document.querySelector("#conversation")).opacity,mode:document.body.dataset.mode}))'
Check 'click-enter-moves-camera-gradually' ($a.t -gt 0.02 -and $a.t -lt 0.9 -and $b.t -eq 1 -and $b.settled -and [double]$b.conv -gt .99) @{ at150ms = $a.t; at2750ms = $b.t; conversationOpacity = $b.conv }
$f = J 'JSON.stringify(__f)'
$maxStep = 0; $gap = 0
for ($i = 1; $i -lt $f.Count; $i++) { $maxStep = [math]::Max($maxStep, [math]::Abs($f[$i][1] - $f[($i - 1)][1]) / [math]::Max(.001, ($f[$i][0] - $f[($i - 1)][0]) / 1000)); if ($f[$i][2] -lt .999 -and $f[$i][3] -eq 0) { $gap++ } }
Check 'enter-no-jump-no-black-frame' ($maxStep -lt 4 -and $gap -eq 0) @{ frames = $f.Count; maxCameraSpeedPerSecond = [math]::Round($maxStep, 4); framesWithNeitherStillFullyShown = $gap }
Save-Shot "$out\desktop-talk-moon.png"
$pt = J 'JSON.stringify([...document.querySelectorAll(".plate .photo")].map(p=>p.getAnimations().length))'
$tr = J 'JSON.stringify([document.querySelector("#plate-room").style.transform,document.querySelector("#plate-talk").style.transform])'
Check 'photos-never-warped-or-animated' (($pt | Measure-Object -Sum).Sum -eq 0 -and ($tr -join ' ') -notmatch 'scale\([^)]*,|skew|rotate|matrix') @{ photoAnimations = $pt; plateTransforms = $tr }
# B. Rapid toggle: leave while still walking in; it turns around smoothly from where it is.
Click '#close-talk'; Wait-Ms 1600
[void](Invoke-Js $rec)
Click '#start-talk'; Wait-Ms 260
$m1 = J 'JSON.stringify(Scene.info())'
Click '#close-talk'; Wait-Ms 60
$m2 = J 'JSON.stringify(Scene.info())'
Click '#start-talk'; Wait-Ms 120; Click '#close-talk'; Wait-Ms 1800
$m3 = J 'JSON.stringify(Object.assign(Scene.info(),{mode:document.body.dataset.mode,talkVis:getComputedStyle(document.querySelector("#plate-talk")).visibility}))'
$f = J 'JSON.stringify(__f)'
$maxStep = 0; $gap = 0
for ($i = 1; $i -lt $f.Count; $i++) { $maxStep = [math]::Max($maxStep, [math]::Abs($f[$i][1] - $f[($i - 1)][1]) / [math]::Max(.001, ($f[$i][0] - $f[($i - 1)][0]) / 1000)); if ($f[$i][2] -lt .999 -and $f[$i][3] -eq 0) { $gap++ } }
Check 'rapid-toggle-redirects-smoothly' ($m1.t -gt 0.05 -and $m1.t -lt .95 -and [math]::Abs($m2.t - $m1.t) -lt .2 -and $m3.t -eq 0 -and $m3.mode -eq 'home' -and $maxStep -lt 4 -and $gap -eq 0) @{ midway = $m1.t; justAfterTurn = $m2.t; final = $m3.t; mode = $m3.mode; maxCameraSpeedPerSecond = [math]::Round($maxStep, 4); framesWithNeitherStillFullyShown = $gap; talkPlate = $m3.talkVis }
# C. Keyboard: Escape and arrows land at once (HEL-795 rule), no long animation, no re-played ink.
Click '#start-talk'; Wait-Ms 1800
[void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 9000
Key 'ArrowLeft'; Wait-Ms 80
$k1 = J "JSON.stringify({finite:$finite,c:__proto.state.cursor,flow:__proto.state.flow,ink:document.querySelectorAll('#stage .is-current .ink').length,latest:!document.querySelector('#latest').hidden})"
Check 'arrow-left-instant' ($k1.finite -eq 0 -and $k1.ink -eq 0 -and -not $k1.flow -and $k1.latest) $k1
Key 'ArrowRight'; Wait-Ms 80
$k2 = J "JSON.stringify({finite:$finite,c:__proto.state.cursor,ink:document.querySelectorAll('#stage .is-current .ink').length})"
Check 'arrow-right-instant' ($k2.finite -eq 0 -and $k2.ink -eq 0 -and $k2.c -eq $k1.c + 1) $k2
Key 'Escape'; Wait-Ms 80
$k3 = J "JSON.stringify(Object.assign(Scene.info(),{finite:$finite,mode:document.body.dataset.mode,homeOpacity:getComputedStyle(document.querySelector('.home-copy')).opacity}))"
Check 'escape-leaves-instantly' ($k3.finite -eq 0 -and $k3.t -eq 0 -and $k3.mode -eq 'home' -and [double]$k3.homeOpacity -gt .99) @{ finiteAnimations = $k3.finite; camera = $k3.t; mode = $k3.mode; homeOpacity = $k3.homeOpacity }
Save-Shot "$out\desktop-home-moon.png"
# D. Reading (carried over from PR#8): auto to the end, pause/review/return, send resumes, identity, cancel, history.
Click '#start-talk'; Wait-Ms 1500
[void](Invoke-Js "__proto.runScenario('long')")
for ($i = 0; $i -lt 70; $i++) { Wait-Ms 1000; $s = J 'JSON.stringify({c:__proto.state.cursor,n:__proto.pages().length,busy:__proto.state.busy})'; 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 }
$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"
[void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 6000
Click '#toggle-flow'; $a = J '__proto.state.cursor'; Wait-Ms 6000; $b = J '__proto.state.cursor'
Check 'pause-holds-position' ($a -eq $b) @{ before = $a; after6s = $b }
Click '#previous'; 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"
Click '#latest'; 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 }
[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,draft:document.querySelector("#draft").value})'
Check 'send-gives-feedback-and-resumes-auto' ($r.flow -and $r.c -ge 1 -and $r.draft -eq '') $r
$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
[void](Invoke-Js "__proto.runScenario('waiting')"); Wait-Ms 3500; Click '#stop'; Wait-Ms 600
$x = J 'JSON.stringify((()=>{const m=__proto.pages().map(p=>p.message).pop();return {status:m.status,len:m.text.length,busy:__proto.state.busy}})())'
Check 'cancel-keeps-partial-marked-incomplete' ($x.status -eq 'cancelled' -and $x.len -gt 0 -and -not $x.busy) $x
[void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 7000; $a = J '__proto.state.cursor'
Click '#history'; Wait-Ms 3000; $b = J '__proto.state.cursor'
$mid = J 'JSON.stringify(Object.assign(Scene.info(),{mode:document.body.dataset.mode}))'
Save-Shot "$out\desktop-history-moon.png"
Click '#close-sheet'; Wait-Ms 1200
$c = J 'JSON.stringify({c:__proto.state.cursor,mode:document.body.dataset.mode})'
Check 'history-holds-she-stays-close-then-returns' ($a -eq $b -and $c.mode -eq 'talk' -and $mid.mode -eq 'record' -and $mid.t -eq 1) @{ before = $a; whileOpen = $b; cameraWhileOpen = $mid.t; after = $c }
# E. Entries lead somewhere (demo content, clearly marked).
$titles = @()
foreach ($step in @("document.querySelector('[data-nav=days]').click()", "document.querySelector('[data-event=\""0\""]').click()", "document.querySelector('[data-event-history]').click()", "document.querySelector('[data-nav=manage]').click()", "document.querySelector('[data-action=relationship]').click()", "document.querySelector('[data-action=manage]').click()", "document.querySelector('[data-action=backup]').click()", "document.querySelector('[data-action=manage]').click()", "document.querySelector('[data-action=services]').click()")) {
[void](Invoke-Js $step); Wait-Ms 700; $titles += (J 'JSON.stringify(document.querySelector("#sheet-kicker").textContent+" / "+document.querySelector("#sheet-title").textContent)')
}
Check 'entries-open-demo-content' ($titles.Count -eq 9 -and ($titles | Where-Object { -not $_ }).Count -eq 0) $titles
Save-Shot "$out\desktop-manage-moon.png"
[void](Invoke-Js "window.__mic=0;if(navigator.mediaDevices){navigator.mediaDevices.getUserMedia=()=>{__mic++;return Promise.reject()}};1")
[void](Invoke-Js "document.querySelector('[data-action=voice]').click()"); Wait-Ms 600
[void](Invoke-Js "document.querySelector('[data-action=start-voice]').click()"); Wait-Ms 1500
$v = J 'JSON.stringify({mic:__mic,row:!document.querySelector("#voice-row").hidden,presence:document.querySelector("#presence").textContent})'
Check 'voice-demo-never-asks-microphone' ($v.mic -eq 0 -and $v.row) $v
Click '#end-voice'; Wait-Ms 400
# F. Themes: same structure and positions, different light.
[void](Invoke-Js "__proto.setMode('talk',{instant:true})"); Wait-Ms 800
$p1 = J 'JSON.stringify(["#composer",".navigation","#stage","#close-talk"].map(s=>{const r=document.querySelector(s).getBoundingClientRect();return [Math.round(r.left),Math.round(r.top),Math.round(r.width)]}))'
Click '[data-theme=fog]'; Wait-Ms 1600
$p2 = J 'JSON.stringify(["#composer",".navigation","#stage","#close-talk"].map(s=>{const r=document.querySelector(s).getBoundingClientRect();return [Math.round(r.left),Math.round(r.top),Math.round(r.width)]}))'
$acc = J 'JSON.stringify(getComputedStyle(document.documentElement).getPropertyValue("--accent").trim())'
Check 'themes-keep-structure' ((($p1 | ConvertTo-Json -Compress) -eq ($p2 | ConvertTo-Json -Compress)) -and $acc -eq '#ebbcff') @{ moon = $p1; fog = $p2; accent = $acc }
Save-Shot "$out\desktop-talk-fog.png"
[void](Invoke-Js "__proto.setMode('home',{instant:true})"); Wait-Ms 1200
Save-Shot "$out\desktop-home-fog.png"
# G. Layout at every size: her face, the lines, the composer and the navigation never cover each other.
$sizes = @(@(1600, 1000, 0), @(1440, 900, 0), @(1280, 720, 0), @(390, 844, 1), @(360, 740, 1), @(320, 568, 1), @(390, 430, 1))
foreach ($size in $sizes) {
$mob = [bool]$size[2]
Set-View $size[0] $size[1] -mobile:$mob -dpr 1; Open-Page "${url}?theme=moon&tier=0"; Wait-Ms 900
[void](Invoke-Js "__proto.setMode('talk',{instant:true})"); [void](Invoke-Js "__proto.runScenario('long')"); Wait-Ms 1500
if ($size[1] -lt 500) { [void](Invoke-Js "document.querySelector('#draft').focus();document.body.classList.add('typing');1") }
$worst = @{ face = 999; composer = 999; nav = 999; right = 999 }
for ($i = 0; $i -lt 14; $i++) {
Wait-Ms 1500
$g = J 'JSON.stringify((()=>{const ls=[...document.querySelectorAll("#stage .line:not(.leaving)")].filter(l=>getComputedStyle(l).display!=="none");if(!ls.length)return null;const R=e=>e.getBoundingClientRect();const top=Math.min(...ls.map(l=>R(l.querySelector("p")).top)),bottom=Math.max(...ls.map(l=>R(l).bottom)),right=Math.max(...ls.map(l=>R(l.querySelector("p")).right));const c=R(document.querySelector("#composer")),n=R(document.querySelector(".navigation")),h=Scene.info().head,navShown=getComputedStyle(document.querySelector(".navigation")).opacity>.5;return {face:top-h.bottom,composer:c.top-bottom,nav:navShown?n.top-c.bottom:99,right:innerWidth-right}})())'
if ($g) { foreach ($k in 'face', 'composer', 'nav', 'right') { $worst[$k] = [math]::Min($worst[$k], $g.$k) } }
}
if ($size[0] -eq 390 -and $size[1] -eq 430) { Save-Shot "$out\mobile-keyboard-430.png" }
if ($size[0] -eq 320) { Save-Shot "$out\mobile-320-talk.png" }
$ok = $worst.face -ge -4 -and $worst.composer -ge 0 -and $worst.nav -ge 0 -and $worst.right -ge 0
Check "no-overlap-while-flowing-$($size[0])x$($size[1])" $ok @{ minGapBelowHerChinPx = [math]::Round($worst.face, 1); minGapAboveComposerPx = [math]::Round($worst.composer, 1); minGapComposerToNavPx = [math]::Round($worst.nav, 1); minRightMarginPx = [math]::Round($worst.right, 1) }
}
# H. Long name on a phone.
Set-View 390 844 -mobile -dpr 2; Open-Page "${url}?theme=fog&tier=0"; Wait-Ms 1000
[void](Invoke-Js "document.querySelector('#rehearse').click()"); Wait-Ms 500
[void](Invoke-Js "document.querySelector('[data-action=create]').click()"); Wait-Ms 500
[void](Invoke-Js "(()=>{document.querySelector('#new-name').value='星河与晚风之间慢慢醒来的小月亮';document.querySelector('#new-relation').value='朋友';document.querySelector('#create-form').requestSubmit();return 1})()"); Wait-Ms 2500
$ov = J 'JSON.stringify((()=>{const b=document.querySelector("#stage .is-current .who b"),t=document.querySelector(".identity strong");return {name:b.textContent,lineFits:b.getBoundingClientRect().right<=innerWidth,topFits:t.getBoundingClientRect().right<=innerWidth}})())'
Check 'long-name-contained' ($ov.lineFits -and $ov.topFits) $ov
Save-Shot "$out\mobile-long-name.png"
# I. Reduced motion: no camera travel, a short crossfade only; ambient loops off.
[void](Send-Cdp 'Emulation.setEmulatedMedia' @{ features = @(@{ name = 'prefers-reduced-motion'; value = 'reduce' }) })
Set-View 1600 1000; Open-Page "${url}?theme=moon&tier=0"; Wait-Ms 1000
$s0 = J 'JSON.stringify(document.querySelector("#plate-room").style.transform)'
Click '#start-talk'; Wait-Ms 90
$rm = J 'JSON.stringify(Object.assign(Scene.info(),{room:document.querySelector("#plate-room").style.transform,loops:document.getAnimations().filter(a=>a.playState==="running"&&a.effect.getTiming().iterations===Infinity).length}))'
Wait-Ms 400
$rm2 = J 'JSON.stringify(Scene.info())'
Check 'reduced-motion-crossfade-only' ($rm.room -eq $s0 -and $rm.t -eq 1 -and $rm.talkOpacity -gt 0 -and $rm.talkOpacity -lt 1 -and $rm.loops -eq 0 -and $rm2.talkOpacity -eq 1) @{ roomUnmoved = ($rm.room -eq $s0); at90ms = $rm.talkOpacity; at490ms = $rm2.talkOpacity; ambientLoops = $rm.loops }
[void](Send-Cdp 'Emulation.setEmulatedMedia' @{ features = @() })
# J. Slow-device tier: no per-frame mask, no backdrop blur, half-resolution rain.
Open-Page "${url}?theme=fog&tier=2"; Wait-Ms 1000
Click '#start-talk'; Wait-Ms 300
$t2 = J 'JSON.stringify(Object.assign(Scene.info(),{blur:getComputedStyle(document.querySelector("#composer")).backdropFilter,rain:document.querySelector("#plate-room canvas").width}))'
Check 'low-tier-fallback' ($t2.tier -eq 2 -and -not $t2.mask -and $t2.blur -eq 'none' -and $t2.rain -eq 800) $t2
Wait-Ms 1500
Save-Shot "$out\desktop-tier2-fog.png"
} finally { Stop-Browser }
$results | ConvertTo-Json -Depth 7 | Set-Content "$out\verification.json" -Encoding utf8
$p = @($results | Where-Object { $_.pass }).Count
"$p / $($results.Count) passed"