Compare commits

..
1 Commits
Author SHA1 Message Date
白栖知andmultica-agent ca5ddd6cb4 HEL-790: add isolated conversation and immersive-space prototype
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 20:12:47 +08:00
48 changed files with 3892 additions and 95 deletions
-2
View File
@@ -11,8 +11,6 @@
# 二进制
*.wav binary
*.mp3 binary
*.mp4 binary
*.webp binary
*.png binary
*.jpg binary
*.onnx binary
-2
View File
@@ -10,8 +10,6 @@
## 施工和交接
- 白栖知于主卡评论 `01a163a5-e2a7-7ac8-afdb-991868e721bc` 明确:本项目整体视觉由视觉总监接手设计与实际落地,职责不止审核把关。施工员无法实现对应效果时,由视觉总监直接编写相关代码。本轮对话及沉浸界面交由视觉总监亲自负责;总管负责协调、核对需求和交付,不再以自己的视觉原型替代它接手。此项是CyberGril项目分工,不修改队员在其他项目的全局职责、模型或认证。
- 使用 `docs/requirements/task-handoff-template.md`,写出本次交付及完整需求的关联;完整当前需求始终有效。阶段不做的内容列明承接,不删除需求。
- 需求、技术方案、阶段范围、实现状态、用户验收分开记录。不得把智能体建议、技术困难、模型登录失败或某阶段通过当作修改需求的授权。
- 不用“当前一两句”以外的长消息滚动主界面替代已确认的交流体验。长回复需完整可读,具体分句等方案先通过动态小样验证。
+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.
+3 -5
View File
@@ -1,12 +1,12 @@
# CyberGril 需求入口
当前完整基准:**v1.8**。批准需求继承办法的主卡评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
当前完整基准:**v1.7**。批准需求继承办法的主卡评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
所有新任务、续办、设计、施工、审核先读完整当前需求,再读本次相关来源。只有白栖知明确修改的要求才可变更,阶段范围不能覆盖产品要求。
| 文件 | 用途 |
| --- | --- |
| [current.md](current.md) | 59条完整有效要求:原31条产品决定、13条视觉、3条补充、6条用户整改、5条执行约束、1条交付边界;另列未定项 |
| [current.md](current.md) | 58条完整有效要求:原31条产品决定、13条视觉、3条补充、6条用户整改、4条执行约束、1条交付边界;另列未定项 |
| [baseline.json](baseline.json) | 同一份要求的结构化编号、边界和来源 |
| [coverage.md](coverage.md) / [coverage.json](coverage.json) | 实现与验收状态,未完成项目继续保留 |
| [changes.md](changes.md) / [changes.json](changes.json) | 后续修改的用户授权及影响 |
@@ -15,7 +15,7 @@
| [source-manifest.json](source-manifest.json) | 来源文件校验值,防止历史来源被静默改写 |
| [implementation-verification.md](implementation-verification.md) | 本次实际写入、旧卡迁移与正反向验证记录 |
`sources/comment-evidence.json` 保存初版125条来源评论(保留原样)的身份、时间、原话和父评论;正文中的编号可追溯回主卡。原始初轮访谈与完整视觉访谈均用分页取齐,后续材料按关联决定保存。其余来源文档保留原样;其中旧进度、技术推荐、旧待定项不能自动成为当前事实。已经被后续明确决定覆盖的内容,见 current.md 和 changes.md。
`sources/comment-evidence.json` 保存125条来源评论的身份、时间、原话和父评论;正文中的编号可追溯回主卡。原始初轮访谈与完整视觉访谈均用分页取齐,后续材料按关联决定保存。其余来源文档保留原样;其中旧进度、技术推荐、旧待定项不能自动成为当前事实。已经被后续明确决定覆盖的内容,见 current.md 和 changes.md。
原仓库 `docs/SPEC.md` 是初始构想,不能盖过后续逐项确认:例如旧VRM路线、允许经历改写出生核心等,均不能用于覆盖当前要求。
@@ -31,6 +31,4 @@
当前没有配置仓库自动CI合并门禁;上述命令作为交付和独立审核的必做项,不声称平台已强制拦截所有修改。
v1.8新增来源见 `sources/comment-evidence-visual-role-20261022.json`,最新分工见GOV-005。
后续以该入口所记录的最新版本为准,不把v1.7永久钉死为永不可新增的文档。每次新版本必须记录用户明确裁定,保留旧版来源与覆盖关系。
+3 -13
View File
@@ -1,9 +1,9 @@
{
"schema_version": 1,
"version": "1.8",
"approval_comment_id": "01a163a5-e2a7-7ac8-afdb-991868e721bc",
"version": "1.7",
"approval_comment_id": "01a1635b-b47c-79ed-ac66-9408c7905af9",
"owner_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"scope": "完整要求;v1.8按用户明确指令新增视觉总监接手及必要代码职责,原58项和已批准边界继续有效,不证明功能已实现或已验收。",
"scope": "完整要求;版本1.7整合已确认内容并新增用户批准的需求继承约束,不证明已实现或已验收。",
"requirements": [
{
"id": "CG-001",
@@ -683,16 +683,6 @@
"01a1629c-732f-7559-8417-6f2ea05c7151"
],
"source_document": "sources/integration-plan-v1.md"
},
{
"id": "GOV-005",
"category": "执行分工",
"topic": "视觉总监接手整体视觉及必要代码",
"conclusion": "CyberGril整体视觉由视觉总监接手设计与实际落地,职责不止审核把关;施工员无法写出对应代码时,直接由视觉总监编写。本轮对话与沉浸界面交由视觉总监亲自负责。",
"boundary": "总管负责协调、完整需求核对与交付;不再用总管制作替代视觉总监接手。分工限本项目,不修改队员其他项目的全局模型、职责或认证;原产品需求与小样先验收再替换正式界面的边界继续有效。",
"source_comment_ids": [
"01a163a5-e2a7-7ac8-afdb-991868e721bc"
]
}
],
"open_items": [
-10
View File
@@ -67,16 +67,6 @@
"VIS-013"
],
"impact": "约束新旧卡及独立界面小样;不改产品数据、模型与现有服务。"
},
{
"version": "1.8",
"approval_comment_id": "01a163a5-e2a7-7ac8-afdb-991868e721bc",
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"summary": "新增GOV-005:整体视觉交给视觉总监接手,不止把关;施工员不能实现时由视觉总监直接写代码。",
"affected_ids": [
"GOV-005"
],
"impact": "本项目本轮视觉设计、动效与相应代码责任转给视觉总监;不改现网、数据、全局模型或认证。"
}
]
}
-6
View File
@@ -5,9 +5,3 @@ v1.7:把原始31项、已确认视觉和后续用户裁定整理到同一入
原始 v1.6 原样保留。后续生活披露、初始关系和名字的覆盖见 current.md 的 AM-001–003 及对应来源,不回写历史。旧文档中的历史进度不作为现在的事实。
以后每次改需求,记录:原要求、新要求、白栖知明确原话及评论编号、涉及编号、影响范围。智能体建议、实现困难、阶段通过均不能替代用户授权。检查脚本只能检查材料完整性,审核者仍必须核对原话。
## v1.8:视觉总监接手
白栖知明确裁定:整体视觉交给视觉总监接手,而不是把关,甚至若施工员无法写出相应代码,直接让视觉总监写代码
来源评论:`01a163a5-e2a7-7ac8-afdb-991868e721bc`。旧分工为总管因环境失败接手制作、后续视觉总监只把关;现按GOV-005交视觉总监亲自设计与落地,必要时直接写代码。本次不改变产品功能与原验收边界。
+1 -11
View File
@@ -1,5 +1,5 @@
{
"baseline_version": "1.8",
"baseline_version": "1.7",
"items": [
{
"requirement_id": "CG-001",
@@ -348,16 +348,6 @@
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "GOV-005",
"status": "用户恢复原运行时后,视觉总监已在HEL-793真实运行;设计与代码由其亲自负责",
"evidence": [
"01a163a5-e2a7-7ac8-afdb-991868e721bc",
"01a163be-0bbc-7a74-8dc1-a3bcee89cc58",
"running:01a163bc-9134-75e4-aa28-71c6371ff692"
],
"next_step": "视觉总监交实际样图和动态代码;总管按完整需求核对后交用户验收,保持用户设定的运行时"
}
]
}
-1
View File
@@ -62,6 +62,5 @@
| GOV-003 | 本版本已写入规则和检查;主分支合入及后续实际执行仍需核验 | 独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行 |
| GOV-004 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| DEL-001 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| GOV-005 | 用户恢复原运行时,视觉总监已在HEL-793真实运行 | 由其交实际设计与代码,保持用户配置 |
详细证据位于 `coverage.json`;空证据表示尚无本基准下整项合格证据,不表示需求取消。
+2 -10
View File
@@ -1,6 +1,6 @@
# CyberGril 完整当前需求 · v1.8
# CyberGril 完整当前需求 · v1.7
完整要求;版本1.8保留原完整要求并新增用户明确的视觉总监接手分工,不证明已实现或已验收。
完整要求;版本1.7整合已确认内容并新增用户批准的需求继承约束,不证明已实现或已验收。
## 阅读与效力
@@ -502,14 +502,6 @@
关联全文:`sources/integration-plan-v1.md`。
## GOV-005 · 视觉总监接手整体视觉及必要代码
CyberGril整体视觉由视觉总监接手设计与实际落地,职责不止审核把关;施工员无法写出对应代码时,直接由视觉总监编写。本轮对话与沉浸界面交由视觉总监亲自负责。
边界:总管负责协调、完整需求核对与交付;不再用总管制作替代视觉总监接手。分工限本项目,不修改队员其他项目的全局模型、职责或认证;原产品需求与小样先验收再替换正式界面的边界继续有效。
来源评论:`01a163a5-e2a7-7ac8-afdb-991868e721bc`。
## 尚未确定或尚未验证
### T-01 · 八字映射细则
+62 -15
View File
@@ -1,14 +1,67 @@
{
"baseline_version": "1.8",
"read_commit": "4242f8377e350e5f17e836d275c15b0f099bf109",
"baseline_version": "1.7",
"read_commit": "498429399812a0776dadd4b56662d31b455a9ea5",
"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": "按用户明确指令记录视觉总监接手设计与相应代码职责,并交接本轮返工",
"deliverable": "完整需求、来源档案、执行约束和检查命令,项目与旧卡统一入口;不是产品功能交付",
"acceptance": [
"58项原意、边界、来源完整;初轮及视觉对话取齐",
"版本变更、来源、覆盖、交接缺项检查可运行",
@@ -19,17 +72,11 @@
"R1修正与R2–R7真实接入继续保留",
"CI自动合并门禁尚未配置,当前检查由交付与审核执行"
],
"requirement_changes": [
{
"approval_comment_id": "01a163a5-e2a7-7ac8-afdb-991868e721bc",
"requirement_id": "GOV-005"
}
],
"requirement_changes": [],
"verification": [
"59项需求及交接检查通过;原始来源和变更历史保留",
"项目规则、主卡与返工卡已明确视觉总监亲自接手设计与相应代码",
"用户手动恢复原运行时后,HEL-793运行01a163bc-9134-75e4-aa28-71c6371ff692真实开始;已读旧稿与样图并检查浏览器;没有重复派工",
"追溯确认此前2026-10-22T09:31:24Z的运行时变更来自总管执行01a162f2-61a5-7c6a-9704-44c8e15219ff,未经用户事先批准;已向用户如实说明",
"主卡恢复in_progress;用户设定的运行时、模型与认证未再更改,正式界面及用户数据保持原状"
"58项正向结构与交接检查通过",
"4项反向检查均拦截:覆盖漏项、历史来源改动、改需求不升版、过期交接",
"项目说明和仓库资源回读一致;39张未关闭子卡和主卡入口逐张回读,原状态和负责人未改",
"独立审核待总工执行,仓库主分支尚未合入;此处不声称CI门禁已配置"
]
}
+1 -2
View File
@@ -6,6 +6,5 @@
"frontend-architecture-v1.md": "eb05f1707a932014d613072e8c5d95bc8d6a0281390dc93c000d63851ecbbc86",
"product-decisions-v1.6.json": "ccc5c50c5604385208eb054caac01e27fc63367dac7731e1be9775a1e5cf46ab",
"visual-confirmation-v2.md": "6e0229aaf693f215cc9dcc647be694aeafb3770683b21bb7e3e30291af60255a",
"integration-plan-v1.md": "454af1d4a3b0360d33aea9c0e97f738b320968ba7be68e012a5fcec6f6ed0169",
"comment-evidence-visual-role-20261022.json": "ecf30eb323129dca2a0d9c2ff2e0163884ee94d3fc7828b0540cd45f03103797"
"integration-plan-v1.md": "454af1d4a3b0360d33aea9c0e97f738b320968ba7be68e012a5fcec6f6ed0169"
}
@@ -1,10 +0,0 @@
[
{
"id": "01a163a5-e2a7-7ac8-afdb-991868e721bc",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "整体视觉交给视觉总监接手,而不是把关,甚至若施工员无法写出相应代码,直接让视觉总监写代码",
"created_at": "2026-10-22T12:46:18Z",
"parent_id": "01a1639b-8416-7b0b-8dbf-0de16c257cb5"
}
]
+49
View File
@@ -0,0 +1,49 @@
# 月光里的相处 · HEL-790 独立动态小样
下载 `index.html`,用浏览器直接打开;图片、文字、样式和交互都在这一个文件里,不需要安装或联网。`evidence/` 是这份真实界面的截图、录屏与检查记录。此目录没有被产品入口导入。
这次请只判断对话的呈现和整体空间的感觉。两套主题都保留。所有人物画面是此前获批静帧,文字、生活、回忆和天气都是虚构示例;没有连接模型、麦克风、摄像头、生活或备份服务。
## 怎样试
1. 相处页保留右侧独立人物窗、左侧少量近况和底部透光导航。点击「和她说话」,看人物窗怎样在同一片空间里展开,再点「回到此刻」。
2. 点击右上角「独立小样 · 演示数据」,选择「一段长回复」。当前只呈现一句;用左右箭头逐句读完,也可以选「自动续读」。点击「展开全文」或输入区左边的记录按钮,查看完整内容,关闭后回到刚才那一句。
3. 同一试用浮层还可以体验短句、连续来两段话、生成中与取消。新来句会排在后面,不抢走正在读的内容。取消后已出现的片段明确标为未完成。
4. 切换月光/雾光,再进入「日子」「我们的回忆」「设置」。从一条已分享生活进入对应聊天,可以返回这一天。新建临时演示身份后,这些区域为空。
5. 手机可直接打开同一 HTML。收起或弹出键盘时,布局按可视高度重新给人物与正文分配空间。本次只验证了缩小视口,未在真实手机软键盘、iOS 或 Safari 上验证。
## 本次交互提案,仍待用户验收
- 默认手动续读;自动续读为可选,每句至少停留 3.8 秒,并随文字长度延长。输入、选择文字、打开记录或切到后台都会停止自动续读。
- 句子按标点、可读长度分段,长句不会丢字;空间很小时段落会更短。上一句/下一句同时覆盖本轮用户与她的文字。身份每句注明。
- 本原型按完整句子逐步模拟生成,不等同于已连接真实 SSE。真实词元流如何缓存成可读分句,需要在正式接入时验证。
- 人物展开、浮层和背景的空间推进均可逆;文本以 100ms 退场、160ms 进入过渡,流式追加的同一句不重播整段动画。移动采用已有缓动与 280ms 预算。
- 月光是打开小样的临时初始值,不代表用户已经选择产品默认主题。
## Before / After / Why
对照对象是 PR#4 开始时实际 head `db571ba204c8372989f96e1ed9b5cd00d3a306c7` 的代码、8310 静态壳,以及 HEL-744/746 获批原型。访问 8310 时拦截全部 API,未请求真实档案与聊天;因此没有声称亲测真实聊天内容或模型表现。
| Before | After | Why |
| --- | --- | --- |
| 当前主区 `dialog-scroll` 同时容纳完整长消息 | 主区当前一句,前后查看;全文与历史独立按需展开 | 长回复完整保留,同时兑现 VIS-013 的人物为主 |
| 曾以局部文字效果和滚动修补作为主要验收依据 | 一个持续存在的人物窗,展开/收回、进入日子与设置都与背景共同改变层次 | 对照 VIS-001/006/007,验证整个相处空间 |
| 矮屏的固定人物高度会逼近正文 | 按真实正文位置计算人物窗剩余高度;缩小视口后重新分配空间 | 正文、身份、输入和导航互不覆盖 |
| 逐次替换文字可能产生生硬跳变 | 旧句先退、新句再进;同一句追加不重播;迟到内容受取消标记约束 | UX-002,阅读连续且取消可信 |
| 全局新旧状态容易争抢阅读位置 | 新来句排队,全文/历史开合保持当前位置 | 用户掌握阅读速度 |
| 组件逐块换色,容易脱离整体氛围 | 同一布局下同时切换天空、光雾、景物、阅读材质与微光 | 保留已认可的两主题,避免改变视觉方向 |
## 复现与范围
```bash
python3 prototypes/hel-790/build.py
node --check prototypes/hel-790/src/space.js
python3 prototypes/hel-790/verify.py
python3 prototypes/hel-790/record.py
```
构建只用 Python 标准库,复用 `web/public/assets/her-room.jpg` 和 `her-talk.jpg`。验证与录屏使用环境已有的 Python Playwright / Chromium;必要的已有共享库路径由脚本检测,仅作用于本次进程。没有新增产品依赖。
结构化需求交接见 `handoff.json`,逐项映射与实际证据见 `REVIEW.md`。开始时从需求治理 PR#5 读取基准;交付前它已独立合入 main。本卡随后快进到 main `4242f8377e350e5f17e836d275c15b0f099bf109`,逐字节复核必读文件一致,并在本分支直接执行需求检查。相对 main 的改动只在本目录,不夹带产品 PR#4。
本小样未更换正式界面,未部署、未合并,也未改用户档案、出生底色、聊天与记忆。R1 的人格诠释/成长缺口、R2–R7 的真实功能接入和独立口型研究仍须后续完成。整项目 HEL-697 持续保留。
+64
View File
@@ -0,0 +1,64 @@
# HEL-790 交接与体验自查
## 需求与来源
- 完整基准 v1.7;读取提交 `97aaefe5fac77e9746a4ce52d3af94adad15efc1`(需求治理 PR#5)。当时 `origin/main` 为 `498429399812a0776dadd4b56662d31b455a9ea5`,尚无需求入口。
- 已读该提交的 AGENTS.md、需求 README/current/coverage、交接模板、原始产品与视觉访谈 `sources/comment-evidence.json`、获批视觉确认、完整设计规范、设计裁定和 R1–R7 接入方案。
- 交付前 PR#5 已独立合入 main;快进到 `4242f8377e350e5f17e836d275c15b0f099bf109`,核对必读文件与已读版本逐字节一致。最终交接以此 main 提交为准,相对 main 只新增本原型目录。
- 用户批准来源:HEL-697 评论 `01a1635b-b47c-79ed-ac66-9408c7905af9`;原两主题与首页比例批准 `01a15f1f-5a71-783c-a458-b326a0073d69`;完整页面与分享/关系/起名裁定 `01a15f42-2781-700e-b75a-3ae4b023f46c`。这些决定不重新提问。
- 取回 HEL-744 两张获批实际样图、HEL-746 完整原型 R2 并实际查看;两个人物素材与仓库文件逐字节相同。没有新增生成图片。
- 开始前经现有合法 SSH 身份认证 Gitea,重查产品 PR#4 实际 head:`db571ba204c8372989f96e1ed9b5cd00d3a306c7`,base main,open、未合并。仅 fetch/读取,不在其分支修改。
- 实际打开 8310 页面时拦截全部 `/api/**`,只有 health 尝试被拦截,没有读取用户档案或聊天。当前长消息行为对照来自上述 head 的 `ChatPanel.tsx` 与样式,未冒充真实聊天实测。
`handoff.json` 逐条列出本卡关联编号、完整原意和来源评论,及本次可观察证据。
## 本次交付
`index.html` 是可离线打开的真实 DOM 原型,构建只复用获批静帧。相处 → 交流 → 全文/历史 → 收回交流 → 日子/回忆/设置 → 相处都可以实际操作。
首页的 30px 轻标题、右侧 27vw 拱窗、14vw 右边距和小巧底部导航沿用获批几何。交流的人物尺寸按可视高度和实际正文占用分配,不让长名字或缩高视口把文字挤到照片里。生活与设置用透光阅读浮层;人物窗和远近环境在同一空间里后退。
两套主题使用相同 DOM、入口与位置;月光有月面、月路和冷雾,雾光有蓝紫流光和更柔软的雾层,景物、材质与关键微光一起变化。没有新增粒子堆叠或整屏卧室背景。
## 动效自查(Before / After / Why)
| Before | After | Why |
| --- | --- | --- |
| 第一轮试制的固定人物高度在小屏接近正文 | 按正文的实际边界分配人物窗高度 | 优先保证身份、正文、输入和导航不互挡 |
| 第一轮试制的图片默认居中,人物取景偏了 | 恢复获批 `88% 42%` 取景与 1.16 静态裁切 | 人物在相处状态也必须看得见 |
| 试制浮层的退出动画可能留下透明填充状态 | 关闭时取消该节点的全部动画,再恢复空间与焦点 | 反复展开记录仍可见、可操作 |
| 突然替换当前文字 | 100ms 退场、160ms 进入;相同句子追加不重播 | 过渡服务于语义变化,不让生成重复闪动 |
| 对几何属性直接做过渡会逐帧触发布局 | 几何一次落位,再用 FLIP 的 transform 运动 | 背景、人物和浮层的动效只播放 transform/opacity |
| 键盘操作也可能播放切换动效 | 键盘操作不启动位置动画;系统减少动效停止循环 | 遵守频率和可访问性约束 |
入口与退出用已有 `cubic-bezier(.22,1,.36,1)`;空间移动与浮层进入 280ms、退出 180ms。背景的 32–64 秒循环只承担舒缓流动,在减少动效或页面隐藏时停止。正常关闭中可以重新打开,人物窗中途反向从当前可见位置衔接。没有 `transition: all`、零尺寸弹入、ease-in 进入或逐帧宽高动画。
工程自查结论:完成本卡的独立设计原型;视觉和阅读节奏仍待白栖知体验裁定,不能以自查代替认可。性能没有做帧率或低端设备实测,不声称稳定 60fps。
## 验证证据
- `evidence/verification.json`:浏览器验证的逐项结果,包含真实 DOM 逐句读取与完整原文比对、全文末句、连续来句不抢阅读位置、取消迟到消息、长名字、长输入、焦点与反复开合、两主题及可视区域边界。
- 电脑四张:`desktop-home-moon/fog.png`、`desktop-talk-moon/fog.png`,1440×900。
- 手机四张:`mobile-home-moon/fog.png`、`mobile-talk-moon/fog.png`,390×844。
- 补充:长回复末尾、全文末尾、生活详情、共同回忆、设置,以及 `simulated-viewport-*.png`。
- 动态:`desktop-walkthrough.webm` 与 `mobile-walkthrough.webm`,实际点击原型录制,展示对话、长回复、全文、记录、往返、主题、日子/回忆和设置。
- Chromium / Linux;验证尺寸包括 1440×900、1280×720、844×390、390×844、390×600、390×420、360×400、320×568、320×360;两主题均做正文/输入/导航边界检查。缩小视口是等效模拟,不是真机键盘测试。
- JavaScript 语法检查;标准库构建。运行环境已有浏览器共享库只设置到本次进程,没有更改全局库、认证或服务。
- 离线原型用 CSP 禁止网络连接,浏览器检查 HTTP/WebSocket 请求为零,不调用媒体权限,不使用真实或浏览器既有产品存储。
- 需求检查在已快进到最新 main 的本工作树运行,实际目标 `origin/main`;执行记录在 `evidence/requirements-check.txt`。没有把治理 PR 或产品 PR#4 搬进本小样分支。
## 本次不能作为完成结论的事项
1. 分句长度、默认手动翻看、自动续读的停留时长、过渡强度,是这次提出供用户验收的交互方案,不是此前已确认的产品规则。
2. 文字生成只是完整句子的模拟,没有接入真实词元流;正式接入须继续验证流缓存、取消、重连、错误及阅读位置。
3. 照片只证明同一人物/房间/衣服的展示关系,不证明真人活动、视频衔接、口型或语音同步已经实现。
4. 四类入口和关系保留。出生排盘/底色、外观导入、备份恢复和外接服务在本卡主要保留入口与未接入说明;没有重做旧 R2 的所有五十多个状态,更不声称完整功能已补齐。后续正式界面继续继承 R2 完整范围及新小样认可结果。
5. 演示生活只有两条已分享记录,没有携带任何未分享记录,也不模拟服务器权限已完成。R2/R3 仍须在响应前裁剪生活、记忆和关联引用。
6. 临时演示身份只供测试长名字与关系选择;正式创建的出生、底色、关系确认、异常恢复等完整流程仍由原方案约束。本卡未改变、重建真实伴侣。
7. 真机软键盘、Safari/iOS、屏幕阅读器的实际朗读、触摸手势与低端设备性能仍待验证。
## 未完成承接与下一道关卡
先由白栖知只验收这次的对话与空间小样。认可后才能安排正式界面实现、独立审核及测试环境发布;本卡没有部署或合并授权。
主卡 HEL-697 保留完整项目追踪。R1 的出生底色深度、模型诠释、初始喜好与持续成长仍有缺口;R2 生活、R3 记忆、R4 主动消息、R5 连续语音、R6 天气/素材/备份、R7 视觉观察及口型独立线继续按已有前置承接。没有新增、删除或降低产品需求,`requirement_changes` 为空。
+14
View File
@@ -0,0 +1,14 @@
"""Build an offline, single-file prototype. No dependencies or network access."""
import base64
from pathlib import Path
ROOT = Path(__file__).resolve().parent
html = (ROOT / 'src/shell.html').read_text()
for key, filename in [('STYLE', 'space.css'), ('SCRIPT', 'space.js')]:
html = html.replace('{{' + key + '}}', (ROOT / 'src' / filename).read_text())
for key, filename in [('ROOM', 'her-room.jpg'), ('TALK', 'her-talk.jpg')]:
data = (ROOT.parents[1] / 'web/public/assets' / filename).read_bytes()
html = html.replace('{{' + key + '}}', 'data:image/jpeg;base64,' + base64.b64encode(data).decode())
assert '{{' not in html
(ROOT / 'index.html').write_text(html)
print(f'Built offline index.html ({len(html.encode()):,} bytes)')
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1023 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.
@@ -0,0 +1,4 @@
$ python3 scripts/check_requirements.py --base-ref origin/main
需求结构检查通过:58项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
$ python3 scripts/check_requirements.py --base-ref origin/main --handoff prototypes/hel-790/handoff.json
需求结构检查通过:58项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
[
{
"file": "desktop-walkthrough.webm",
"duration": 35.84,
"currentTime": 35.84,
"ended": true,
"decodedCallbacks": 895,
"totalVideoFrames": 896,
"droppedVideoFrames": 0,
"videoWidth": 1280,
"videoHeight": 800,
"inspection": "完整浏览器播放至结束,每2秒取一帧供逐段检视;此统计不是产品性能测试。"
},
{
"file": "mobile-walkthrough.webm",
"duration": 34.84,
"currentTime": 34.84,
"ended": true,
"decodedCallbacks": 870,
"totalVideoFrames": 871,
"droppedVideoFrames": 0,
"videoWidth": 390,
"videoHeight": 844,
"inspection": "完整浏览器播放至结束,每2秒取一帧供逐段检视;此统计不是产品性能测试。"
}
]
+328
View File
@@ -0,0 +1,328 @@
{
"baseline_version": "1.7",
"read_commit": "4242f8377e350e5f17e836d275c15b0f099bf109",
"requirement_ids": [
"CG-004",
"CG-018",
"CG-024",
"CG-031",
"VIS-001",
"VIS-002",
"VIS-003",
"VIS-004",
"VIS-005",
"VIS-006",
"VIS-007",
"VIS-008",
"VIS-009",
"VIS-010",
"VIS-011",
"VIS-012",
"VIS-013",
"UX-001",
"UX-002",
"UX-003",
"UX-005",
"UX-006",
"AM-001",
"AM-002",
"AM-003",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"DEL-001"
],
"deliverable": "HEL-790:独立离线HTML、两主题电脑/手机真实截图、实际动态录屏、可审查PR及本结构化交接。",
"acceptance": [
"相处→交流→全文/历史→收回→生活/回忆/管理→相处的连续空间关系。",
"当前一句身份清楚、柔和进出;长回复可逐句读到末尾,全文与历史按需展开并保留阅读位置。",
"多视口两主题的人物/正文/输入/导航不互挡,明确未测真机键盘。",
"四类入口保留;未接入能力说明;用户只验收小样,不代表完整产品通过。"
],
"deferred": [
"正式界面实现和测试部署:小样认可后再安排独立审核发布。",
"R1出生底色深度、模型诠释、少量初始喜好与持续成长仍有缺口。",
"R2生活与未分享裁剪、R3记忆、R4主动消息、R5连续语音、R6天气/素材/备份、R7视觉观察及独立口型线。",
"R2旧完整设计的所有状态继续继承;本卡聚焦对话和整体空间,部分管理项仅入口说明。",
"真机软键盘、Safari/iOS、屏幕阅读器、低端设备性能未测。"
],
"requirement_changes": [],
"verification": [
"标准库离线构建;node --check语法通过。",
"verify.py真实浏览器检查,实际逐句DOM文字与完整原文比较、多视口遮挡/取消/身份/记录/焦点/离线隔离等;详evidence/verification.json。",
"电脑4张、手机4张两主题主要截图,以及长句/全文末尾/生活回忆/管理/缩小视口补图。",
"实际桌面与手机walkthrough录屏;复放检查另记evidence/video-review.json。",
"需求检查在快进到PR#5已合入的main之后,本分支直接执行--base-ref origin/main,含--handoff;详evidence/requirements-check.txt。",
"体验待白栖知验收。"
],
"requirement_mapping": [
{
"id": "CG-004",
"meaning": "日常可看她在环境中生活;交谈时允许自然切近到同一人物、同一房间、同一套衣服的半身或脸部。",
"source_comment_ids": [
"01a15724-dcaa-7d2b-ae23-430f24ca36b9",
"01a15726-e178-72b0-9a3c-8b2041538730"
],
"this_delivery": "同一持续存在的人物窗展开,复用同一人物/房间/衣服的获批静帧;录屏验证,非真人动态能力结论。"
},
{
"id": "CG-018",
"meaning": "主要在电脑使用,优先大画面沉浸体验;适配手机看画面、文字聊天和连续语音,使用同一伴侣和同一份记忆。",
"source_comment_ids": [
"01a1577b-b65c-787a-b436-b0a74c38fea4",
"01a1577c-d10f-7513-8f58-62b3b387240a"
],
"this_delivery": "9种电脑/手机/矮屏/横屏视口,两个主题,人物/正文/输入/导航分别验证;真机与Safari未测。"
},
{
"id": "CG-024",
"meaning": "先确认人物与房间的写实样图,再验收写实动态小样;验证环境与人物独立变化、同一人物连续性、动作循环衔接、半身/近景交谈与口型语音。用户认可后,再逐步接完整功能,并分别验收。",
"source_comment_ids": [
"01a157a9-bd18-7533-9b21-e5f4c3a90d7c",
"01a157ab-209c-7c8f-956c-40ebb3340f4e",
"01a1582c-7386-7515-bf62-89dfadfea3d3",
"01a1599b-f137-71cc-8af2-a265499b4b6e",
"01a15e92-e689-754a-aad3-4566100962b3"
],
"this_delivery": "仅独立设计小样与实际截图录屏,不把设计通过当实时口型或完整产品通过。"
},
{
"id": "CG-031",
"meaning": "完整前端应覆盖四类要素:相处主界面、交流界面、她的生活与共同回忆、创建与管理。设计需建立在对整个项目的充分理解之上;此前认可的人物和高层卧室图片只属于人物/场景/口型等视觉子模块的参考,不能作为整体前端的固定基座,不能据此先铺满场景再往上叠界面。",
"source_comment_ids": [
"01a15e8d-91ab-739e-84f9-fc4026bfcd70",
"01a15e92-e689-754a-aad3-4566100962b3"
],
"this_delivery": "四类区域均有入口和返回关系;空间背景独立于人物/卧室,非全屏房间叠按钮。"
},
{
"id": "VIS-001",
"meaning": "整体像只属于两人的数字空间,人物、交流、回忆融在其中。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"this_delivery": "人物、对话和回忆在同一环境连续开合。"
},
{
"id": "VIS-002",
"meaning": "静谧梦幻、安静亲密,有空间深度和柔和微光。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"this_delivery": "静谧夜色、远近景物、柔和微光,等待用户亲眼验收。"
},
{
"id": "VIS-003",
"meaning": "切换主题同时改变配色、背景、光影、材质和动效;页面结构与操作位置一致。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"this_delivery": "同一DOM与入口,天空/光雾/材质/微光随主题整体改变。"
},
{
"id": "VIS-004",
"meaning": "靛蓝月光与蓝紫雾光两套都保留;不把原型临时默认主题当成用户指定默认。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"this_delivery": "月光与雾光均保留,临时初始主题不作为默认选择。"
},
{
"id": "VIS-005",
"meaning": "首页只留少量当前状态与入口,其余内容按需展开,完整功能仍保留。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"this_delivery": "首页只保留一句近况、已分享留言与入口,保留获批比例。"
},
{
"id": "VIS-006",
"meaning": "光雾、景深与若隐若现的景物融合;具体画面通过样图验证。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"this_delivery": "远近天际线、光路和两层雾构成独立背景,不铺满卧室。"
},
{
"id": "VIS-007",
"meaning": "平时舒缓流动与景物微动;操作时有明显景深变化和空间过渡。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"this_delivery": "一个人物窗通过FLIP连贯移动,背景共同推进;减少动效与隐藏页暂停。"
},
{
"id": "VIS-008",
"meaning": "内容采用透光浮层,背后保留空间光影,文字清晰可读。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"this_delivery": "日子/回忆/设置/历史都用主题透光阅读浮层,原生dialog管理焦点。"
},
{
"id": "VIS-009",
"meaning": "现代轻盈、字形简洁、标题克制,正文易读。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"this_delivery": "沿用30px轻标题和轻盈字体,正文清楚。"
},
{
"id": "VIS-010",
"meaning": "少量微光集中于关键操作和选中状态,两主题各有强调色。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"this_delivery": "微光仅集中于关键入口与选中状态。"
},
{
"id": "VIS-011",
"meaning": "人物平时作为空间中独立视窗;交流时自然放大。已认可的人物位置、分量与首页文字分量沿用。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"this_delivery": "右侧27vw拱窗与14vw右边距,恢复获批取景;交流时放大。"
},
{
"id": "VIS-012",
"meaning": "底部小巧透光导航,图标配简短文字,选中亮起微光。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"this_delivery": "底部四入口、图标与简短文字,两主题同布局。"
},
{
"id": "VIS-013",
"meaning": "交流展开后以人物为主,只呈现当前一两句对话;完整记录按需展开,文字输入和语音操作均保留。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"this_delivery": "当前一句,前后翻看,全文/历史按需展开,长回复完整保留;细节仍待用户验收。"
},
{
"id": "UX-001",
"meaning": "手机对话不被人物、导航、底部区域遮挡,长句和键盘弹出时仍能阅读与输入;等效视口测试不能冒充真机验证。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
],
"this_delivery": "当前正文实际边界决定剩余人物高度;多尺寸零重叠。键盘只做缩小视口等效验证。"
},
{
"id": "UX-002",
"meaning": "文字有柔和的进入和退场,流式更新不重复闪烁;动态效果必须实际演示,不以 CSS 存在代替验收。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
],
"this_delivery": "退场100ms/进入160ms;同一句追加不重播;真实DOM逐句完整读回并有动态录屏。"
},
{
"id": "UX-003",
"meaning": "记录、详情、管理、首建等滚动区域与两套主题协调,保留清晰可操作性。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
],
"this_delivery": "全文、记录、设置与日子同用主题scrollbar;两主题实际截图。"
},
{
"id": "UX-005",
"meaning": "用户与实际起名的伴侣身份明确,当前对话与完整记录一致,不只依靠位置或颜色猜。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
],
"this_delivery": "每句显示你或实际演示名字;历史身份相同;长名字验证。"
},
{
"id": "UX-006",
"meaning": "按已认可的完整前端设计补齐功能和页面结构,未接通能力说明真实边界,不删成空壳也不伪造已运行。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
],
"this_delivery": "保留相处/交流/生活回忆/创建管理入口和返回关系;未接功能有说明,R2完整状态及真实接入继续承接。"
},
{
"id": "AM-001",
"meaning": "等她讲过才显示,覆盖列表、详情、摘要、关联回忆与消息引用;未知分享状态不展示,不能用链接或搜索绕开。真实接入必须在服务器响应前裁剪,前端过滤不能冒充权限控制。",
"source_comment_ids": [
"01a15f42-2781-700e-b75a-3ae4b023f46c"
],
"this_delivery": "离线包仅两条已经分享的虚构生活,关联聊天一致;新建临时身份后空生活。没有未分享数据;后端权限仍待R2/R3。"
},
{
"id": "AM-002",
"meaning": "创建时由用户选择关系,不预选、不替用户决定;以后改变关系保留记忆与信任,底色不变。覆盖旧记录 T-02 中关系初始值仍待定的部分。",
"source_comment_ids": [
"01a15f42-2781-700e-b75a-3ae4b023f46c"
],
"this_delivery": "演示首建关系无预选,确认后进入小样;正式完整创建沿用原方案。"
},
{
"id": "AM-003",
"meaning": "首次创建由用户提供有效名字,主页、交流、首建与设置统一;参考名不能硬编码成真实身份,不额外新增改名流程。",
"source_comment_ids": [
"01a15f42-2781-700e-b75a-3ae4b023f46c"
],
"this_delivery": "演示名字由用户输入并校验,在顶部、当前句子和历史复用;不新增正式改名流程。"
},
{
"id": "GOV-001",
"meaning": "所有新卡、续办卡、设计卡、施工卡、审核卡从同一完整需求入口开始,不依赖会话记忆或最近一条反馈;现有未完成卡补齐对照项。",
"source_comment_ids": [
"01a1635b-b47c-79ed-ac66-9408c7905af9"
],
"this_delivery": "完整读取v1.7及原话来源,记录精确提交和对应编号。"
},
{
"id": "GOV-002",
"meaning": "只有白栖知明确的新决定可以覆盖原要求,保留原话来源、旧结论、新结论及影响;不能把技术困难、阶段减项、智能体建议或演示通过当成需求修改。",
"source_comment_ids": [
"01a1635b-b47c-79ed-ac66-9408c7905af9"
],
"this_delivery": "没有产品需求变更;分句、速度明确是待用户验收提案。"
},
{
"id": "GOV-003",
"meaning": "每卡列基准版本及提交位置、关联要求编号、交付验收、未完成承接、需求变更和验证证据;自动检查缺项,人工核对含义,用户验收体验。",
"source_comment_ids": [
"01a1635b-b47c-79ed-ac66-9408c7905af9"
],
"this_delivery": "结构化交接、真实截图/录屏/检查与未完成承接;结构检查不代替审美验收。"
},
{
"id": "GOV-004",
"meaning": "先落实需求继承约束,再制作对话显示和整体沉浸界面的电脑/手机样图与可操作动态小样;小样认可前不替换正式界面,不合并当前产品 PR,不改现有伴侣和聊天数据,不切换队员全局模型。",
"source_comment_ids": [
"01a1635b-b47c-79ed-ac66-9408c7905af9"
],
"this_delivery": "独立原型分支,不改正式界面、不合并PR#4、不触碰真实档案和全局认证/模型。"
},
{
"id": "DEL-001",
"meaning": "前端演示通过只代表该阶段通过;主卡保留追踪完整真实功能,后续接入按已批准 R1–R7 依赖顺序推进。阶段和技术方案不能降低完整需求。",
"source_comment_ids": [
"01a161a4-842f-7c26-9be0-1812b720976a",
"01a1629c-732f-7559-8417-6f2ea05c7151"
],
"this_delivery": "主项目不归档,R1修正与R2–R7及口型独立线保留;本卡无部署/合并/真实数据写入。"
}
],
"initial_read_commit": "97aaefe5fac77e9746a4ce52d3af94adad15efc1"
}
File diff suppressed because one or more lines are too long
+40
View File
@@ -0,0 +1,40 @@
"""Record the real offline DOM, not a sequence of mocked screenshots."""
import os
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT=Path(__file__).resolve().parent
OUT=ROOT/'evidence'
LIBS=Path('/home/xiaomo/.local/pwlibs/usr/lib/x86_64-linux-gnu')
if LIBS.exists():os.environ['LD_LIBRARY_PATH']=str(LIBS)
with sync_playwright() as p:
browser=p.chromium.launch(headless=True,args=['--no-sandbox'])
for label,width,height in [('desktop',1280,800),('mobile',390,844)]:
context=browser.new_context(viewport={'width':width,'height':height},record_video_dir=str(OUT),record_video_size={'width':width,'height':height})
page=context.new_page();page.goto((ROOT/'index.html').as_uri());video=page.video
page.wait_for_timeout(1800)
page.click('#start-talk');page.wait_for_timeout(1700)
page.click('#next');page.wait_for_timeout(1400)
page.click('#rehearse');page.wait_for_timeout(1400)
page.locator('[data-scenario="long"]').click();page.wait_for_timeout(3400)
for _ in range(3):page.click('#next');page.wait_for_timeout(1700)
page.click('#read-all');page.wait_for_timeout(1800)
page.locator('#sheet-content').evaluate('(e)=>e.scrollTo({top:e.scrollHeight,behavior:"smooth"})');page.wait_for_timeout(1600)
page.click('#close-sheet');page.wait_for_timeout(850)
page.click('#history');page.wait_for_timeout(1300)
page.click('#close-sheet');page.wait_for_timeout(500)
page.click('#close-talk');page.wait_for_timeout(1500)
page.locator('.theme [data-theme="fog"]').click();page.wait_for_timeout(1800)
page.locator('[data-nav="days"]').click();page.wait_for_timeout(1500)
page.locator('[data-event="0"]').click();page.wait_for_timeout(1500)
page.locator('[data-action="days"]').click();page.wait_for_timeout(500)
page.locator('[data-action="memories"]').click();page.wait_for_timeout(1300)
page.click('#close-sheet');page.wait_for_timeout(1000)
page.locator('[data-nav="manage"]').click();page.wait_for_timeout(1400)
page.click('#close-sheet');page.wait_for_timeout(1300)
context.close();video.save_as(str(OUT/f'{label}-walkthrough.webm'))
original=Path(video.path())
if original.name!=f'{label}-walkthrough.webm':original.unlink()
print(f'{label} walkthrough recorded')
browser.close()
+64
View File
@@ -0,0 +1,64 @@
<!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 · 月光里的相处 · 独立动态小样</title>
<style>{{STYLE}}</style>
</head>
<body data-mode="home">
<svg class="symbols" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><defs>
<symbol id="i-moon" viewBox="0 0 24 24"><path d="M20 15.5A9 9 0 0 1 8.5 4 9 9 0 1 0 20 15.5Z"/></symbol>
<symbol id="i-talk" viewBox="0 0 24 24"><path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5H4l-2 2 1.8-6A8.5 8.5 0 1 1 21 11.5Z"/></symbol>
<symbol id="i-book" viewBox="0 0 24 24"><path d="M5 3h14v18H5zM8 7h8M8 11h6M8 17h8"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 1v2m0 18v2M1 12h2m18 0h2M4 4l2 2m12 12 2 2M4 20l2-2M18 6l2-2"/></symbol>
<symbol id="i-mic" viewBox="0 0 24 24"><rect x="9" y="2" width="6" height="13" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2m-7 9v3m-4 0h8"/></symbol>
<symbol id="i-arrow" viewBox="0 0 24 24"><path d="m5 12 7-7 7 7m-7-7v16"/></symbol>
<symbol id="i-close" viewBox="0 0 24 24"><path d="m6 6 12 12M6 18 18 6"/></symbol>
<symbol id="i-lines" viewBox="0 0 24 24"><path d="M5 6h14M5 12h14M5 18h9"/></symbol>
</defs></svg>
<div class="space-scale" aria-hidden="true">
<div class="sky moon"><div class="moon-halo"></div><div class="moon-disc"></div><div class="moon-road"></div></div>
<div class="sky fog"><div class="ribbon r1"></div><div class="ribbon r2"></div><div class="ribbon r3"></div></div>
<div class="skyline far"></div><div class="horizon"></div><div class="skyline near"></div>
<div class="mist mist-back"></div><div class="mist mist-front"></div><div class="foreground"></div>
</div>
<div class="depth-veil" aria-hidden="true"></div>
<div id="viewport">
<header class="topbar">
<div class="identity"><span class="glint"></span><strong data-name>晚晚</strong><span class="presence">在家 · 醒着</span></div>
<div class="top-actions"><button id="rehearse" class="sample-tag">独立小样 · 演示数据</button><span class="weather">上海 · 小雨 14° <small>示例</small></span><div class="theme" role="group" aria-label="空间主题"><button data-theme="moon" aria-pressed="true">● 月光</button><button data-theme="fog" aria-pressed="false">● 雾光</button></div></div>
</header>
<main id="world" aria-label="相处空间">
<section class="home-copy" aria-label="此刻">
<p class="eyebrow">此 刻</p><h1>书翻到一半,<br>窗外的雨声不大。</h1><p class="quiet">安静,有点倦 <span>·</span> 你们最近挺熟络</p>
<button class="left-note glass" id="shared-note"><span class="note-meta"><span>她留给你的</span><time>今天 15:52</time></span><span>楼下花店的洋桔梗开了。<br>我挑了一小束,放在窗边。</span></button>
<button class="small-link" id="days-link">你不在时,她分享过的小事 <span>›</span></button>
<div class="home-actions"><button class="primary" id="start-talk"><svg><use href="#i-talk"/></svg>和她说话</button><button class="round glass" id="home-voice" aria-label="语音演示"><svg><use href="#i-mic"/></svg></button></div>
</section>
<div class="portal-glow" aria-hidden="true"></div>
<figure class="portal" id="portal">
<img class="room-photo" src="{{ROOM}}" alt="获批参考静帧:她坐在高层卧室沙发上看书">
<img class="talk-photo" src="{{TALK}}" alt="获批参考静帧:同一人物、房间和衣服的交流近景">
<figcaption><span id="scene-label">窗边看书</span><span>参考静帧</span></figcaption>
<div class="portal-top"><span id="portal-state">在你面前</span><button class="close-talk" id="close-talk"><svg><use href="#i-close"/></svg><span>回到此刻</span></button></div>
</figure>
<section class="conversation" id="conversation" aria-label="当前一句" inert>
<div class="reading" id="reading">
<div class="speaker-row"><span id="speaker">晚晚</span><span id="message-status"></span></div>
<p id="sentence">我在。你想说什么?</p>
</div>
<div class="reading-controls"><button id="previous" aria-label="上一句">‹</button><span id="page-count">1 / 1</span><button id="next" aria-label="下一句">›</button><button id="autoplay" aria-pressed="false">自动续读</button><button id="read-all">展开全文</button><button id="stop" hidden>取消回复</button></div>
<div class="voice-row" id="voice-row" hidden><span>语音演示 · 没有收音</span><button id="end-voice">结束演示</button></div>
<form class="composer glass" id="composer"><button type="button" class="round" id="history" aria-label="完整聊天记录"><svg><use href="#i-lines"/></svg></button><textarea id="draft" rows="1" maxlength="4000" placeholder="想和她说点什么…" aria-label="想和她说的话"></textarea><button type="submit" class="send round" aria-label="发送演示消息"><svg><use href="#i-arrow"/></svg></button><button type="button" class="voice-button" id="voice" aria-label="语音演示"><svg><use href="#i-mic"/></svg></button></form>
</section>
</main>
<nav class="navigation glass" aria-label="主要区域">
<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>
</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>
+34
View File
@@ -0,0 +1,34 @@
/* Approved HEL-744/746 palette and home geometry. Motion is isolated here. */
:root{color-scheme:dark;--font-sans:"PingFang SC","HarmonyOS Sans SC","MiSans","Microsoft YaHei","Noto Sans CJK SC",system-ui,sans-serif;--ease-out:cubic-bezier(.22,1,.36,1);--ease-in-out:cubic-bezier(.65,0,.35,1);--dur-fast:160ms;--dur-ui:280ms;--view-h:100dvh;--view-top:0px;--safe-bottom:env(safe-area-inset-bottom,0px)}
html[data-theme="moon"]{--bg-0:#060a1c;--bg-1:#0d1838;--bg-2:#1b2c5e;--text-1:#eaf0ff;--text-2:#b4c2e3;--text-3:#9aaaca;--accent:#a9dcff;--accent-soft:#a9dcff1f;--accent-glow:#96d2ff55;--on-accent:#08142e;--glass:#1c2c5c80;--glass-read:#0a122ede;--glass-line:#cddeff2e;--surface:#0b1330;--window-glow:#ffc48c22;--mist:#7698e630;--horizon:#83b7e438;--far:#1f3863;--near:#182c50;--glass-blur:18px}
html[data-theme="fog"]{--bg-0:#110b26;--bg-1:#22174a;--bg-2:#43307a;--text-1:#f3ecff;--text-2:#d6c4ed;--text-3:#bca7d9;--accent:#ebbcff;--accent-soft:#ebbcff24;--accent-glow:#e2aaff55;--on-accent:#2a0f40;--glass:#3c266c80;--glass-read:#221442de;--glass-line:#ecd8ff2e;--surface:#24154a;--window-glow:#ffaabe22;--mist:#d5a9ff42;--horizon:#e0abfa40;--far:#473267;--near:#36264d;--glass-blur:30px}
*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--bg-0)}body{font:15px/1.7 var(--font-sans);color:var(--text-1);-webkit-font-smoothing:antialiased}button,input,textarea,select{font:inherit;color:inherit}button{border:0;background:none;cursor:pointer}button:disabled{opacity:.35;cursor:default}button:active:not(:disabled){transform:scale(.97)}button{transition:transform var(--dur-fast) var(--ease-out),opacity var(--dur-fast) var(--ease-out)}button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:4px}button svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;flex:none}h1,h2,h3,p,figure{margin:0}h2{font-size:22px;font-weight:350}h3{font-size:18px;font-weight:400}strong{font-weight:500}[hidden]{display:none!important}.symbols,.sr{position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden;white-space:nowrap}.glass{background:var(--glass);border:1px solid var(--glass-line);box-shadow:inset 0 1px 0 #ffffff08,0 20px 80px #02061620;backdrop-filter:blur(var(--glass-blur)) saturate(120%)}.round{width:44px;height:44px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;flex:none}.primary{display:inline-flex;gap:10px;align-items:center;justify-content:center;background:var(--accent);color:var(--on-accent);padding:13px 23px;border-radius:30px;box-shadow:0 0 30px var(--accent-glow)}.small-link{color:var(--text-2);padding:12px 0;font-size:13px;text-align:left}.eyebrow{font-size:12px;letter-spacing:.18em;color:var(--text-3);margin-bottom:15px}.quiet{color:var(--text-2);font-size:14px}.quiet span{margin:0 10px}.subtle{font-size:13px;color:var(--text-2)}
/* The environment is independent of the photo. Three depth planes and a light path. */
.space-scale{position:fixed;inset:-3%;overflow:hidden;transform:scale(1);transition:transform var(--dur-ui) var(--ease-out);pointer-events:none}.sky{position:absolute;inset:0;transition:opacity var(--dur-ui) var(--ease-out)}.sky.moon{background:radial-gradient(ellipse at 22% 8%,#2c4178 0,transparent 49%),linear-gradient(#071027 10%,#102047 58%,#17294d 76%,#0b1530)}.sky.fog{opacity:0;background:radial-gradient(ellipse at 15% 80%,#68498685,transparent 53%),radial-gradient(ellipse at 90% 4%,#5556b66e,transparent 65%),linear-gradient(150deg,#161033,#2c1e59 58%,#46306b 80%,#201235)}html[data-theme="fog"] .sky.fog{opacity:1}html[data-theme="fog"] .sky.moon{opacity:0}.moon-disc{position:absolute;left:16.7%;top:13%;width:72px;height:72px;border-radius:50%;background:#edf2ff;box-shadow:0 0 4px #edf2ff,0 0 60px 15px #9ac2ff30;opacity:.85}.moon-halo{position:absolute;left:calc(16.7% - 220px);top:calc(13% - 220px);width:512px;height:512px;background:radial-gradient(circle,#87b5ff30,transparent 68%)}.moon-road{position:absolute;left:5%;top:63%;width:42%;height:36%;background:radial-gradient(ellipse,#9fcdf71a,transparent 67%);transform:rotate(-16deg)}.ribbon{position:absolute;border-radius:50%;filter:blur(45px);animation:flow 32s var(--ease-in-out) infinite alternate}.r1{left:-15%;top:12%;width:90%;height:28%;background:radial-gradient(ellipse,#c895f942,transparent 68%);transform:rotate(-12deg)}.r2{right:-15%;top:35%;width:70%;height:34%;background:radial-gradient(ellipse,#6d8fee45,transparent 68%);animation-duration:46s}.r3{left:30%;bottom:-10%;width:70%;height:45%;background:radial-gradient(ellipse,#e4a7d529,transparent 68%);animation-duration:60s}.skyline{position:absolute;left:0;right:0;bottom:7%;height:28%;opacity:.4;pointer-events:none}.skyline svg{width:100%;height:100%;fill:var(--far)}.skyline.far{filter:blur(2px)}.skyline.near{height:21%;bottom:-2%;opacity:.55}.skyline.near svg{fill:var(--near)}.horizon{position:absolute;left:-10%;right:-10%;top:67%;height:30%;background:radial-gradient(ellipse at 50% 20%,var(--horizon),transparent 67%);transform:rotate(-3deg)}.mist{position:absolute;left:-20%;width:140%;background:radial-gradient(ellipse,var(--mist),transparent 67%);filter:blur(30px);border-radius:50%;animation:flow 48s var(--ease-in-out) infinite alternate}.mist-back{height:26%;bottom:15%;opacity:.7}.mist-front{height:35%;bottom:-6%;animation-duration:64s;animation-direction:alternate-reverse}.foreground{position:absolute;inset:0;box-shadow:inset 0 -80px 130px #03071955;background:radial-gradient(ellipse at 50% 45%,transparent 35%,#02040c55 100%)}@keyframes flow{from{transform:translate3d(-3%,0,0)}to{transform:translate3d(4%,2%,0)}}.depth-veil{position:fixed;inset:0;background:#020614;opacity:0;transition:opacity var(--dur-ui) var(--ease-out);pointer-events:none}body[data-mode="talk"] .space-scale{transform:scale(1.05)}body[data-mode="talk"] .depth-veil{opacity:.2}body[data-mode="pane"] .space-scale{transform:scale(1.025)}body[data-mode="pane"] .depth-veil{opacity:.36}
#viewport{position:absolute;top:var(--view-top);left:0;right:0;height:var(--view-h);min-height:280px;overflow:hidden}.topbar{height:78px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 32px;position:relative;z-index:3}.identity{display:flex;align-items:center;gap:12px;min-width:0}.identity strong{font-size:18px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.presence{color:var(--text-3);font-size:13px;white-space:nowrap}.glint{width:6px;height:6px;background:var(--accent);border-radius:50%;box-shadow:0 0 14px var(--accent)}.top-actions{display:flex;align-items:center;gap:12px}.sample-tag{border:1px dashed var(--glass-line);border-radius:20px;font-size:11px;padding:4px 10px;color:var(--text-2)}.weather{font-size:12px;color:var(--text-2)}.weather small{color:var(--text-3)}.theme{display:flex;border:1px solid var(--glass-line);padding:3px;border-radius:25px;background:var(--glass)}.theme button{font-size:12px;color:var(--text-2);padding:5px 13px;border-radius:20px}.theme [aria-pressed="true"]{color:var(--text-1);background:var(--accent-soft);box-shadow:0 0 20px var(--accent-glow)}
.home-copy{position:absolute;left:9vw;top:50%;transform:translateY(-42%);width:min(440px,38vw);transition:opacity var(--dur-ui) var(--ease-out),transform var(--dur-ui) var(--ease-out)}h1{font-size:30px;font-weight:300;line-height:1.65;letter-spacing:.01em;margin-bottom:16px}.left-note{display:block;width:100%;text-align:left;padding:18px 20px;border-radius:20px;margin-top:32px;line-height:1.85}.note-meta{display:flex;justify-content:space-between;gap:8px;font-size:12px;color:var(--accent);margin-bottom:10px}.note-meta time{color:var(--text-3)}.home-actions{display:flex;align-items:center;gap:12px;margin-top:18px}.home-actions .round{width:50px;height:50px}.portal{position:absolute;right:14vw;top:calc(50% - 7px);transform:translateY(-50%);width:clamp(300px,27vw,430px);aspect-ratio:1/1.3;border-radius:48% 48% 26px 26px;overflow:hidden;isolation:isolate;border:1px solid #f1e2ce35;box-shadow:0 0 50px #ffdca70a,0 25px 70px #03081855;background:#1e2332;z-index:1}.portal img{position:absolute;width:100%;height:100%;object-fit:cover;object-position:center;transition:opacity var(--dur-ui) var(--ease-out)}.room-photo{opacity:1}.talk-photo{opacity:0}.portal figcaption{position:absolute;bottom:14px;left:17px;right:17px;display:flex;justify-content:space-between;gap:8px;color:#f3ede3;font-size:11px;text-shadow:0 1px 5px #000}.portal figcaption span{padding:3px 9px;border-radius:15px;background:#14132170}.portal-glow{position:absolute;right:4vw;top:28%;width:44vw;height:45%;pointer-events:none;background:radial-gradient(ellipse,var(--window-glow),transparent 70%);filter:blur(25px);opacity:.8;transition:opacity var(--dur-ui) var(--ease-out)}.portal-top{position:absolute;inset:14px 14px auto;display:flex;align-items:flex-start;justify-content:space-between;opacity:0;pointer-events:none;color:#fff}.portal-top>span,.close-talk{background:#10152594;border:1px solid #ffffff20;backdrop-filter:blur(12px);border-radius:24px;padding:7px 13px;font-size:12px}.close-talk{display:flex;align-items:center;gap:5px}.close-talk svg{width:16px;height:16px}body[data-mode="talk"] .portal{top:78px;left:50%;right:auto;transform:translateX(-50%);width:min(960px,72vw);height:calc(var(--view-h) - 365px - var(--safe-bottom));min-height:140px;aspect-ratio:auto;border-radius:28px}body[data-mode="talk"] .talk-photo{opacity:1}body[data-mode="talk"] .portal-top{opacity:1;pointer-events:auto}body:not([data-mode="home"]) .home-copy{opacity:0;transform:translate(-20px,-42%);pointer-events:none}body[data-mode="pane"] .portal{width:clamp(150px,16vw,240px);right:7vw}body[data-mode="pane"] .portal-glow{opacity:.4}
.conversation{position:absolute;bottom:calc(95px + var(--safe-bottom));left:50%;width:min(740px,78vw);transform:translate(-50%,10px);opacity:0;pointer-events:none;transition:opacity var(--dur-ui) var(--ease-out),transform var(--dur-ui) var(--ease-out);z-index:2}body[data-mode="talk"] .conversation{opacity:1;transform:translate(-50%,0);pointer-events:auto}.reading{min-height:90px;display:flex;flex-direction:column;justify-content:center;padding:2px 26px;text-align:center}.speaker-row{display:flex;justify-content:center;align-items:center;gap:12px;font-size:12px;color:var(--accent);margin-bottom:8px;min-height:20px}#speaker{overflow-wrap:anywhere}#message-status{font-size:11px;color:var(--text-2);white-space:nowrap}#sentence{font-size:20px;font-weight:350;line-height:1.75;overflow-wrap:anywhere;white-space:pre-wrap;text-shadow:0 2px 18px #030719;font-synthesis:none}.reading-controls{display:flex;align-items:center;justify-content:center;gap:10px;font-size:12px;color:var(--text-2);margin:7px 0 14px;min-height:28px}.reading-controls>button{min-height:30px;padding:1px 7px;white-space:nowrap}.reading-controls #previous,.reading-controls #next{font-size:23px;min-width:32px}.reading-controls #page-count{min-width:42px;text-align:center;font-variant-numeric:tabular-nums}#autoplay[aria-pressed="true"]{color:var(--accent)}#stop{color:var(--accent)}.composer{display:flex;gap:6px;align-items:center;padding:7px;border-radius:32px}.composer textarea{flex:1;min-width:0;resize:none;max-height:70px;border:0;background:transparent;line-height:1.6;padding:7px 4px;outline:none;color:var(--text-1);font-size:15px}.composer textarea:focus-visible{outline-offset:0;outline:1px solid var(--accent);border-radius:6px}.composer textarea::placeholder{color:var(--text-3)}.send{background:var(--accent-soft);color:var(--accent)}.voice-button{display:flex;align-items:center;justify-content:center;border-radius:50%;width:44px;height:44px;background:var(--accent);color:var(--on-accent);flex:none}.voice-row{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px;color:var(--text-2);margin:7px 10px}.voice-row button{color:var(--accent)}.navigation{position:absolute;left:50%;bottom:calc(18px + var(--safe-bottom));transform:translateX(-50%);display:flex;padding:5px;border-radius:40px;z-index:3}.navigation button{min-width:68px;min-height:51px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;padding:4px 15px;color:var(--text-2);border-radius:32px;font-size:12px}.navigation svg{width:18px;height:18px}.navigation [aria-current="page"]{background:var(--accent-soft);color:var(--accent);box-shadow:0 0 20px var(--accent-glow)}
/* Native dialog supplies modality and focus containment; reading surfaces stay in the space. */
.sheet{position:fixed;inset:auto;margin:0;top:calc(var(--view-top) + 84px);left:5vw;width:min(950px,68vw);height:calc(var(--view-h) - 183px - var(--safe-bottom));max-height:none;max-width:calc(100vw - 24px);padding:0;border-radius:28px;color:var(--text-1);background:var(--glass-read);overflow:hidden;box-shadow:0 25px 100px #02041260}.sheet::backdrop{background:#02061422}.sheet[data-kind="history"],.sheet[data-kind="full"]{left:auto;right:4vw;width:min(460px,42vw)}.sheet-header{display:flex;justify-content:space-between;align-items:center;gap:20px;padding:24px 26px 18px;border-bottom:1px solid var(--glass-line);height:110px}.sheet-header .eyebrow{font-size:11px;margin-bottom:3px}.sheet-content{height:calc(100% - 110px);overflow:auto;overscroll-behavior:contain;padding:24px 26px 34px;scrollbar-gutter:stable;line-height:1.9}.sheet-content :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)}.sheet-content .item{width:100%;text-align:left;border-top:1px solid var(--glass-line);padding:22px 0;display:block}.item small{display:block;color:var(--text-2);margin-top:7px;font-size:13px}.item .date{font-size:11px;letter-spacing:.08em;color:var(--accent);margin-bottom:10px}.quote{font-size:20px;font-weight:350;border-left:1px solid var(--accent);padding-left:20px;margin:24px 0}.sheet-content .message{padding:18px 0;border-bottom:1px solid var(--glass-line)}.message .author{color:var(--accent);font-size:12px;margin-bottom:7px;overflow-wrap:anywhere}.message[data-role="user"] .author{color:var(--text-2)}.message p{font-size:15px;white-space:pre-wrap;overflow-wrap:anywhere}.message .incomplete{font-size:12px;color:var(--text-2)}.sheet-content label{display:block;font-size:13px;color:var(--text-2);margin:18px 0 7px}.sheet-content input,.sheet-content textarea,.sheet-content select{border:1px solid var(--glass-line);border-radius:12px;background:var(--surface);padding:11px 14px;width:100%;max-width:100%;font-size:15px}.sheet-content textarea{min-height:90px;resize:vertical}.sheet-content .notice{padding:14px 17px;border:1px solid var(--glass-line);border-radius:16px;color:var(--text-2);font-size:13px}.sheet-content .status{color:var(--accent);min-height:26px;font-size:13px}.sheet-content .guide{font-size:13px;color:var(--text-2)}.sheet-content summary{cursor:pointer;color:var(--accent);padding:10px 0}.sheet-content details{border-top:1px solid var(--glass-line);margin-top:20px}.sheet-content .error{color:#ffb8c8}*{scrollbar-width:thin;scrollbar-color:var(--accent-glow) transparent}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-thumb{background:var(--accent-glow);border-radius:10px}::-webkit-scrollbar-track{background:transparent}
@media(hover:hover) and (pointer:fine){button:hover:not(:disabled){opacity:.82}.sheet-content .item:hover{color:var(--accent)}}
@media(max-width:1100px){.weather{display:none}.presence{display:none}.sheet{width:86vw;left:7vw}body[data-mode="pane"] .portal{opacity:.35;right:3vw}.sheet[data-kind="history"],.sheet[data-kind="full"]{width:440px;right:4vw}.identity strong{max-width:180px}}
@media(max-width:720px){
.topbar{height:57px;padding:0 16px;gap:8px;align-items:center}.identity{gap:8px}.identity strong{font-size:15px;max-width:100px}.top-actions{gap:6px}.sample-tag{font-size:10px;padding:4px 7px}.theme{padding:2px}.theme button{font-size:11px;padding:4px 9px}.theme button:first-letter{font-size:8px}
.moon-disc{left:13%;top:12%;width:46px;height:46px}.moon-halo{left:calc(13% - 140px);top:calc(12% - 140px);width:326px;height:326px}.skyline.far{height:30%}.skyline.near{height:20%}
.home-copy{left:24px;top:auto;bottom:calc(109px + var(--safe-bottom));width:calc(100% - 48px);transform:none}.home-copy .eyebrow{margin-bottom:10px}.home-copy h1{font-size:23px;line-height:1.55;margin-bottom:10px}.home-copy .quiet{font-size:12px}.left-note{padding:13px 15px;font-size:13px;margin-top:18px;border-radius:18px}.note-meta{font-size:11px;margin-bottom:5px}.home-copy .small-link{padding:9px 0;font-size:12px}.home-actions{margin-top:9px}.home-actions .primary{padding:10px 20px;font-size:14px}.home-actions .round{width:44px;height:44px}
.portal{right:23px;top:77px;transform:none;width:clamp(150px,45vw,220px);height:calc(var(--view-h) - 531px - var(--safe-bottom));min-height:190px;max-height:265px;aspect-ratio:auto;border-radius:48% 48% 18px 18px}.portal figcaption{left:8px;right:8px;bottom:9px;font-size:9px;gap:4px}.portal figcaption span{padding:2px 6px}.portal-glow{right:-20%;top:11%;width:95vw;height:42%}
body:not([data-mode="home"]) .home-copy{transform:translateY(12px)}
body[data-mode="talk"] .portal{left:12px;right:12px;top:68px;width:calc(100% - 24px);transform:none;height:calc(var(--view-h) - 354px - var(--safe-bottom));min-height:120px;max-height:none;border-radius:23px}.portal-top{inset:10px 10px auto}.portal-top>span,.close-talk{font-size:10px;padding:5px 9px}.close-talk svg{width:13px;height:13px}
.conversation{width:calc(100% - 24px);bottom:calc(91px + var(--safe-bottom))}.reading{padding:0 14px;min-height:115px}.speaker-row{margin-bottom:5px}.reading #sentence{font-size:17px;line-height:1.8}.reading-controls{gap:5px;font-size:11px;margin:4px 0 10px}.reading-controls>button{padding:1px 5px}.reading-controls #previous,.reading-controls #next{min-width:28px}.composer{padding:5px;gap:3px}.composer .round,.voice-button{width:39px;height:39px}.composer textarea{font-size:16px;padding:6px 3px}.navigation{bottom:calc(15px + var(--safe-bottom));padding:4px}.navigation button{min-width:66px;min-height:47px;padding:3px 15px;font-size:11px}.navigation svg{width:17px;height:17px}
body[data-mode="pane"] .portal{top:90px;right:16px;width:160px;min-height:200px;opacity:.18}
.sheet,.sheet[data-kind="history"],.sheet[data-kind="full"]{left:12px;right:12px;width:calc(100% - 24px);top:calc(var(--view-top) + 65px);height:calc(var(--view-h) - 151px - var(--safe-bottom));border-radius:24px}.sheet-header{padding:16px 18px;height:86px}.sheet-content{padding:19px 20px 28px;height:calc(100% - 86px)}h2{font-size:20px}.quote{font-size:18px}
}
/* A compact visual viewport is the keyboard/short-screen layout, not a fake keyboard. */
@media(max-height:700px) and (max-width:720px){body[data-mode="home"] .portal{top:68px;min-height:140px;height:160px;width:145px}.home-copy{bottom:91px}.home-copy h1{font-size:20px}.home-copy .eyebrow{display:none}.home-copy .left-note{margin-top:12px;padding:9px 13px}.home-copy .quiet{display:none}.home-actions{margin-top:0}.home-copy .small-link{padding:6px 0}.home-copy .note-meta{margin-bottom:3px}.home-copy .left-note br{display:none}}
body.compact[data-mode="talk"] .portal{top:57px;height:calc(var(--view-h) - 260px - var(--safe-bottom));min-height:56px;border-radius:17px}body.compact .portal figcaption{display:none}body.compact .conversation{bottom:calc(75px + var(--safe-bottom))}body.compact .reading{min-height:68px;padding:0 8px}body.compact #sentence{font-size:15px;line-height:1.65}body.compact .speaker-row{font-size:11px;margin-bottom:2px}body.compact .reading-controls{margin:2px 0 5px;gap:4px}body.compact .navigation{bottom:calc(8px + var(--safe-bottom))}body.compact .navigation button{min-height:40px}body.compact .topbar{height:49px}body.compact .weather,body.compact .presence{display:none}body.compact .voice-row{margin:2px 9px;font-size:11px}body.compact .composer .round,body.compact .voice-button{height:36px;width:36px}body.compact .sheet{top:calc(var(--view-top) + 54px);height:calc(var(--view-h) - 125px - var(--safe-bottom))}body.compact .sheet-header{height:74px;padding:10px 18px}body.compact .sheet-content{height:calc(100% - 74px)}body.has-voice[data-mode="talk"] .portal{height:calc(var(--view-h) - 392px - var(--safe-bottom))}body.compact.has-voice[data-mode="talk"] .portal{height:calc(var(--view-h) - 290px - var(--safe-bottom))}
.room-photo{object-position:88% 42%!important;transform:scale(1.16);transform-origin:80% 42%}.horizon{border-radius:50%;filter:blur(24px)}
#sentence{white-space:normal}
.top-actions{flex:none}.sample-tag,.theme button{white-space:nowrap}body.compact .talk-photo{object-position:54% 20%}
@media(max-width:720px){body.compact[data-mode="home"] .home-copy{top:70px;bottom:90px;transform:none}body.compact[data-mode="home"] h1{width:calc(100% - 140px);font-size:19px;line-height:1.6}body.compact[data-mode="home"] .portal{top:70px;right:24px;width:120px;height:140px;min-height:100px}body.compact[data-mode="home"] .left-note{position:absolute;top:auto;bottom:62px;margin:0;padding:9px 12px}body.compact[data-mode="home"] .left-note>.note-meta{margin:0}body.compact[data-mode="home"] .left-note>span:last-child{display:none}body.compact[data-mode="home"] .small-link{display:none}body.compact[data-mode="home"] .home-actions{position:absolute;bottom:0;margin:0}body.micro[data-mode="home"] .left-note{display:none}}
html.no-motion *,html.keyboard-action *{transition-duration:0ms!important}html.no-motion .mist,html.no-motion .ribbon,body.suspended .mist,body.suspended .ribbon{animation-play-state:paused}html.no-motion .space-scale{transition:none;transform:none}html.no-motion .sky{transition:opacity 160ms var(--ease-out)}
@media(prefers-reduced-motion:reduce){.mist,.ribbon{animation:none}.space-scale{transition:none;transform:none!important}.home-copy,.conversation{transition:opacity 160ms var(--ease-out)}.sky{transition:opacity 160ms var(--ease-out)}}
+329
View File
@@ -0,0 +1,329 @@
'use strict';
// Local fixtures only. CSP also forbids connections; nothing is persisted.
const $ = s => document.querySelector(s);
const $$ = s => [...document.querySelectorAll(s)];
const html = document.documentElement;
const sheet = $('#sheet');
const content = $('#sheet-content');
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
const state = {mode:'home',name:'晚晚',relation:'朋友',turn:0,cursor:1,auto:false,busy:false,voice:false,token:0,dialog:null,returnMode:'home',memoryTab:false,readKey:'',renderEpoch:0};
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, autoTimer = 0, streamTimer = 0, animatingPortal = null, closing = null, trigger = null;
const SHORT = '刚刚看完一小节,正好想歇一会儿。你呢,今天过得怎么样?';
const LONG = '今天读到一段写雨的文字,想到小时候放学回家的那条路。那时候总觉得路很长,下雨时又不舍得走快,想听雨落在不同屋檐上的声音。后来搬到高一点的地方,雨声变远了,能看见的灯却多了。我还是会在这种时候把书放一放,看看窗外,给自己泡一杯热茶。\n\n有时候,一天里也没有发生什么大事。可能只是花店换了橱窗,或者读到一个特别喜欢的句子。但如果能把这些细小的事情讲给你听,这一天就像多了一盏亮着的灯。\n\n我也想知道你那边的天气。如果你今天有点累,我们可以慢慢聊,不急着把所有的话一次说完。等你想说的时候,我就在这里。最后这一句也完整保留:愿今晚的雨声让你睡得安稳。';
const FIXTURES = [
{title:'窗边的洋桔梗',time:'今天 · 15:52',text:'楼下花店的洋桔梗开了。我挑了一小束,放在窗边。',memory:'你说,淡色的花看起来很安静。'},
{title:'一起听过的雨',time:'昨天 · 21:16',text:'昨晚我们都没有急着说话,只是听了一会儿雨。',memory:'没有话的时候,也可以一起待一会儿。'}
];
const escapeHTML = value => String(value).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const icon = name => `<svg aria-hidden="true"><use href="#i-${name}"/></svg>`;
const motionOff = () => reduced.matches || html.classList.contains('no-motion') || html.classList.contains('keyboard-action');
// Small generated skyline: code-native scenery, never a replacement photo or full UI image.
for (const [i,node] of $$('.skyline').entries()) {
let x=0, rects='';
for(let n=0;n<36;n++) {
const w=24+((n*37+i*17)%46),h=30+((n*53+i*31)%140);
rects+=`<rect x="${x}" y="${210-h}" width="${w}" height="${h}" rx="1"/>`;
if(n%3===0) for(let k=0;k<3;k++) rects+=`<rect x="${x+8+k*8}" y="${222-h}" width="2" height="3" fill="#d8d7ed" opacity=".25"/>`;
x+=w+5;
}
node.innerHTML=`<svg viewBox="0 0 ${x} 210" preserveAspectRatio="none">${rects}</svg>`;
}
function setName(name) {
state.name=name;
$$('[data-name]').forEach(n=>n.textContent=name);
$$('[data-name]').forEach(n=>n.title=name);
renderSentence(true);
}
function setMode(mode) {
if(mode!=='talk') pauseAuto();
const portal=$('#portal'),before=portal.getBoundingClientRect();
// Cancel at current visual position; FLIP moves one persistent portal, not duplicate scenes.
if(animatingPortal){animatingPortal.cancel();animatingPortal=null;}
state.mode=mode;document.body.dataset.mode=mode;
$('#scene-label').textContent=mode==='talk'?'交流近景':'窗边看书';
if(mode!=='talk')portal.style.height='';
$('.home-copy').inert=mode!=='home';$('#conversation').inert=mode!=='talk';
if(mode==='talk')fitTalkStage();
const after=portal.getBoundingClientRect();
if(!motionOff() && before.width && after.width) {
// Fold the resting CSS translation into FLIP before starting an animation.
const base=getComputedStyle(portal).transform;
const matrix=new DOMMatrix(base==='none'?undefined:base);
const end=`translate(${matrix.e}px,${matrix.f}px)`;
animatingPortal=portal.animate([
{transformOrigin:'0 0',transform:`translate(${matrix.e+before.x-after.x}px,${matrix.f+before.y-after.y}px) scale(${before.width/after.width},${before.height/after.height})`},
{transformOrigin:'0 0',transform:end}
],{duration:280,easing:'cubic-bezier(.22,1,.36,1)'});
}
$$('.navigation button').forEach(n=>{if(n.dataset.nav===mode)n.setAttribute('aria-current','page');else n.removeAttribute('aria-current');});
if(mode==='talk')renderSentence(true);
}
function openTalk(focus=false) {
setMode('talk');if(focus)$('#draft').focus({preventScroll:true});
}
function graphemes(text) {
return typeof Intl.Segmenter==='function'?[...new Intl.Segmenter('zh',{granularity:'grapheme'}).segment(text)].map(s=>s.segment):Array.from(text);
}
function chunks(text) {
const compact=document.body.classList.contains('compact');
const width=Math.max(200,$('#reading').clientWidth-48),font=compact?15:innerWidth<=720?17:20;
const max=Math.max(16,Math.min(compact?28:58,Math.floor(width/font)*(compact?1.5:1.65)));
const units=graphemes(text),out=[];let line='';
for(const char of units){
// Keep punctuation and paragraph breaks attached; never make a blank reading page.
if(!line&&out.length&&/[。!?!?\n]/.test(char)){out[out.length-1]+=char;continue;}
line+=char;
if((/[。!?!?\n]/.test(char)&&line.trim())||graphemes(line).length>=max){out.push(line);line='';}
}
if(line)out.push(line);
return out;
}
function pages() {
return messages.filter(m=>m.turn===state.turn).flatMap(m=>chunks(m.text).map((text,index)=>({message:m,text,index,key:`${m.id}:${index}`})));
}
function announce(text){$('#announcement').textContent=text;}
function fitTalkStage(){
if(state.mode!=='talk')return;
const portal=$('#portal'),conversation=$('#conversation'),reading=conversation.getBoundingClientRect();
const transform=getComputedStyle(conversation).transform;
const offset=new DOMMatrix(transform==='none'?undefined:transform).f;
const top=$('#viewport').getBoundingClientRect().top+parseFloat(getComputedStyle(portal).top);
portal.style.minHeight='48px';
portal.style.height=`${Math.max(48,Math.floor(reading.top-offset-top-14))}px`;
}
function updateControls(list) {
$('#previous').disabled=state.cursor<=0;
$('#next').disabled=state.cursor>=list.length-1;
$('#page-count').textContent=list.length?`${state.cursor+1} / ${list.length}`:'0 / 0';
$('#stop').hidden=!state.busy;
$('#autoplay').setAttribute('aria-pressed',String(state.auto));
$('#autoplay').textContent=state.auto?'暂停续读':'自动续读';
$('#portal-state').textContent=state.busy?'正在回应 · 演示':state.voice?'语音界面演示':'在你面前';
}
function renderSentence(immediate=false) {
const list=pages();state.cursor=Math.max(0,Math.min(state.cursor,list.length-1));
updateControls(list);
const page=list[state.cursor];if(!page)return;
const who=page.message.role==='user'?'你':state.name;
$('#message-status').textContent=state.busy?'她正在回应…':page.message.status==='cancelled'?'未完成 · 已取消':'';
const apply=()=>{
$('#speaker').textContent=who;
$('#sentence').textContent=page.text;
fitTalkStage();
announce(`${who}:${page.text}`);
};
if(page.key===state.readKey){apply();return;}
const epoch=++state.renderEpoch;
state.readKey=page.key;
const node=$('#reading');node.getAnimations().forEach(a=>a.cancel());
if(immediate || motionOff() || state.mode!=='talk'){apply();return;}
node.animate([{opacity:1,transform:'translateY(0)'},{opacity:0,transform:'translateY(-4px)'}],{duration:100,easing:'cubic-bezier(.22,1,.36,1)'}).finished.then(()=>{
if(epoch!==state.renderEpoch)return;
apply();
node.animate([{opacity:0,transform:'translateY(5px)'},{opacity:1,transform:'translateY(0)'}],{duration:160,easing:'cubic-bezier(.22,1,.36,1)'});
}).catch(()=>{});
}
function movePage(delta){state.cursor=Math.max(0,Math.min(state.cursor+delta,pages().length-1));renderSentence();scheduleAuto();}
function pauseAuto(){state.auto=false;clearTimeout(autoTimer);$('#autoplay').setAttribute('aria-pressed','false');$('#autoplay').textContent='自动续读';}
function scheduleAuto(){
clearTimeout(autoTimer);
if(!state.auto||state.dialog||document.hidden||state.mode!=='talk')return;
if(document.activeElement===$('#draft')||String(getSelection()).length)return;
const page=pages()[state.cursor];
if(page)autoTimer=setTimeout(()=>{
if(state.cursor<pages().length-1)movePage(1);else if(state.busy)scheduleAuto();else pauseAuto();
},Math.max(3800,graphemes(page.text).length*180));
}
function cancelReply() {
state.token++;clearTimeout(streamTimer);state.busy=false;
messages.filter(m=>m.status==='generating').forEach(m=>{m.status='cancelled';if(!m.text)m.text='(回复在内容出现前取消)';});
updateControls(pages());renderSentence(true);
}
function beginReply(text,reply=SHORT,after=null) {
cancelReply();pauseAuto();
state.turn++;const user={id:nextId++,turn:state.turn,role:'user',text,status:'complete'};
messages.push(user);state.cursor=0;state.readKey='';renderSentence();
streamReply(reply,true,after);
}
function streamReply(reply,followFirst=false,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(pages());
const parts=reply.match(/[^。!?!?\n]+[。!?!?\n]*|\n+/gu)||[reply];let index=0;
function append(){
if(token!==state.token)return;
if(document.hidden){streamTimer=setTimeout(append,500);return;}
message.text+=parts[index++];
if(index===1&&followFirst&&state.cursor===0&&!state.dialog){
const list=pages();state.cursor=list.findIndex(p=>p.message.id===message.id);
}
if(index>=parts.length){state.busy=false;message.status='complete';}
renderSentence();
if(state.dialog==='history')renderHistory();
if(state.busy)streamTimer=setTimeout(append,650);
else if(after)after();
}
streamTimer=setTimeout(append,1100);
}
// Native dialog restores focus. WAAPI only controls its visible entry/exit.
function showSheet(kind,title,kicker,markup){
pauseAuto();
if(closing){closing.cancel();closing=null;}
if(!sheet.open){trigger=document.activeElement;state.returnMode=state.mode;setMode('pane');sheet.showModal();}
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(14px)'},{opacity:1,transform:'translateY(0)'}],{duration:280,easing:'cubic-bezier(.22,1,.36,1)'});
$('#close-sheet').focus({preventScroll:true});
}
function finishClose(){
sheet.getAnimations().forEach(a=>a.cancel());sheet.close();state.dialog=null;closing=null;setMode(state.returnMode);
if(trigger&&trigger.isConnected&&!trigger.inert)trigger.focus({preventScroll:true});
}
function closeSheet(){
if(closing)return;
if(motionOff()){finishClose();return;}
closing=sheet.animate([{opacity:1,transform:'translateY(0)'},{opacity:0,transform:'translateY(14px)'}],{duration:180,easing:'cubic-bezier(.22,1,.36,1)',fill:'forwards'});
closing.finished.then(finishClose).catch(()=>{});
}
function renderHistory(){
const previousScroll=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">${escapeHTML(m.role==='user'?'你':state.name)}</div><p>${escapeHTML(m.text||'正在回应…')}</p>${m.status==='cancelled'?'<span class="incomplete">已取消 · 这是未完成片段</span>':''}</article>`).join('');
content.scrollTop=previousScroll;
}
function showHistory(){showSheet('history','你们说过的话','完整记录 · 演示', '');renderHistory();}
function showFull(){
const current=pages()[state.cursor];if(!current)return;
const m=current.message;
showSheet('full','这一段,慢慢看',`${m.role==='user'?'你':state.name} · 演示全文`,`<p class="guide">这里可以滚动读完整段。返回后仍停在刚才那一句。</p><article class="message"><p>${escapeHTML(m.text)}</p>${m.status==='cancelled'?'<span class="incomplete">这段回复已取消,内容未完成。</span>':''}</article>`);
}
function showDays(memory=false){
state.memoryTab=memory;
if(state.createdNew){showSheet('days',memory?'我们的回忆':'她的日子','初次见面 · 演示',`<div class="row"><button class="pill" data-action="days">她的日子</button><button class="pill" data-action="memories">我们的回忆</button></div><p>刚刚见面,还没有分享过的日子与共同回忆。</p><p class="guide">真实生活和记忆尚未接入,小样不会自动编出她未分享的经历。</p>`);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>`);
}
function eventDetail(index){
const f=FIXTURES[index];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="${index}">看那时说的话</button><p class="guide">演示故事,不是真实的后台生活。</p>`);
}
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>`);
}
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>':''}`);
}
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>`);
}
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>`);
}
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>`);
}
function showRehearsal(){
showSheet('rehearsal','试试这些相处片段','小样试用 · 全部为演示',`<p>先回到此刻,再和她说话。也可以从下面选择一段情境。</p><div class="row"><button class="pill" data-scenario="short">短句回复</button><button class="pill" data-scenario="long">一段长回复</button><button class="pill" data-scenario="incoming">连续来两段话</button><button class="pill" data-scenario="waiting">生成中与取消</button></div><p class="guide">回复分句保留,可手动前后翻看,也可选自动续读。分句方式、停留时间和整体动效都是本次待你验收的交互方案。</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></details><p class="subtle">“独立小样 · 演示数据”随时可以再打开这里。</p>`);
}
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><button class="primary" type="submit">以这个名字体验小样</button><p id="creation-error" class="error" role="status"></p></form>`);
}
function showVoice(){
showSheet('voice','体验语音界面','没有收音的演示',`<p>这里仅演示语音开启、取消与结束的界面。不会申请麦克风权限,也不会播放或识别声音。</p><button class="primary" data-action="start-voice">开始界面演示</button><button class="pill" data-action="dismiss">暂不开启</button>`);
}
function runScenario(kind){
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){
if(!['moon','fog'].includes(theme))return;
html.dataset.theme=theme;$$('[data-theme]').filter(n=>n.tagName==='BUTTON').forEach(n=>n.setAttribute('aria-pressed',String(n.dataset.theme===theme)));
}
document.addEventListener('click',e=>{
const button=e.target.closest('button');if(!button)return;
if(button.dataset.theme){applyTheme(button.dataset.theme);return;}
if(button.dataset.nav){
const mode=button.dataset.nav;
if(sheet.open){finishClose();}
if(mode==='home'){setMode('home');}else if(mode==='talk')openTalk();else if(mode==='days')showDays();else showManage();return;
}
if(button.dataset.event!==undefined){eventDetail(Number(button.dataset.event));return;}
if(button.dataset.eventHistory!==undefined){
const index=Number(button.dataset.eventHistory);showHistory();
content.insertAdjacentHTML('afterbegin',`<button class="small-link" data-event="${index}">‹ 返回这一天</button>`);
content.querySelector(`[data-life="${index}"]`)?.scrollIntoView({block:'center'});return;
}
if(button.dataset.scenario){runScenario(button.dataset.scenario);return;}
const action=button.dataset.action;
const actions={days:()=>showDays(),memories:()=>showDays(true),manage:showManage,relationship:showRelationship,city:showCity,display:showDisplay,create:showCreation,voice:showVoice,dismiss:closeSheet,
motion:()=>{html.classList.toggle('no-motion');showDisplay();},
related:()=>{showHistory();const target=[...content.querySelectorAll('.message')][0];target?.scrollIntoView({block:'start'});},
'start-voice':()=>{finishClose();openTalk();state.voice=true;document.body.classList.add('has-voice');$('#voice-row').hidden=false;renderSentence(true);}
};
if(actions[action])actions[action]();else if(['profile','appearance','backup','services'].includes(action))showInfo(action);
});
$('#start-talk').onclick=()=>openTalk();$('#shared-note').onclick=()=>{openTalk();showHistory();};$('#days-link').onclick=()=>showDays();$('#close-talk').onclick=()=>setMode('home');
$('#history').onclick=showHistory;$('#read-all').onclick=showFull;$('#close-sheet').onclick=closeSheet;$('#rehearse').onclick=showRehearsal;
$('#home-voice').onclick=showVoice;$('#voice').onclick=showVoice;$('#end-voice').onclick=()=>{state.voice=false;document.body.classList.remove('has-voice');$('#voice-row').hidden=true;cancelReply();};
$('#previous').onclick=()=>{pauseAuto();movePage(-1);};$('#next').onclick=()=>{pauseAuto();movePage(1);};$('#autoplay').onclick=()=>{state.auto=!state.auto;updateControls(pages());scheduleAuto();};$('#stop').onclick=cancelReply;
$('#composer').addEventListener('submit',e=>{e.preventDefault();const text=$('#draft').value.trim();if(!text)return;$('#draft').value='';beginReply(text,/长|故事|慢慢|完整/.test(text)?LONG:SHORT);});
$('#draft').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey&&!e.isComposing&&e.keyCode!==229){e.preventDefault();$('#composer').requestSubmit();}});
$('#draft').addEventListener('focus',pauseAuto);
document.addEventListener('selectionchange',()=>{if(String(getSelection()).length)pauseAuto();});
sheet.addEventListener('cancel',e=>{e.preventDefault();closeSheet();});
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();messages=[];state.createdNew=true;state.turn++;messages.push({id:nextId++,turn:state.turn,role:'her',text:'你好。很高兴在这里见到你。',status:'complete'});state.cursor=0;state.relation=relation;setName(name);finishClose();openTalk();
}
});
document.addEventListener('keydown',e=>{
html.classList.add('keyboard-action');
if(e.key==='Escape'&&!sheet.open&&state.mode==='talk'){setMode('home');$('#start-talk').focus();}
});
document.addEventListener('pointerdown',()=>html.classList.remove('keyboard-action'));
document.addEventListener('visibilitychange',()=>{
document.body.classList.toggle('suspended',document.hidden);
if(document.hidden)pauseAuto();
});
function fitViewport(){
const viewport=window.visualViewport,h=viewport?.height||innerHeight,t=viewport?.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<400);
// Keep the same content offset when wrapping changes; never drop characters.
const current=pages()[state.cursor];const anchor=current?{id:current.message.id,offset:chunks(current.message.text).slice(0,current.index).join('').length}:null;
requestAnimationFrame(()=>{
if(anchor){const list=pages();let consumed=0;for(let i=0;i<list.length;i++){if(list[i].message.id!==anchor.id)continue;if(consumed+list[i].text.length>anchor.offset){state.cursor=i;break;}consumed+=list[i].text.length;}}
renderSentence(true);
});
}
window.visualViewport?.addEventListener('resize',fitViewport);window.visualViewport?.addEventListener('scroll',fitViewport);window.addEventListener('resize',fitViewport);
reduced.addEventListener('change',()=>{if(reduced.matches)animatingPortal?.cancel();});
fitViewport();renderSentence(true);
+188
View File
@@ -0,0 +1,188 @@
"""Focused browser verification of the offline prototype, with actual screenshots.
Requires the host's Python Playwright + Chromium. No production services used.
Run: python3 prototypes/hel-790/build.py && python3 prototypes/hel-790/verify.py
"""
import json
import os
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parent
OUT = ROOT / 'evidence'
OUT.mkdir(exist_ok=True)
LIBS = Path('/home/xiaomo/.local/pwlibs/usr/lib/x86_64-linux-gnu')
if LIBS.exists():
os.environ['LD_LIBRARY_PATH'] = str(LIBS) + (':' + os.environ['LD_LIBRARY_PATH'] if os.environ.get('LD_LIBRARY_PATH') else '')
results = []
def check(condition, label, details=None):
assert condition, f'{label}: {details}'
results.append({'check': label, 'passed': True, 'details': details})
def settle(page, ms=330):
page.wait_for_timeout(ms)
def geometry(page):
return page.evaluate('''() => {
const r=s=>{const x=document.querySelector(s).getBoundingClientRect();return {x:x.x,y:x.y,w:x.width,h:x.height,b:x.bottom,r:x.right}};
return {portal:r('#portal'),reading:r('#reading'),sentence:r('#sentence'),composer:r('#composer'),nav:r('.navigation'),viewport:r('#viewport'),speaker:r('#speaker')};
}''')
def no_overlap(page, label):
g=geometry(page)
check(g['portal']['b']+8 <= g['reading']['y'], label+' 人物与正文分开', g)
check(g['sentence']['b'] <= g['composer']['y'], label+' 正文与输入分开')
check(g['composer']['b']+4 <= g['nav']['y'], label+' 输入与导航分开')
check(g['nav']['b'] <= g['viewport']['b'], label+' 导航在可视范围')
check(page.evaluate('document.documentElement.scrollWidth<=innerWidth'),label+' 无横向溢出')
with sync_playwright() as p:
browser=p.chromium.launch(headless=True,args=['--no-sandbox'])
context=browser.new_context(viewport={'width':1440,'height':900})
page=context.new_page()
errors=[];requests=[]
page.on('pageerror',lambda error:errors.append(str(error)))
page.on('request',lambda request: requests.append(request.url) if request.url.startswith(('http:','https:','ws:','wss:')) else None)
page.goto((ROOT/'index.html').as_uri())
settle(page)
# All four desktop states plus both themes on mobile, using real DOM.
for theme in ['moon','fog']:
page.locator(f'.theme [data-theme="{theme}"]').click()
settle(page)
page.screenshot(path=str(OUT/f'desktop-home-{theme}.png'))
page.click('#start-talk');settle(page)
no_overlap(page,f'1440×900 {theme}')
page.screenshot(path=str(OUT/f'desktop-talk-{theme}.png'))
page.click('#close-talk');settle(page)
for theme in ['moon','fog']:
page.set_viewport_size({'width':390,'height':844});settle(page)
page.locator(f'.theme [data-theme="{theme}"]').click();settle(page)
page.screenshot(path=str(OUT/f'mobile-home-{theme}.png'))
page.click('#start-talk');settle(page)
no_overlap(page,f'390×844 {theme}')
page.screenshot(path=str(OUT/f'mobile-talk-{theme}.png'))
page.click('#close-talk');settle(page)
# Complete text is recoverable from the main sequence, not just from history.
page.set_viewport_size({'width':390,'height':844});settle(page)
page.click('#rehearse');settle(page);page.locator('[data-scenario="long"]').click()
page.wait_for_function('() => !state.busy',timeout=20000);settle(page)
expected=page.evaluate("messages.filter(m=>m.turn===state.turn).map(m=>m.text).join('')")
page.evaluate('state.cursor=0;renderSentence(true)');collected=[]
while True:
collected.append(page.locator('#sentence').text_content())
if page.locator('#next').is_disabled():break
page.click('#next');settle(page,290)
actual=page.evaluate("pages().map(p=>p.text).join('')")
check(''.join(collected)==expected,'逐句点击后的真实 DOM 文字与完整原文一致')
check(actual==expected,'长回复分句完整无缺字')
check(page.evaluate('pages().every(p=>p.text.trim())'),'长回复没有空白翻页')
check(len(collected)>8 and '最后这一句' in actual,'长回复末尾可从主界面翻到',len(collected))
page.screenshot(path=str(OUT/'mobile-long-end-fog.png'))
cursor=page.locator('#page-count').inner_text()
page.click('#read-all');settle(page)
check('愿今晚的雨声让你睡得安稳。' in page.locator('#sheet-content').inner_text(),'全文保留末句')
page.locator('#sheet-content').evaluate('(e)=>e.scrollTop=e.scrollHeight')
page.screenshot(path=str(OUT/'mobile-full-end-fog.png'))
page.click('#close-sheet');settle(page)
check(page.locator('#page-count').inner_text()==cursor,'收回全文保持句子位置')
# Repeated open/close catches stale fill-forwards animations and focus restoration.
for n in range(3):
page.click('#history');settle(page)
check(page.evaluate('getComputedStyle(sheet).opacity')=='1',f'记录第 {n+1} 次打开可见')
check(page.evaluate('sheet.contains(document.activeElement)'),'浮层焦点在原生对话框内')
for _ in range(12):page.keyboard.press('Tab')
check(page.evaluate('sheet.contains(document.activeElement)'),'Tab 保持在浮层内')
page.keyboard.press('Escape');settle(page)
check(page.locator('#page-count').inner_text()==cursor,'收回记录保持句子位置')
# New incoming content queues while the user reads, with no forced jump.
page.click('#rehearse');settle(page);page.locator('[data-scenario="incoming"]').click()
settle(page,1800);sentence=page.locator('#sentence').inner_text();settle(page,3000)
check(page.locator('#sentence').inner_text()==sentence,'连续来句不抢走正在读的句子')
check(page.evaluate('messages.filter(m=>m.turn===state.turn&&m.role==="her").length')==2,'两段来句均保留')
# Cancel after a partial response; wait past old timers, then issue a fresh turn.
page.click('#rehearse');settle(page);page.locator('[data-scenario="waiting"]').click()
settle(page,1300);page.click('#stop');settle(page,300)
partial=page.evaluate('messages[messages.length-1].text');settle(page,1500)
check(page.evaluate('messages[messages.length-1].text')==partial,'取消后迟到内容不再写入')
check(page.evaluate('messages[messages.length-1].status')=='cancelled','未完成片段明确标注')
page.fill('#draft','新的话题');page.click('.send');page.wait_for_function('() => !state.busy',timeout=10000)
check(page.evaluate('messages[messages.length-1].text')==page.evaluate('SHORT'),'新一轮不混入取消的长回复')
# Long user input, long name and reduced viewports, with both themes.
long_user='这是很长的一段话,包含标点、换行和表情🌧️。\n' * 25
page.fill('#draft',long_user);page.click('.send');settle(page,300)
check(page.evaluate("pages().filter(p=>p.message.role==='user').map(p=>p.text).join('')")==long_user.strip(),'长用户输入与表情完整保留')
page.click('#stop');settle(page)
page.evaluate("setName('一个很长很长但仍然需要完整显示的演示名字🌙');state.cursor=pages().findIndex(p=>p.message.role==='her');renderSentence(true)")
for width,height in [(1440,900),(1280,720),(844,390),(390,844),(390,600),(390,420),(360,400),(320,568),(320,360)]:
for theme in ['moon','fog']:
page.set_viewport_size({'width':width,'height':height});settle(page)
page.locator(f'.theme [data-theme="{theme}"]').click();settle(page)
no_overlap(page,f'{width}×{height} 长名 {theme}')
if height<=420 and width<=390:
page.screenshot(path=str(OUT/f'simulated-viewport-{width}-{height}.png'))
check(page.evaluate('chunks(LONG).join("")===LONG'),'紧凑视口不丢失长回复文字')
# Life / memory / linked chat / management stay navigable with shared fixtures only.
page.set_viewport_size({'width':1440,'height':900});page.evaluate("setName('晚晚');applyTheme('moon')");settle(page)
page.click('#close-talk');settle(page);page.locator('[data-nav="days"]').click();settle(page)
page.locator('[data-event="0"]').click();settle(page)
page.screenshot(path=str(OUT/'desktop-life-moon.png'))
page.locator('[data-event-history="0"]').click();settle(page)
check(page.locator('[data-life="0"]').count()==2,'日子关联到对应的已分享对话')
page.locator('[data-event="0"]').click();settle(page)
check(page.locator('#sheet-title').inner_text()=='窗边的洋桔梗','关联聊天能返回原来的一天')
page.locator('[data-action="days"]').click();settle(page);page.locator('[data-action="memories"]').click();settle(page)
page.evaluate("applyTheme('fog')");settle(page)
page.screenshot(path=str(OUT/'desktop-memory-fog.png'))
page.click('#close-sheet');settle(page)
page.locator('[data-nav="manage"]').click();settle(page);page.screenshot(path=str(OUT/'desktop-management-fog.png'))
page.locator('[data-action="relationship"]').click();settle(page)
page.select_option('#relation','恋人');page.locator('#relation-form button[type="submit"]').click()
check('已确认' in page.locator('#form-status').inner_text(),'演示关系保存有明确反馈')
page.click('#close-sheet');settle(page)
# No preselected creation relationship; user-supplied name is used safely.
page.click('#rehearse');settle(page);page.locator('summary').first.click();page.locator('[data-action="create"]').click();settle(page)
check(page.locator('#new-relation').input_value()=='','初次创建关系不预选')
page.fill('#new-name','小雨 <🌙>');page.select_option('#new-relation','朋友');page.locator('#create-form button[type="submit"]').click();settle(page)
check(page.locator('#creation-error').inner_text()!='','名字复用已批准规则,拒绝尖括号')
page.fill('#new-name','小雨🌙');page.locator('#create-form button[type="submit"]').click();settle(page)
check(page.locator('.identity strong').inner_text()=='小雨🌙','用户给的名字贯穿身份显示且按文字处理')
page.click('#voice');settle(page);page.locator('[data-action="start-voice"]').click();settle(page)
check(page.locator('#voice-row').is_visible(),'语音演示持续标注没有收音')
no_overlap(page,'语音演示')
page.click('#end-voice');settle(page)
# Short home layouts keep the portrait beside, rather than over, the opening words.
page.click('#close-talk');settle(page)
for width,height in [(390,600),(390,420),(320,568),(320,360)]:
page.set_viewport_size({'width':width,'height':height});settle(page)
g=page.evaluate('''() => {const r=s=>{const e=document.querySelector(s).getBoundingClientRect();return {x:e.x,y:e.y,b:e.bottom,r:e.right}};return {title:r('h1'),portal:r('#portal'),actions:r('.home-actions'),nav:r('.navigation')}}''')
check(g['title']['r']+4<=g['portal']['x'] or g['portal']['b']+4<=g['title']['y'],f'{width}×{height} 相处页人物不挡近况',g)
check(g['actions']['b']+4<=g['nav']['y'],f'{width}×{height} 相处入口不挡导航')
page.set_viewport_size({'width':390,'height':844});settle(page);page.click('#start-talk');settle(page)
page.click('#autoplay');check(page.locator('#autoplay').get_attribute('aria-pressed')=='true','自动续读明确由用户选择')
page.focus('#draft');check(page.locator('#autoplay').get_attribute('aria-pressed')=='false','输入时停止自动续读')
page.emulate_media(reduced_motion='reduce')
check(page.evaluate("getComputedStyle(document.querySelector('.mist')).animationName")=='none','系统减少动效停止背景循环')
page.click('#close-talk');settle(page);page.click('#start-talk');settle(page)
check(page.evaluate("!document.querySelector('#portal').getAnimations().some(a=>a.playState==='running')"),'减少动效不播放空间移动')
# Keyboard mode must not initiate WAAPI animations.
page.keyboard.press('Escape');page.keyboard.press('Tab');page.keyboard.press('Enter');settle(page)
check(not errors,'无浏览器脚本错误',errors)
check(not requests,'离线原型没有 HTTP / WebSocket 请求',requests)
browser.close()
(OUT/'verification.json').write_text(json.dumps({'browser':'Chromium / Linux','checks':results,'limitations':['未测试真机软键盘','未测试 Safari / iOS','视口缩小是等效模拟','人物为获批静帧;服务全部未接入']},ensure_ascii=False,indent=2))
print(f'{len(results)} meaningful checks passed; evidence saved to evidence/')
+1 -7
View File
@@ -21,13 +21,7 @@ def check(handoff=None, base_ref=None):
rows = baseline['requirements']
ids = [r['id'] for r in rows]
known = set(ids)
evidence = {}
for path in sorted((DOCS / 'sources').glob('comment-evidence*.json')):
for comment in read(path):
old = evidence.get(comment['id'])
if old and old != comment:
errors.append(f'同一来源评论出现不同记录:{comment["id"]}')
evidence[comment['id']] = comment
evidence = {c['id']: c for c in read(DOCS / 'sources/comment-evidence.json')}
if len(ids) != len(known):
errors.append('重复的需求编号')
text = (DOCS / 'current.md').read_text(encoding='utf-8')