diff --git a/.gitignore b/.gitignore
index 5782304..bff9488 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,3 +48,7 @@ models/
*.swp
.DS_Store
Thumbs.db
+
+# Independent web demo
+web/node_modules/
+web/dist/
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..d7edc78
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1 @@
+qa-artifacts/
diff --git a/web/QA.md b/web/QA.md
new file mode 100644
index 0000000..67963ff
--- /dev/null
+++ b/web/QA.md
@@ -0,0 +1,64 @@
+# HEL-700 施工与自测记录
+
+基准 `1e9b08a`;交付分支 `hel-700-rain-room`。实现范围仅 `web/` 与根 `.gitignore`。未部署,未改 Python 核心、配置、测试或远端服务。
+
+## 实现
+
+- `src/app/`:真实 DOM 导航、玻璃面板、消息、输入、片刻/命盘预览、设置,样式 token 统一在 `style.css`。系统减少动效、触屏、抽屉和矮屏各有适配;设置在桌面右侧、手机底部最多55dvh,避开脸部。
+- `src/scene/scene.ts`:Pixi 仅允许 WebGL 后端并校验类型,独立 Canvas2D、CSS、静态降级;雨幕、水痕高光、粒子、光轨及指针视差。原始带 UI 参考图没有放入网页。
+- `src/engine/audio.ts`:用户主动开启程序化雨声、音量、隐藏暂停及恢复。语音和附件只解释未接入,不采集设备。
+- `src/net/llm.ts`:同源探测、UTF-8 SSE 分包、2/8/40 秒超时、取消、保留流式内容、手动重试;空响应与截断均报错。模型内容作为文本节点渲染。
+- `src/demo/`:演示台词、片刻、场景文案和预设唯一来源;`state/contracts.ts` 镜像 Python State/Utterance 字段。
+- `vite.config.ts` 开发/preview 同源代理;`nginx.conf.example` 是交总工的生产模板,没有执行服务器配置。
+
+## 命令与结果
+
+从仓库根:
+
+```sh
+npm ci --prefix web
+npm run typecheck --prefix web
+npm run build --prefix web
+python3 -m pytest -q
+CYBERGRIL_ARTIFACT_DIR="$PWD/qa-output" python3 web/tests/browser_check.py
+CYBERGRIL_ARTIFACT_DIR="$PWD/qa-output" python3 web/tests/network_check.py
+CYBERGRIL_ARTIFACT_DIR="$PWD/qa-output" python3 web/tests/lifecycle_check.py
+CYBERGRIL_ARTIFACT_DIR="$PWD/qa-output" python3 web/tests/production_check.py
+```
+
+浏览器脚本使用宿主已有 Python Playwright,不增加 npm 依赖;需要宿主可用的浏览器图形运行库。每个脚本前台启动并最终关闭自己的 Vite 子进程。严格 TS/noUnused、构建、锁文件重装均通过;既有 Python 测试 **78 passed**。未新增独立 linter 依赖,另做 `git diff --check` 与调试残留检查。
+
+浏览器:Linux 无头 Chromium 151.0.7922.34,桌面 1600×900、1280×800,平板 768×1024,手机视口 390×844 收起/展开/聚焦、844×390 横屏。截图均为实际页面,生产截图前缀 `production-`。
+
+- 主交互回归 32 项通过:Enter/Shift+Enter、组合输入保护、500 字、流式期间编辑、取消/重试、来源标注、预览、静态/轻量、减少动效、语音说明、折叠恢复、XSS 文本显示等。
+- 生命周期回归 13 项通过:真实 `WEBGL_lose_context` 丢失;屏蔽 WebGL API 转独立 Canvas2D,屏蔽 Canvas2D 转 CSS;20 FPS 注入触发自动逐级到静态;雨声隐藏暂停/回来恢复;长消息内部滚动和返回底部;抽屉拖动/键盘;软键盘布局分支。
+- 网络故障 8 项通过:逐字节 UTF-8/CRLF 分包、网络错误、取消、空响应、坏 SSE、首包 8001ms 超时、整包 40000ms 超时、探测 2000ms 超时。
+- 真实内网模型经开发同源代理完成回复,单次完整回复约 1408ms;生产 preview `/v1/models` 返回 200。实时失败未转换成演示台词,已有部分输出不会自动重试。
+- 构建产物 smoke 7 项通过,无未处理页面异常。
+
+## 包体与性能(实际采样,不扩大适用范围)
+
+| 项目 | 结果 |
+| --- | --- |
+| 全部 JS gzip 合计(Python gzip 默认级别9) | 269538 字节;低于 300KB 目标 |
+| JS 原始合计 | 931348 字节;Pixi 主块约 904KB,Vite 大块提示仍存在 |
+| 素材合计(WebP + JPEG 回退) | 593708 字节;低于 1.5MB |
+| 首屏 WebP / JPEG 回退 | 267992 / 325716 字节 |
+| 4 秒帧采样 | 198 帧;帧间隔 P95 33.40ms;未观测到 >50ms long task |
+| 场景 rAF 回调 | 379 次,P95 0.740ms,最长 2.497ms |
+| 浏览器主线程任务 | 3052 次,最长 10.304ms;3 次 >8ms |
+
+**8ms 全主线程任务目标尚未完全达到。** 超出任务来自浏览器 `ScheduledActionSendBeginMainFrame`(含合成/布局),不是只统计业务 JS。保留完整 `chromium-main-thread-trace.json`,不以无 long task 替代 8ms 证明。快速连续键击字符串完整保留。该采样使用本机软件图形后端,不能代表低端安卓。
+
+## 限制与待审核项
+
+- Firefox 尝试启动,但宿主缺 libgtk/libXcursor 等运行库;未完成 Firefox、Edge、Safari/iOS 或低端安卓真机测试。
+- 中文输入法通过浏览器 composition 事件测试保护逻辑;未使用桌面系统真实输入法人工验收。软键盘布局通过 visualViewport 压缩事件模拟,未做手机系统键盘真机测试。
+- 隐藏/恢复通过 document visibility 事件和隐藏值注入,验证场景、CSS 和实际 AudioContext 状态;没有冒充真实设备后台生命周期测试。
+- 真正 WebGL 上下文丢失有实测;低帧率/不可用 API 和坏 SSE 使用明确的故障注入,不声称真实硬件故障。
+- 运行时依赖审计 0 漏洞。授权的 Vite 5.4.21 / esbuild 开发依赖仍有 1 high + 1 moderate 通告(详见 npm audit 原始附件);没有擅自升级授权之外的大版本。开发与 preview 只绑定本机;生产只交静态 dist 与代理模板。后续升级需总管确认版本范围。
+- nginx 模板未经远端部署验证;未接 ASR、TTS、Python 性格/记忆/生活/排盘;人物与城市是一体素材,不宣称独立人物动画。
+
+## 证据索引
+
+`browser-report.json`、`network-report.json`、`lifecycle-report.json`、`production-report.json` 为原始结果;PNG 包含主场景、发送/取消/失败、预览、各档降级与所有适配截图;trace 为 Chromium DevTools timeline 数据。证据随本卡附件交付,不以本地路径代替交付。
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..11e28e3
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,34 @@
+# 雨夜相处演示
+
+独立前端,对应 HEL-700,基准 `1e9b08a`。不连接 Python 性格、记忆、生活、排盘或语音引擎。
+
+```sh
+cd web
+npm ci
+npm run dev
+npm run typecheck
+npm run build
+npm run preview
+```
+
+开发与 preview 均提供同源 `/v1` 代理。生产由总工按 `nginx.conf.example` 合入已有站点配置,禁止把 Vite 开发服务作为生产服务。模板不选择端口、不修改服务器。模型配置集中在 `src/net/llm.ts` 的请求和探测中。
+
+目录边界:`app/` 真实 DOM 与视觉 token;`scene/` 只读取独立场景时间与氛围;`engine/audio.ts` 管理用户主动开启的程序雨声;`net/llm.ts` 管理探测、SSE、超时与取消;`demo/` 是演示台词与片刻的唯一来源;`state/contracts.ts` 镜像 Python State、Utterance 字段并定义前端消息类型。没有共享场景/输入状态。
+
+降级:全特效首先创建 Pixi WebGL,创建失败或上下文丢失切到独立 Canvas2D;Canvas 创建失败切 CSS 雨幕;连续 4 秒采样低于 30 FPS 逐级降低,CSS 仍低帧率则静态。轻量直接用 Canvas2D,静态或系统减少动效关闭环境与波形动画。用户手动选择档位可重新尝试。`body[data-renderer]` 标出实际渲染器,`#effects[data-running]` 标出运行状态,供审核检查。素材始终静态,只有雨幕与光膜位移。手机不读设备传感器。
+
+2 秒模型探测失败进入明确标注的演示模式。实时请求首个内容片段 8 秒超时,整包 40 秒超时。错误、截断、取消保留已有内容,不自动重试;只有用户点击重试才重置该失败回复。已产生后续消息时要求重新发送,防止重试混入过期上下文。模型内容始终 `textContent` 渲染。演示消息不会进入实时模型上下文。不会把模型错误转换为角色台词。
+
+雨声每次打开页面保持关闭;用户开启后,页面隐藏会暂停,返回按当次选择恢复。音量保存在 localStorage(存储不可用不影响使用)。设置中的预设仅修改场景灯光和雨密度,界面暖色 token 不变。
+
+手机默认抽屉 30dvh,展开 55dvh;横屏、矮屏或软键盘压缩可视区时切成上下可滚动布局。手柄支持点击/键盘与上下拖动。语音和附件只显示说明,不触发设备授权。
+
+浏览器回归用本机已有的 Python Playwright(不增加 npm 依赖):
+
+```sh
+python3 web/tests/browser_check.py
+```
+
+其余网络故障、生命周期与生产脚本见 `QA.md`,上述测试命令从仓库根执行。脚本启动并最终关闭本地 Vite 服务,输出到执行时指定的 `CYBERGRIL_ARTIFACT_DIR`(默认 `web/qa-artifacts`)。截图、采样和实际覆盖情况见任务卡交付附件。
+
+Pixi API 按官方 Application / Graphics 文档实现:https://pixijs.com/8.x/guides/components/application ,https://pixijs.com/8.x/guides/components/scene-objects/graphics 。
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..ed71487
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1 @@
+
CyberGril · 与晚晚相处
diff --git a/web/nginx.conf.example b/web/nginx.conf.example
new file mode 100644
index 0000000..9376692
--- /dev/null
+++ b/web/nginx.conf.example
@@ -0,0 +1,17 @@
+# Template only. Integrator selects the existing server and release root.
+# Insert these locations in the deployment owner's server block.
+location / {
+ root /srv/cybergril/current;
+ try_files $uri $uri/ /index.html;
+}
+location /v1/ {
+ proxy_pass http://192.168.200.11:8731;
+ proxy_http_version 1.1;
+ proxy_set_header Host $proxy_host;
+ proxy_set_header Connection "";
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 45s;
+ proxy_send_timeout 45s;
+ add_header X-Accel-Buffering no always;
+}
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..7d2179a
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,1166 @@
+{
+ "name": "cybergril-rain-room",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "cybergril-rain-room",
+ "version": "0.1.0",
+ "dependencies": {
+ "pixi.js": "^8.0.0"
+ },
+ "devDependencies": {
+ "typescript": "~5.9.3",
+ "vite": "^5.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@pixi/colord": {
+ "version": "2.9.6",
+ "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
+ "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==",
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.10.tgz",
+ "integrity": "sha512-oJ9ZfVIZjSjts0ZIYA48+5T0vurE5fQfX/Liad2U52Y6EN99x/OCdjGGuKMDP6jSk2i2lY6jwwA3X0drEc4PBg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.10.tgz",
+ "integrity": "sha512-LylBINgYYwgHNz8OMCy5+Y0w/LEgRYE0PcAIZfEuTuLNMf0S40GSLX6SFwqZsBLplS7RIHHy4zOAbl8Y5cQEvg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.10.tgz",
+ "integrity": "sha512-vbuT3VT1GBI2fAOoUJUrsHVoOg9nbrFXTM7e3DSnb9PYf4n6jZCN8v4Vo8O6x4u5B67+/c803b8BtCZe2nIeqA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.10.tgz",
+ "integrity": "sha512-qYUp/qYvM3T4cF+VWalPl+przBUESILxi+qZ1m1MVRHpg06vS+jHaxXubjCaRpXjLJPDFdXzOZ0FABDghHOhNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.10.tgz",
+ "integrity": "sha512-v9NRQG+JztbP4DP/0yuhSyDcEJk047lYbqeNnqIgbXITcVDjGDS7OpkW5nadSY3tJ7MY8W1qIMMqP4Q3Q8BDjQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.10.tgz",
+ "integrity": "sha512-CHc9LBHdxhn6/NXfb9IegXCWoz5VlvcDOx9QrzZ9q+XYhFlfzwymSWJjOlZcEMMtC1zgMV7qxDe6U18AI1SG0A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.10.tgz",
+ "integrity": "sha512-ble2WR7kWfzZFJUd3ODMGCJn1XDszptfRC0QvWc0h9/EA6MxnrQ1mvbjPZifEdgHyfHhDrwWeKeJONDITcrHSg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.10.tgz",
+ "integrity": "sha512-DMCYegihxBMKvLpODdk4OsvIgxtHTi+Oddf1Pm3d6wMVNjcBiG2pqyXjMPy55HgG1gCdEFr9VfhB1CmvuKn1lw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.10.tgz",
+ "integrity": "sha512-HQ6HuVH9TmhvPWIas7MA/KeAROgdwqH3REpUU6hZq47NW7m1b1QCrHgM+M3+N/B8DfMt88x1/z79ppRc9RflCQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.10.tgz",
+ "integrity": "sha512-avBhxba1DdctX0kxKkN3JWdTSg72QsxSn6FdfhZ9MkL3TTYDOXqxY1GqbC7VNttlgviK7tCe2No5btfRkFgeIg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.10.tgz",
+ "integrity": "sha512-UtQMex7fiXq0W/GqcWRpy6XKqUbXgQeGKB+Bd5rJngmHzpI+3kjwpWFLAhKg02onFsIMSh+Zx0zp9JIMQ7/05A==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.10.tgz",
+ "integrity": "sha512-0Aum2pUsCSuOopi6oka+nR1DTj/nfvc1xjL979lvh6kXUjNa30TCX7sKFjyMsrX7bK3bNVs0FsJwQcE2HCWX1Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.10.tgz",
+ "integrity": "sha512-4dgsouKal3qfMFORgtNigLPt7K3HXiO6jTr7+nFJv1GWu1OkZx0kvQIXbK4sjrIGnaLBk0s80eQCQ9hPIelLrw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.10.tgz",
+ "integrity": "sha512-d7NE89try3jisYzBD7J9M5iwfgcrXYC4dwHEjQ3HIWBftvwch1U/cy3iZ26SthtUOyClKiu7iqYiFjQEwiOlqQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.10.tgz",
+ "integrity": "sha512-dqB5ChSBp91JWXxpCKAS5ZVAE38esKQvdfyqANm0lF5JPiQrXnYwgG3oVBQmuNRcQ1WHkA3j5qYLsrl2CtfhCA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.10.tgz",
+ "integrity": "sha512-7GMf3vbQQslorWLmfsbi5fdt9RVnglk205XpOMFvW6UY5jE5oJc6JLieQJPHtsYlZ2EO0PtC06nY9Hz/IG8guw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.10.tgz",
+ "integrity": "sha512-tuKbkB23DKc5LG+irPSUnZZztRHqC3CNmyxeWloueBteHB+i+1HkRCMzAE41lseLDlarybeLhqfibFitKOxKMg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.10.tgz",
+ "integrity": "sha512-ntHtQOMw7oIshHRF1swV3vyBw+beQt9PeZ04O3D32EeDZUMoHMJuZKp4/fDllH/+xogeK0GUCONVfjjKusqKLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.10.tgz",
+ "integrity": "sha512-Dlh8q8r145aYgh+N3gNIG1dz35Fz/FCxe5hs/43orDsffjP8S1P4Xz5I4FpSJsDS4LpQw1QmswZkQlUbkCldjQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.10.tgz",
+ "integrity": "sha512-VBJhHWz/55u45CWFv9OmfSRDgJDVENESgFxSAn13gIKJpTuFJbP4/7ymMZhDfeKFx1zGn1XBsLnNB7Ryy71qww==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.10.tgz",
+ "integrity": "sha512-Vjcazg+EEzUfIz+LcaxS6YtbQHo+sv0sCD+5VbwhY504bCKNW4OOreVgKq61pHT2esRQ/+AXYVPStDwxSvAOdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.10.tgz",
+ "integrity": "sha512-jJAaP4El+HWxYkM0jeJghASaFgfnluY0FRHhyHXxU6STs/nBhKMIxIMDrJEFg2ftKz6orX9WAo4diVan5NugNA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.10.tgz",
+ "integrity": "sha512-bXXdRUfRx9PaHCToKt8R/7wjDV6Tbk/hmSHK0uQBKmD3rj4jkuYndct8uGX77ZuZsePrVkQbWUgPR2jJhCTMpQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.10.tgz",
+ "integrity": "sha512-57Idx+VkEOQeG6Ys6sZOBFT7FfbeyxGv9A3k1m+Lf/z2SwqT40rNWbAINzjPHSMoCCBvY7Fnb9RGZWQin/HHAw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.10.tgz",
+ "integrity": "sha512-vrvuasIpm/xf+BpZbzgirku0WnrQZMAc+H2Ai26jzqBxYEmQQE4DdJb7h8erkJMfRRipx0ma2Cty8jJwUm9ocQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/earcut": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
+ "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@webgpu/types": {
+ "version": "0.1.74",
+ "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
+ "integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@xmldom/xmldom": {
+ "version": "0.8.15",
+ "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz",
+ "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/earcut": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.4.tgz",
+ "integrity": "sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==",
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gifuct-js": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
+ "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
+ "license": "MIT",
+ "dependencies": {
+ "js-binary-schema-parser": "^2.0.3"
+ }
+ },
+ "node_modules/ismobilejs": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
+ "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
+ "license": "MIT"
+ },
+ "node_modules/js-binary-schema-parser": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
+ "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/parse-svg-path": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz",
+ "integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/pixi.js": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.22.0.tgz",
+ "integrity": "sha512-QbTANHMZ751MIjLiLSUSM8a7LNMWwMIf0KbRCqlW+SPS9hNLfSVS+cl1O2SEp9g1K/wyLzj/OJHv+1TUfiPr/w==",
+ "license": "MIT",
+ "workspaces": [
+ "examples",
+ "playground"
+ ],
+ "dependencies": {
+ "@pixi/colord": "^2.9.6",
+ "@types/earcut": "^3.0.0",
+ "@webgpu/types": "^0.1.69",
+ "@xmldom/xmldom": "^0.8.15",
+ "earcut": "^3.0.2",
+ "eventemitter3": "^5.0.1",
+ "gifuct-js": "^2.1.2",
+ "ismobilejs": "^1.1.1",
+ "parse-svg-path": "^0.2.0",
+ "tiny-lru": "^11.4.7"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/pixijs"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
+ "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.20",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.64.10",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.10.tgz",
+ "integrity": "sha512-8N52YH1B5JzBP+B1rkcBERG7rfRTGAM0RmUpyd76yyRfTljuG452GCNbVm6MPzs1+gXBha6Rw5/oHRFohOpfDg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.64.10",
+ "@rollup/rollup-android-arm64": "4.64.10",
+ "@rollup/rollup-darwin-arm64": "4.64.10",
+ "@rollup/rollup-darwin-x64": "4.64.10",
+ "@rollup/rollup-freebsd-arm64": "4.64.10",
+ "@rollup/rollup-freebsd-x64": "4.64.10",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.64.10",
+ "@rollup/rollup-linux-arm-musleabihf": "4.64.10",
+ "@rollup/rollup-linux-arm64-gnu": "4.64.10",
+ "@rollup/rollup-linux-arm64-musl": "4.64.10",
+ "@rollup/rollup-linux-loong64-gnu": "4.64.10",
+ "@rollup/rollup-linux-loong64-musl": "4.64.10",
+ "@rollup/rollup-linux-ppc64-gnu": "4.64.10",
+ "@rollup/rollup-linux-ppc64-musl": "4.64.10",
+ "@rollup/rollup-linux-riscv64-gnu": "4.64.10",
+ "@rollup/rollup-linux-riscv64-musl": "4.64.10",
+ "@rollup/rollup-linux-s390x-gnu": "4.64.10",
+ "@rollup/rollup-linux-x64-gnu": "4.64.10",
+ "@rollup/rollup-linux-x64-musl": "4.64.10",
+ "@rollup/rollup-openbsd-x64": "4.64.10",
+ "@rollup/rollup-openharmony-arm64": "4.64.10",
+ "@rollup/rollup-win32-arm64-msvc": "4.64.10",
+ "@rollup/rollup-win32-ia32-msvc": "4.64.10",
+ "@rollup/rollup-win32-x64-gnu": "4.64.10",
+ "@rollup/rollup-win32-x64-msvc": "4.64.10",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tiny-lru": {
+ "version": "11.4.7",
+ "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
+ "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..b5317e8
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "cybergril-rain-room",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 127.0.0.1",
+ "typecheck": "tsc --noEmit",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview --host 127.0.0.1"
+ },
+ "dependencies": {
+ "pixi.js": "^8.0.0"
+ },
+ "devDependencies": {
+ "typescript": "~5.9.3",
+ "vite": "^5.4.0"
+ }
+}
diff --git a/web/public/assets/rain-room.jpg b/web/public/assets/rain-room.jpg
new file mode 100644
index 0000000..c244de4
Binary files /dev/null and b/web/public/assets/rain-room.jpg differ
diff --git a/web/public/assets/rain-room.webp b/web/public/assets/rain-room.webp
new file mode 100644
index 0000000..3eefc52
Binary files /dev/null and b/web/public/assets/rain-room.webp differ
diff --git a/web/src/app/shell.ts b/web/src/app/shell.ts
new file mode 100644
index 0000000..a9a12fb
--- /dev/null
+++ b/web/src/app/shell.ts
@@ -0,0 +1,14 @@
+// Static application chrome only. All message/model content uses textContent.
+export const shell = `
+
+
+演示版 · 性格与记忆引擎未接入
+
+
+
+
+波形演示 · 无语音播放
+
+正在探测模型…
0 / 500文字 / 语音
语音录入与 TTS 未接入;附件功能未接入。
+
+`;
diff --git a/web/src/app/style.css b/web/src/app/style.css
new file mode 100644
index 0000000..315dd54
--- /dev/null
+++ b/web/src/app/style.css
@@ -0,0 +1,1015 @@
+:root {
+ --scene-night-deep:#16233c;
+ --scene-city-blue:#305481;
+ --scene-city-mid:#5a76a2;
+ --scene-tower:#2b456b;
+ --scene-glass-blue:#718fc2;
+ --scene-amber:#e8a45c;
+ --scene-amber-soft:#f2c894;
+ --scene-wood:#59361d;
+ --scene-cream:#e8d9c8;
+ --scene-blanket:#362928;
+ --ink-primary:#f2efe9;
+ --ink-secondary:rgba(242,239,233,.64);
+ --ink-tertiary:rgba(242,239,233,.4);
+ --ink-disabled:rgba(242,239,233,.28);
+ --accent:#e8a45c;
+ --accent-hover:#f2b879;
+ --accent-pressed:#c9853f;
+ --accent-ink:#1a1208;
+ --glass-bg:rgba(14,18,30,.55);
+ --glass-bg-strong:rgba(14,18,30,.72);
+ --glass-border:rgba(255,255,255,.08);
+ --glass-highlight:rgba(255,255,255,.14);
+ --bubble-user:rgba(120,144,180,.2);
+ --bubble-user-border:rgba(255,255,255,.07);
+ --bubble-her:rgba(255,255,255,.06);
+ --bubble-failed:rgba(200,90,80,.14);
+ --state-danger:#e07a6a;
+ --font-sans:"PingFang SC","HarmonyOS Sans SC","MiSans","Source Han Sans SC",system-ui,-apple-system,sans-serif;
+ --fs-caption:12px;
+ --fs-small:13px;
+ --fs-body:14px;
+ --fs-chat:15px;
+ --fs-nav:15px;
+ --fs-title:20px;
+ --fs-brand:16px;
+ --fs-time:13px;
+ --space-1:4px;
+ --space-2:8px;
+ --space-3:12px;
+ --space-4:16px;
+ --space-5:20px;
+ --space-6:24px;
+ --space-8:32px;
+ --space-10:40px;
+ --r-panel:24px;
+ --r-bubble:16px;
+ --r-card:16px;
+ --r-control:12px;
+ --r-pill:999px;
+ --shadow-panel:0 24px 80px rgba(0,0,0,.45);
+ --shadow-pop:0 8px 24px rgba(0,0,0,.35);
+ --glow-accent:0 0 12px rgba(232,164,92,.45);
+ --glass-blur:blur(28px) saturate(1.2);
+ --z-scene-far:0;
+ --z-scene-rain:1;
+ --z-scene-near:2;
+ --z-caption:10;
+ --z-nav:10;
+ --z-topbar:20;
+ --z-panel:30;
+ --z-toast:40;
+ --ease-out:cubic-bezier(.23,1,.32,1);
+ --ease-in-out:cubic-bezier(.77,0,.175,1);
+ --ease-drawer:cubic-bezier(.32,.72,0,1);
+ --lamp-opacity:.12;
+ --panel-width:clamp(360px,30vw,460px);
+ --panel-top:64px;
+ --panel-inset:24px;
+ --scene-height:330px;
+ --drawer-height:30dvh;
+}
+* {
+ box-sizing:border-box
+}
+[hidden] {
+ display:none!important
+}
+html,body {
+ margin:0;
+ min-height:100%;
+ background:var(--scene-night-deep);
+ color:var(--ink-primary);
+ font-family:var(--font-sans);
+ font-size:var(--fs-body);
+ letter-spacing:.02em
+}
+body {
+ height:100dvh;
+ overflow:hidden
+}
+button,input,textarea,select {
+ font:inherit;
+ color:inherit
+}
+button {
+ cursor:pointer;
+ border:1px solid transparent;
+ background:transparent;
+ border-radius:var(--r-control);
+ padding:var(--space-2) var(--space-3);
+ transition:transform 160ms var(--ease-out),opacity 160ms var(--ease-out)
+}
+button:active {
+ transform:scale(.94);
+ opacity:.7
+}
+button:disabled {
+ color:var(--ink-disabled);
+ cursor:default
+}
+button[aria-disabled=true] {
+ color:var(--ink-disabled)
+}
+button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible {
+ outline:2px solid var(--accent);
+ outline-offset:3px
+}
+p,h1,h2 {
+ margin:0
+}
+small {
+ font-size:var(--fs-caption);
+ color:var(--ink-secondary)
+}
+h1 {
+ font-size:var(--fs-title);
+ font-weight:500
+}
+h2 {
+ font-size:var(--fs-title);
+ font-weight:500
+}
+.glass {
+ background:var(--glass-bg);
+ border:1px solid var(--glass-border);
+ border-radius:var(--r-panel);
+ backdrop-filter:var(--glass-blur);
+ -webkit-backdrop-filter:var(--glass-blur);
+ box-shadow:inset 0 1px 0 var(--glass-highlight),var(--shadow-panel)
+}
+.scene {
+ position:fixed;
+ inset:0;
+ z-index:var(--z-scene-far);
+ pointer-events:none;
+ overflow:hidden
+}
+.scene picture,.scene img {
+ position:absolute;
+ width:100%;
+ height:100%;
+ object-fit:cover;
+ object-position:32% 40%
+}
+.shade {
+ position:absolute;
+ inset:0;
+ background:linear-gradient(180deg,var(--glass-bg-strong),transparent 17%,transparent 70%,var(--glass-bg-strong))
+}
+#effects {
+ position:absolute;
+ inset:-12px;
+ mask-image:linear-gradient(90deg,transparent 42%,black 54%,black 92%,transparent);
+ will-change:transform
+}
+#effects canvas {
+ width:100%;
+ height:100%;
+ display:block
+}
+.lamp {
+ position:absolute;
+ inset:0;
+ background:radial-gradient(ellipse at 18% 35%,var(--scene-amber),transparent 50%);
+ opacity:var(--lamp-opacity)
+}
+.css-rain {
+ display:none;
+ position:absolute;
+ inset:-25%;
+ left:48%;
+ opacity:.25;
+ background:repeating-linear-gradient(100deg,transparent 0 70px,var(--scene-glass-blue) 71px,transparent 72px 170px);
+ animation:rain 9s linear infinite
+}
+.light-trail {
+ position:absolute;
+ left:55%;
+ top:56%;
+ width:20%;
+ height:1px;
+ background:linear-gradient(90deg,transparent,var(--scene-amber-soft),transparent);
+ opacity:.4;
+ animation:trail 14s linear infinite
+}
+.dust {
+ position:absolute;
+ left:17%;
+ top:27%;
+ width:18%;
+ height:34%;
+ opacity:.25;
+ background:radial-gradient(circle at 10% 20%,var(--scene-amber-soft) 0 1px,transparent 2px),radial-gradient(circle at 75% 40%,var(--scene-amber-soft) 0 1px,transparent 2px),radial-gradient(circle at 30% 80%,var(--scene-amber-soft) 0 1px,transparent 2px);
+ background-size:49px 79px;
+ animation:dust 16s linear infinite
+}
+header {
+ height:52px;
+ padding:0 28px;
+ position:fixed;
+ inset:0 0 auto;
+ z-index:var(--z-topbar);
+ display:flex;
+ align-items:center;
+ justify-content:space-between
+}
+.brand {
+ font-size:var(--fs-brand);
+ font-weight:600;
+ letter-spacing:.06em;
+ display:flex;
+ align-items:center;
+ gap:var(--space-3)
+}
+.brand>span:last-child {
+ font-weight:400;
+ letter-spacing:.02em
+}
+.moon {
+ font-size:26px;
+ color:var(--accent)
+}
+.clock {
+ display:flex;
+ align-items:center;
+ gap:var(--space-4);
+ font-size:var(--fs-time);
+ color:var(--ink-secondary)
+}
+time {
+ font-variant-numeric:tabular-nums;
+ color:var(--ink-primary)
+}
+.clock button {
+ font-size:20px
+}
+.badge {
+ position:fixed;
+ left:32px;
+ top:66px;
+ z-index:var(--z-caption);
+ font-size:var(--fs-caption);
+ color:var(--ink-secondary)
+}
+nav {
+ position:fixed;
+ left:24px;
+ top:60%;
+ z-index:var(--z-nav);
+ display:flex;
+ flex-direction:column;
+ gap:var(--space-3)
+}
+nav button {
+ text-align:left;
+ font-size:var(--fs-nav);
+ color:var(--ink-secondary);
+ position:relative
+}
+nav button[aria-current] {
+ color:var(--ink-primary)
+}
+nav button[aria-current]:before {
+ content:"";
+ width:6px;
+ height:6px;
+ border-radius:50%;
+ background:var(--accent);
+ position:absolute;
+ left:-4px;
+ top:calc(50% - 3px)
+}
+nav small {
+ border:1px solid var(--glass-border);
+ border-radius:var(--r-pill);
+ padding:1px 5px;
+ margin-left:var(--space-1)
+}
+.caption {
+ white-space:pre-line;
+ position:fixed;
+ left:32px;
+ bottom:40px;
+ color:var(--ink-secondary);
+ line-height:1.8;
+ z-index:var(--z-caption)
+}
+.panel {
+ position:fixed;
+ right:var(--panel-inset);
+ top:var(--panel-top);
+ bottom:var(--panel-inset);
+ width:var(--panel-width);
+ z-index:var(--z-panel);
+ padding:var(--space-5);
+ display:flex;
+ flex-direction:column;
+ gap:var(--space-4);
+ min-height:0;
+ transition:transform 240ms var(--ease-out),opacity 240ms var(--ease-out)
+}
+.panel-head {
+ display:flex;
+ align-items:center;
+ justify-content:space-between;
+ min-height:56px
+}
+.panel-head p {
+ color:var(--ink-secondary);
+ font-size:var(--fs-small);
+ margin-top:var(--space-1)
+}
+.presence {
+ display:inline-block;
+ width:8px;
+ height:8px;
+ background:var(--accent);
+ border-radius:50%;
+ box-shadow:var(--glow-accent);
+ margin-right:var(--space-1);
+ animation:presence 2.4s var(--ease-in-out) infinite
+}
+.panel-head button {
+ font-size:24px
+}
+.handle {
+ display:none
+}
+.view {
+ flex:1;
+ display:flex;
+ flex-direction:column;
+ min-height:0;
+ gap:var(--space-4);
+ position:relative
+}
+#messages {
+ flex:1;
+ overflow:auto;
+ overscroll-behavior:contain;
+ scrollbar-width:thin;
+ scrollbar-color:var(--glass-highlight) transparent;
+ min-height:0;
+ padding-right:var(--space-1)
+}
+.date {
+ text-align:center;
+ font-size:var(--fs-caption);
+ color:var(--ink-tertiary);
+ margin:var(--space-3) 0 var(--space-6)
+}
+.message {
+ max-width:78%;
+ width:fit-content;
+ background:var(--bubble-her);
+ border:1px solid var(--glass-border);
+ border-radius:var(--r-bubble);
+ padding:var(--space-3) var(--space-4);
+ margin-bottom:var(--space-4);
+ overflow-wrap:anywhere;
+ animation:appear 180ms var(--ease-out)
+}
+.message p {
+ white-space:pre-wrap;
+ font-size:var(--fs-chat);
+ line-height:1.6
+}
+.message>small {
+ display:block;
+ font-size:10px;
+ color:var(--ink-tertiary);
+ margin-bottom:var(--space-1)
+}
+.message.user {
+ margin-left:auto;
+ background:var(--bubble-user);
+ border-color:var(--bubble-user-border);
+ border-bottom-right-radius:6px
+}
+.message.pending {
+ opacity:.55
+}
+.message:has(.error) {
+ background:var(--bubble-failed)
+}
+.error {
+ color:var(--state-danger);
+ font-size:var(--fs-caption);
+ margin-top:var(--space-2)
+}
+.error button {
+ color:var(--accent);
+ padding:var(--space-1) var(--space-2)
+}
+#bottom {
+ position:absolute;
+ bottom:140px;
+ left:30%;
+ background:var(--glass-bg-strong);
+ color:var(--accent);
+ box-shadow:var(--shadow-pop)
+}
+.wave-card {
+ display:flex;
+ align-items:center;
+ gap:var(--space-2);
+ flex-wrap:wrap
+}
+.wave-card>button {
+ color:var(--accent);
+ border-color:var(--accent);
+ border-radius:var(--r-pill);
+ height:32px;
+ width:32px;
+ padding:0
+}
+.wave-bars {
+ height:20px;
+ display:flex;
+ gap:2px;
+ align-items:center
+}
+.wave-bars i {
+ width:2px;
+ height:var(--bar-height);
+ background:var(--ink-secondary);
+ border-radius:var(--r-pill)
+}
+.wave-bars.playing i {
+ animation:wave 700ms var(--ease-in-out) infinite alternate;
+ animation-delay:var(--delay)
+}
+.wave-card>small {
+ font-size:10px;
+ color:var(--ink-tertiary);
+ width:100%;
+ padding-left:40px
+}
+.moment {
+ display:flex;
+ align-items:center;
+ gap:var(--space-3);
+ background:var(--bubble-her);
+ padding:var(--space-2);
+ border-radius:var(--r-card);
+ min-height:64px;
+ text-align:left;
+ flex-shrink:0
+}
+.moment>span:last-child {
+ margin-left:auto;
+ font-size:24px;
+ color:var(--ink-secondary)
+}
+.thumb {
+ width:44px;
+ height:44px;
+ border-radius:var(--r-control);
+ background:url('/assets/rain-room.webp') 15% 50% / auto 90px
+}
+.moment small {
+ line-height:1.8;
+ color:var(--ink-tertiary)
+}
+.compose-area {
+ flex-shrink:0
+}
+.mode-row {
+ display:flex;
+ justify-content:space-between;
+ align-items:center;
+ font-size:10px;
+ color:var(--ink-tertiary);
+ margin-bottom:var(--space-2);
+ gap:var(--space-1)
+}
+.mode-row button {
+ padding:var(--space-1);
+ color:var(--ink-secondary);
+ font-size:10px
+}
+form {
+ display:flex;
+ gap:var(--space-1);
+ align-items:center;
+ border:1px solid var(--glass-border);
+ background:var(--glass-bg-strong);
+ border-radius:var(--r-pill);
+ padding:var(--space-1);
+ min-height:48px
+}
+form:focus-within {
+ border-color:var(--accent);
+ box-shadow:var(--glow-accent)
+}
+form button svg {
+ width:18px;
+ height:18px;
+ fill:none;
+ stroke:currentColor;
+ stroke-width:1.5;
+ stroke-linecap:round;
+ stroke-linejoin:round;
+ vertical-align:middle
+}
+form button {
+ padding:0;
+ width:32px;
+ height:36px;
+ flex-shrink:0
+}
+textarea {
+ flex:1;
+ min-width:0;
+ resize:none;
+ max-height:80px;
+ border:0;
+ outline:none!important;
+ background:transparent;
+ line-height:1.6;
+ font-size:var(--fs-body);
+ padding:var(--space-2) 0
+}
+textarea::placeholder {
+ color:var(--ink-tertiary)
+}
+#send {
+ border-radius:var(--r-pill);
+ background:var(--accent);
+ color:var(--accent-ink);
+ font-size:24px;
+ width:36px
+}
+#voice {
+ border:1.5px solid var(--accent);
+ border-radius:var(--r-pill);
+ width:32px;
+ height:32px;
+ box-shadow:var(--glow-accent)
+}
+.compose-meta {
+ display:flex;
+ justify-content:space-between;
+ align-items:center;
+ color:var(--ink-tertiary);
+ font-size:var(--fs-caption);
+ margin-top:var(--space-2)
+}
+.compose-meta button {
+ padding:0;
+ color:var(--accent)
+}
+#voice-note {
+ font-size:10px;
+ color:var(--ink-tertiary);
+ margin-top:var(--space-2)
+}
+#chat-status {
+ font-size:10px;
+ color:var(--ink-secondary);
+ min-height:14px;
+ margin-top:var(--space-1)
+}
+#restore {
+ position:fixed;
+ right:24px;
+ bottom:24px;
+ z-index:var(--z-panel);
+ padding:var(--space-4);
+ color:var(--accent)
+}
+.chat-hidden .panel {
+ opacity:0;
+ transform:translateX(calc(100% + 24px));
+ pointer-events:none;
+ visibility:hidden
+}
+#preview-view {
+ overflow:auto;
+ line-height:1.8
+}
+#preview-view h2 {
+ margin:var(--space-5) 0
+}
+#preview-content>p {
+ margin:var(--space-4) 0;
+ padding:var(--space-4);
+ background:var(--bubble-her);
+ border-radius:var(--r-card)
+}
+#preview-content small {
+ color:var(--accent)
+}
+dialog {
+ position:fixed;
+ top:64px;
+ right:var(--panel-inset);
+ bottom:auto;
+ left:auto;
+ margin:0;
+ width:var(--panel-width);
+ color:var(--ink-primary);
+ padding:var(--space-6);
+ max-height:calc(100dvh - 88px);
+ overflow:auto
+}
+dialog::backdrop {
+ background:var(--glass-bg-strong)
+}
+.dialog-head {
+ display:flex;
+ justify-content:space-between;
+ align-items:center;
+ margin-bottom:var(--space-5)
+}
+.dialog-head button {
+ font-size:24px
+}
+dialog p {
+ color:var(--ink-secondary);
+ font-size:var(--fs-small);
+ line-height:1.6
+}
+dialog label {
+ display:flex;
+ flex-direction:column;
+ gap:var(--space-3);
+ margin:var(--space-5) 0
+}
+dialog select {
+ background:var(--scene-night-deep);
+ border:1px solid var(--glass-highlight);
+ padding:var(--space-3);
+ border-radius:var(--r-control)
+}
+.choices {
+ display:flex;
+ flex-wrap:wrap;
+ gap:var(--space-2);
+ margin:var(--space-4) 0
+}
+.choices button {
+ border-color:var(--glass-highlight);
+ font-size:var(--fs-small)
+}
+.choices button[aria-pressed=true] {
+ color:var(--accent);
+ border-color:var(--accent)
+}
+dialog .audio-label {
+ flex-direction:row;
+ align-items:center
+}
+input {
+ accent-color:var(--accent)
+}
+#toast {
+ position:fixed;
+ left:50%;
+ bottom:var(--space-6);
+ transform:translateX(-50%);
+ max-width:calc(100vw - 32px);
+ width:max-content;
+ padding:var(--space-3) var(--space-5);
+ z-index:var(--z-toast);
+ font-size:var(--fs-small);
+ pointer-events:none
+}
+@keyframes rain {
+ to {
+ transform:translateY(25%)
+ }
+}
+@keyframes trail {
+ from {
+ transform:translateX(-50%);
+ opacity:.1
+ }
+ 50% {
+ opacity:.5
+ }
+ to {
+ transform:translateX(80%);
+ opacity:.1
+ }
+}
+@keyframes dust {
+ 50% {
+ transform:translate(8px,-14px);
+ opacity:.15
+ }
+}
+@keyframes presence {
+ 50% {
+ opacity:.5
+ }
+}
+@keyframes wave {
+ to {
+ transform:scaleY(.3)
+ }
+}
+@keyframes appear {
+ from {
+ transform:translateY(8px);
+ opacity:0
+ }
+ to {
+ transform:translateY(0);
+ opacity:1
+ }
+}
+body[data-renderer=css] .css-rain {
+ display:block
+}
+body[data-motion=static] .scene * {
+ animation:none!important
+}
+body[data-motion=static] #effects {
+ transform:none!important
+}
+body[data-motion=static] .light-trail,body[data-motion=static] .dust {
+ display:none
+}
+.paused *,.paused *::before,.paused *::after {
+ animation-play-state:paused!important
+}
+@media(hover:hover) and (pointer:fine) {
+ button:hover {
+ color:var(--accent-hover);
+ border-color:var(--glass-highlight)
+ }
+ nav button:hover {
+ transform:translateX(-2px)
+ }
+ #send:hover {
+ background:var(--accent-hover);
+ color:var(--accent-ink)
+ }
+}
+@supports not (backdrop-filter:blur(1px)) {
+ .glass {
+ background:var(--glass-bg-strong)
+ }
+}
+@media(min-width:768px) and (max-width:1279px) {
+ :root {
+ --panel-width:340px;
+ --panel-inset:16px
+ }
+ nav {
+ left:16px
+ }
+ .caption {
+ left:24px
+ }
+}
+@media(min-width:768px) and (max-width:1279px) and (orientation:portrait) {
+ .scene img {
+ object-position:44% 40%
+ }
+}
+@media(max-width:767px) {
+ dialog {
+ top:auto;
+ bottom:0;
+ right:0;
+ left:0;
+ width:100%;
+ max-width:100%;
+ max-height:55dvh;
+ border-radius:var(--r-panel) var(--r-panel) 0 0;
+ }
+ :root {
+ --panel-inset:0px;
+ --panel-top:auto
+ }
+ header {
+ height:44px;
+ padding:0 var(--space-4)
+ }
+ .brand {
+ font-size:var(--fs-body);
+ gap:var(--space-2)
+ }
+ .clock {
+ font-size:0;
+ gap:var(--space-2)
+ }
+ .clock time {
+ font-size:var(--fs-time)
+ }
+ nav {
+ top:48px;
+ left:var(--space-3);
+ right:var(--space-3);
+ flex-direction:row;
+ gap:var(--space-2)
+ }
+ nav button {
+ font-size:var(--fs-small);
+ padding:var(--space-2)
+ }
+ nav button[aria-current]:before {
+ width:24px;
+ height:2px;
+ left:var(--space-2);
+ top:auto;
+ bottom:0
+ }
+ .badge {
+ left:var(--space-5);
+ top:96px;
+ font-size:10px
+ }
+ .scene img {
+ object-position:30% 30%
+ }
+ .panel {
+ width:100%;
+ height:var(--drawer-height);
+ bottom:0;
+ border-radius:var(--r-panel) var(--r-panel) 0 0;
+ padding:var(--space-3) var(--space-4) max(var(--space-3),env(safe-area-inset-bottom));
+ gap:var(--space-2)
+ }
+ .handle {
+ display:block;
+ position:absolute;
+ top:0;
+ left:calc(50% - 40px);
+ width:80px;
+ height:20px;
+ padding:0;
+ touch-action:none
+ }
+ .handle span {
+ display:block;
+ background:var(--glass-highlight);
+ width:36px;
+ height:4px;
+ border-radius:var(--r-pill);
+ margin:auto
+ }
+ .panel-head {
+ min-height:32px;
+ padding-top:var(--space-1)
+ }
+ .panel-head h1 {
+ font-size:var(--fs-brand)
+ }
+ .panel-head p {
+ display:none
+ }
+ .date,.moment,.wave-card {
+ display:none
+ }
+ .compose-meta {
+ margin-top:var(--space-1);
+ font-size:10px
+ }
+ #voice-note {
+ font-size:9px;
+ margin-top:var(--space-1)
+ }
+ .message {
+ padding:var(--space-2) var(--space-3);
+ margin-bottom:var(--space-2)
+ }
+ .message p {
+ font-size:var(--fs-body)
+ }
+ .message>small {
+ font-size:9px
+ }
+ .mode-row {
+ margin-bottom:var(--space-1)
+ }
+ .caption {
+ left:var(--space-5);
+ bottom:calc(var(--drawer-height) + 20px);
+ font-size:var(--fs-small)
+ }
+ body.expanded {
+ --drawer-height:55dvh
+ }
+ .expanded .caption {
+ display:none
+ }
+ .expanded .moment {
+ display:flex
+ }
+ .expanded .date {
+ display:block
+ }
+ .panel-head button {
+ padding:0 var(--space-2)
+ }
+ #bottom {
+ bottom:var(--space-3);
+ left:30%
+ }
+ .expanded #bottom {
+ bottom:80px
+ }
+ #toast {
+ bottom:16px
+ }
+ .chat-hidden .panel {
+ transform:translateY(100%)
+ }
+ #restore {
+ bottom:var(--space-4);
+ right:var(--space-4)
+ }
+}
+/* Short screens and a visible software keyboard get a scrollable scene above chat. */
+@media(max-width:950px) and (max-height:600px) {
+ body {
+ height:auto;
+ min-height:100dvh;
+ overflow:auto
+ }
+ .scene {
+ position:absolute;
+ height:var(--scene-height);
+ bottom:auto
+ }
+ .scene img {
+ object-position:30% 30%
+ }
+ header {
+ position:absolute
+ }
+ .badge {
+ position:absolute;
+ top:calc(var(--scene-height) - 24px)
+ }
+ nav {
+ position:absolute;
+ top:48px;
+ left:var(--space-3);
+ flex-direction:row
+ }
+ .caption {
+ display:none
+ }
+ .panel {
+ position:relative;
+ top:auto;
+ right:auto;
+ bottom:auto;
+ width:min(100%,680px);
+ height:480px;
+ margin:var(--scene-height) auto 0;
+ border-radius:var(--r-panel) var(--r-panel) 0 0
+ }
+ .chat-hidden .panel {
+ display:none
+ }
+ #restore {
+ position:relative;
+ margin-top:var(--scene-height)
+ }
+ #toast {
+ bottom:12px
+ }
+}
+body.keyboard {
+ height:auto;
+ overflow:auto
+}
+body.keyboard .scene {
+ position:absolute;
+ height:var(--scene-height);
+ bottom:auto
+}
+body.keyboard header,body.keyboard nav,body.keyboard .badge {
+ position:absolute
+}
+body.keyboard .panel {
+ position:relative;
+ top:auto;
+ right:auto;
+ bottom:auto;
+ margin-top:var(--scene-height);
+ height:480px;
+ width:100%
+}
+body.keyboard .badge {
+ top:calc(var(--scene-height) - 24px)
+}
+body.keyboard .caption {
+ display:none
+}
+@media(prefers-reduced-motion:reduce) {
+ *,*::before,*::after {
+ animation:none!important;
+ transition:opacity 200ms!important
+ }
+ #effects {
+ transform:none!important
+ }
+}
diff --git a/web/src/demo/script.ts b/web/src/demo/script.ts
new file mode 100644
index 0000000..d61e5ed
--- /dev/null
+++ b/web/src/demo/script.ts
@@ -0,0 +1,11 @@
+export const caption = ['窗外下雨了。', '她刚读完一本书的一页。'];
+export const momentTitle = '雨声 · 阅读';
+export const opening = ['窗外又下雨了。', '刚好读完一页书。你今天过得怎么样?'];
+export const moments = [ ['20:36', '把台灯调暗了一点,留一盏暖光给这一页。'], ['21:02', '读到一句喜欢的话,停下来听了一会儿雨。'], ['21:08', '书还摊在膝上,城市在窗外慢慢亮着。'] ];
+export const presets = [ { name: '雨夜阅读', density: 1, warmth: .12 }, { name: '暖灯小憩', density: .55, warmth: .22 }, { name: '静听蓝城', density: 1.4, warmth: .04 } ];
+export function reply(text: string, turn: number): string {
+ if (/书|读/.test(text)) return '这段演示里,我正翻到书中描写雨夜的那一页。你最近有读到喜欢的句子吗?';
+ if (/累|忙|难过/.test(text)) return '那就先歇一会儿吧。不急着把今天说完,也可以从一件小事慢慢说起。';
+ if (/雨|晚|好/.test(text)) return '雨落在窗上的声音很轻。你那边的夜晚是什么样的?';
+ return ['这是一段预设的演示对话。你可以试着和我聊聊书、雨夜,或者今天的心情。', '把今天留一点在这里吧。现在显示的是演示台词,尚未接入性格与记忆引擎。'][turn % 2];
+}
diff --git a/web/src/engine/audio.ts b/web/src/engine/audio.ts
new file mode 100644
index 0000000..85d430a
--- /dev/null
+++ b/web/src/engine/audio.ts
@@ -0,0 +1,25 @@
+export class RainAudio {
+ private context?: AudioContext;
+ private gain?: GainNode;
+ enabled = false;
+ volume = .25;
+ async set(enabled: boolean, volume = this.volume) {
+ this.enabled = enabled; this.volume = volume;
+ if (enabled && !this.context) {
+ this.context = new AudioContext();
+ const buffer = this.context.createBuffer(1, this.context.sampleRate * 3, this.context.sampleRate);
+ const values = buffer.getChannelData(0);
+ for (let i = 0; i < values.length; i++) values[i] = Math.random() * 2 - 1;
+ const source = this.context.createBufferSource(); source.buffer = buffer; source.loop = true;
+ const filter = this.context.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.value = 1200;
+ this.gain = this.context.createGain();
+ source.connect(filter).connect(this.gain).connect(this.context.destination); source.start();
+ }
+ if (this.gain && this.context) this.gain.gain.setTargetAtTime(volume * .3, this.context.currentTime, .1);
+ if (this.context) {
+ if (enabled && !document.hidden) await this.context.resume();
+ else await this.context.suspend();
+ }
+ }
+ async visibility() { if (this.context) { if (document.hidden) await this.context.suspend(); else if (this.enabled) await this.context.resume(); } }
+}
diff --git a/web/src/main.ts b/web/src/main.ts
new file mode 100644
index 0000000..e4efc97
--- /dev/null
+++ b/web/src/main.ts
@@ -0,0 +1,159 @@
+import './app/style.css';
+import { shell } from './app/shell';
+import { opening, moments, presets, reply, caption, momentTitle } from './demo/script';
+import { probe, stream, demoStream } from './net/llm';
+import { Scene } from './scene/scene';
+import { RainAudio } from './engine/audio';
+import type { ChatMessage, FxLevel } from './state/contracts';
+const $ = (id: string) => document.getElementById(id) as T;
+$('app').innerHTML = shell;
+$('caption').textContent = caption.join('\n');
+$('moment-title').textContent = momentTitle;
+let toastTimer: ReturnType;
+function toast(text: string) { $('toast').textContent = text; $('toast').hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { $('toast').hidden = true; }, 5000); }
+const scene = new Scene($('effects'), toast);
+void scene.set('full');
+const audio = new RainAudio();
+const history: ChatMessage[] = [];
+let live = false, modeChosen = false, probing = true, active: AbortController | undefined, composing = false, follow = true, turn = 0;
+let view = 'chat';
+const input = $('input');
+const messages = $('messages');
+function scroll() { if (follow) messages.scrollTop = messages.scrollHeight; }
+new ResizeObserver(() => { if (follow) scroll(); }).observe(messages);
+messages.addEventListener('scroll', () => { follow = messages.scrollHeight - messages.scrollTop - messages.clientHeight < 70; $('bottom').hidden = follow; });
+$('bottom').onclick = () => { follow = true; scroll(); $('bottom').hidden = true; };
+function bubble(message: ChatMessage) {
+ const article = document.createElement('article'); article.className = `message ${message.role}`;
+ const label = document.createElement('small'); label.textContent = message.source === 'live' ? '实时模型' : '演示对话 · 非实时模型';
+ const text = document.createElement('p'); text.textContent = message.text;
+ article.append(label, text); messages.append(article); scroll(); return { article, text };
+}
+for (const text of opening) { const message: ChatMessage = { role: 'assistant', text, source: 'demo', complete: true }; history.push(message); bubble(message); }
+function modeLabel() {
+ $('mode').disabled = false;
+ $('connection').textContent = live ? '实时模型 · 已连接' : '演示对话 · 非实时模型';
+ $('mode').textContent = live ? '切换演示' : '检测并连接模型';
+}
+void probe().then(ok => { probing = false; if (!modeChosen) { live = ok; modeLabel(); if (!ok) toast('内网 LLM 不可达,已进入明确标注的演示模式'); } });
+$('mode').onclick = async () => {
+ if (active) { toast('请先取消当前回应'); return; }
+ modeChosen = true;
+ if (live) { live = false; modeLabel(); return; }
+ $('connection').textContent = '正在探测模型…';
+ const ok = await probe(); live = ok; modeLabel(); if (!ok) toast('模型不可达,继续使用演示模式');
+};
+async function respond(user: ChatMessage, existing?: ChatMessage, node?: ReturnType) {
+ const source = user.source;
+ const response: ChatMessage = existing ?? { role: 'assistant', text: '', source, complete: false };
+ if (!existing) history.push(response);
+ response.text = ''; response.complete = false;
+ const elements = node ?? bubble(response);
+ elements.text.textContent = '回应中…'; elements.article.classList.add('pending');
+ elements.article.querySelector('.error')?.remove();
+ active = new AbortController(); const controller = active;
+ $('cancel').hidden = false; $('send').disabled = true;
+ $('chat-status').textContent = source === 'live' ? '正在等待模型回应…' : '正在显示演示对话…';
+ try {
+ const delta = (text: string) => { response.text += text; elements.text.textContent = response.text; scroll(); };
+ if (source === 'live') await stream(history.slice(0, history.indexOf(response)), controller.signal, delta);
+ else await demoStream(reply(user.text, turn++), controller.signal, delta);
+ response.complete = true; $('chat-status').textContent = '回应已完成';
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '网络连接失败';
+ if (!response.text) elements.text.textContent = '未收到回复。';
+ const row = document.createElement('div'); row.className = 'error';
+ const label = document.createElement('span'); label.textContent = message;
+ const retry = document.createElement('button'); retry.textContent = '重试';
+ retry.onclick = () => {
+ if (active) return toast('请先结束当前回应');
+ // Keep retry context deterministic; no concurrent/newer turn is duplicated.
+ if (history[history.length - 1] !== response) return toast('已有后续对话,请重新发送需要重试的内容');
+ void respond(user, response, elements);
+ };
+ row.append(label, retry); elements.article.append(row); $('chat-status').textContent = message;
+ } finally {
+ elements.article.classList.remove('pending');
+ if (active === controller) active = undefined;
+ $('cancel').hidden = true; $('send').disabled = false; scroll();
+ }
+}
+$('compose').onsubmit = event => {
+ event.preventDefault();
+ if (active || composing || !input.value.trim()) return;
+ if (probing) { toast('模型检测中,请稍候'); return; }
+ const text = input.value.trim(); if ([...text].length > 500) { toast('最多输入500字'); return; }
+ const message: ChatMessage = { role: 'user', text, source: live ? 'live' : 'demo', complete: true };
+ history.push(message); follow = true; bubble(message); input.value = ''; count();
+ void respond(message);
+};
+function count() { $('count').textContent = `${[...input.value].length} / 500`; }
+input.oninput = count;
+input.addEventListener('compositionstart', () => { composing = true; });
+input.addEventListener('compositionend', () => { composing = false; count(); });
+input.onkeydown = event => {
+ if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && !composing && event.keyCode !== 229) {
+ event.preventDefault(); $('compose').requestSubmit();
+ }
+};
+$('cancel').onclick = () => active?.abort();
+function cancel() { active?.abort(); }
+$('collapse').onclick = () => { cancel(); document.body.classList.add('chat-hidden'); $('restore').hidden = false; };
+$('restore').onclick = () => { document.body.classList.remove('chat-hidden'); $('restore').hidden = true; requestAnimationFrame(scroll); };
+const drawer = $('drawer');
+function expand(expanded: boolean) { document.body.classList.toggle('expanded', expanded); drawer.setAttribute('aria-expanded', String(expanded)); drawer.setAttribute('aria-label', expanded ? '收起抽屉' : '展开对话'); }
+let dragged = false;
+drawer.onclick = () => { if (dragged) { dragged = false; return; } expand(!document.body.classList.contains('expanded')); };
+let startY = 0;
+drawer.onpointerdown = e => { dragged = false; startY = e.clientY; drawer.setPointerCapture(e.pointerId); };
+drawer.onpointerup = e => { if (Math.abs(e.clientY - startY) > 15) { dragged = true; expand(e.clientY < startY); } };
+function showView(next: string) {
+ cancel(); view = next;
+ document.body.classList.remove('chat-hidden'); $('restore').hidden = true;
+ for (const button of document.querySelectorAll('[data-view]')) {
+ if (button.dataset.view === next) button.setAttribute('aria-current', 'page'); else button.removeAttribute('aria-current');
+ }
+ $('chat-view').hidden = next !== 'chat'; $('preview-view').hidden = next === 'chat';
+ const content = $('preview-content'); content.replaceChildren();
+ $('preview-title').textContent = next === 'daily' ? '她的日常' : '命盘';
+ if (next === 'daily') {
+ for (const [time, text] of moments) { const p = document.createElement('p'); const small = document.createElement('small'); small.textContent = `${time} · 演示片刻`; const body = document.createElement('p'); body.textContent = text; p.append(small, body); content.append(p); }
+ } else { const p = document.createElement('p'); p.textContent = '星轨尚未点亮。命盘将呈现角色的稳定底色,本轮尚未接入排盘引擎,不提供运势推演。'; content.append(p); }
+ if (next !== 'chat') expand(true); else { follow = true; requestAnimationFrame(scroll); }
+}
+for (const button of document.querySelectorAll('[data-view]')) button.onclick = () => showView(button.dataset.view!);
+$('moment').onclick = () => showView('daily');
+$('attachment').onclick = () => toast('附件功能未接入,本轮只支持文字。');
+$('voice').onclick = () => toast('语音录入与 TTS 未接入,不会启动麦克风。');
+const settings = $('settings');
+$('settings-open').onclick = () => settings.showModal(); $('settings-close').onclick = () => settings.close();
+settings.addEventListener('click', e => { if (e.target === settings && (e.clientX < settings.getBoundingClientRect().left || e.clientX > settings.getBoundingClientRect().right || e.clientY < settings.getBoundingClientRect().top || e.clientY > settings.getBoundingClientRect().bottom)) settings.close(); });
+presets.forEach((preset, index) => {
+ const button = document.createElement('button'); button.textContent = preset.name; button.setAttribute('aria-pressed', String(index === 0));
+ button.onclick = () => { scene.preset(index); for (const child of $('presets').children) child.setAttribute('aria-pressed', String(child === button)); toast(`氛围已切换为${preset.name}`); }; $('presets').append(button);
+});
+$('quality').onchange = event => { const level = (event.target as HTMLSelectElement).value as FxLevel; void scene.set(level); toast(matchMedia('(prefers-reduced-motion: reduce)').matches ? '系统已开启减少动效,场景保持静态' : '动效档位已切换'); };
+try { audio.volume = Number(localStorage.getItem('rain-volume') ?? .25); } catch { /* storage optional */ }
+$('volume').value = String(audio.volume * 100);
+$('rain-sound').onchange = async event => {
+ const checkbox = event.target as HTMLInputElement;
+ try { await audio.set(checkbox.checked); $('audio-status').textContent = checkbox.checked ? '程序化雨声已开启' : '雨声已关闭'; }
+ catch { checkbox.checked = false; audio.enabled = false; $('audio-status').textContent = '浏览器无法启动音频,雨声保持关闭'; }
+};
+$('volume').oninput = event => { const volume = Number((event.target as HTMLInputElement).value) / 100; void audio.set(audio.enabled, volume).catch(() => toast('音频不可用')); try { localStorage.setItem('rain-volume', String(volume)); } catch { /* optional */ } };
+const bars = document.querySelector('.wave-bars')!;
+for (let i = 0; i < 48; i++) { const bar = document.createElement('i'); bar.style.setProperty('--bar-height', `${4 + Math.sin(i * 1.8) ** 2 * 14}px`); bar.style.setProperty('--delay', `${i * -0.09}s`); bars.append(bar); }
+$('wave').onclick = () => { const on = $('wave').getAttribute('aria-pressed') !== 'true'; $('wave').setAttribute('aria-pressed', String(on)); $('wave').textContent = on ? 'Ⅱ' : '▷'; bars.classList.toggle('playing', on); };
+function clock() { $('time').textContent = new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(new Date()); }
+let clockTimer: ReturnType | undefined;
+function scheduleClock() { clearInterval(clockTimer); clock(); if (!document.hidden) clockTimer = setInterval(clock, 30000); }
+scheduleClock();
+document.addEventListener('visibilitychange', () => { void audio.visibility().catch(() => {}); scheduleClock(); });
+function viewport() {
+ const height = window.visualViewport?.height ?? innerHeight;
+ document.documentElement.style.setProperty('--visible-height', `${height}px`);
+ document.body.classList.toggle('keyboard', height < innerHeight * .78);
+ if (document.body.classList.contains('keyboard') && document.activeElement === input) requestAnimationFrame(() => input.scrollIntoView({ block: 'end' }));
+}
+window.visualViewport?.addEventListener('resize', viewport); viewport();
+input.onfocus = () => { if (view !== 'chat') showView('chat'); };
diff --git a/web/src/net/llm.ts b/web/src/net/llm.ts
new file mode 100644
index 0000000..b28193a
--- /dev/null
+++ b/web/src/net/llm.ts
@@ -0,0 +1,77 @@
+import type { ChatMessage } from '../state/contracts';
+export async function probe(): Promise {
+ try {
+ const response = await fetch('/v1/models', { signal: AbortSignal.timeout(2000) });
+ const body = await response.json();
+ return response.ok && body.data?.some((m: { id: string }) => m.id === 'halogen-qwen3.8-flash-next');
+ } catch { return false; }
+}
+// SSE is decoded across arbitrary UTF-8 / line boundaries. Completion is required;
+// a truncated stream preserves visible output and offers explicit manual retry.
+export async function stream(messages: ChatMessage[], signal: AbortSignal, delta: (s: string) => void): Promise {
+ const controller = new AbortController();
+ const cancel = () => controller.abort(signal.reason);
+ signal.addEventListener('abort', cancel, { once: true });
+ if (signal.aborted) cancel();
+ let timeout = false;
+ const expire = () => { timeout = true; controller.abort(); };
+ const total = setTimeout(expire, 40000);
+ let first: ReturnType | undefined = setTimeout(expire, 8000);
+ let reader: ReadableStreamDefaultReader | undefined;
+ try {
+ const response = await fetch('/v1/chat/completions', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: controller.signal,
+ body: JSON.stringify({ model: 'halogen-qwen3.8-flash-next', stream: true, max_tokens: 600,
+ messages: [{ role: 'system', content: '你叫晚晚,简短温和地用中文交流,每次不超过150字。不得声称拥有记忆或情绪引擎,不得声称已接入语音或真实生活引擎。' },
+ ...messages.filter(m => m.complete && m.source === 'live').slice(-16).map(m => ({ role: m.role, content: m.text }))] })
+ });
+ if (!response.ok || !response.body) throw new Error(`模型服务返回 ${response.status}`);
+ reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '', done = false, received = false;
+ const line = (s: string) => {
+ if (!s.startsWith('data:')) return;
+ const value = s.slice(5).trim();
+ if (!value) return;
+ if (value === '[DONE]') { done = true; return; }
+ const event = JSON.parse(value);
+ if (event.error) throw new Error('模型服务返回错误');
+ const choice = event.choices?.[0];
+ if (choice?.delta?.content) {
+ received = true; clearTimeout(first); first = undefined;
+ delta(choice.delta.content);
+ }
+ if (choice?.finish_reason) done = true;
+ };
+ while (!done) {
+ const chunk = await reader.read();
+ buffer += decoder.decode(chunk.value, { stream: !chunk.done });
+ const lines = buffer.split('\n'); buffer = lines.pop() ?? '';
+ for (const item of lines) line(item.trimEnd());
+ if (chunk.done) { if (buffer) line(buffer); break; }
+ }
+ if (!done) throw new Error('流式连接中断,已收到的内容保留');
+ if (!received) throw new Error('模型未返回正文,请手动重试');
+ } catch (error) {
+ if (timeout) throw new Error('模型响应超时,请手动重试');
+ if (signal.aborted) throw new Error('已取消,已收到的内容保留');
+ if (error instanceof SyntaxError) throw new Error('流式数据格式错误,已收到的内容保留');
+ if (error instanceof TypeError) throw new Error('网络连接失败,请检查模型服务后重试');
+ throw error instanceof Error ? error : new Error('网络连接失败');
+ } finally {
+ clearTimeout(total); clearTimeout(first);
+ signal.removeEventListener('abort', cancel);
+ await reader?.cancel().catch(() => {});
+ }
+}
+export async function demoStream(text: string, signal: AbortSignal, delta: (s: string) => void) {
+ for (const character of text) {
+ if (signal.aborted) throw new Error('已取消,已收到的内容保留');
+ await new Promise((resolve, reject) => {
+ const cancel = () => { clearTimeout(timer); reject(new Error('已取消,已收到的内容保留')); };
+ const timer = setTimeout(() => { signal.removeEventListener('abort', cancel); resolve(); }, 40);
+ signal.addEventListener('abort', cancel, { once: true });
+ });
+ delta(character);
+ }
+}
diff --git a/web/src/scene/scene.ts b/web/src/scene/scene.ts
new file mode 100644
index 0000000..00a9b08
--- /dev/null
+++ b/web/src/scene/scene.ts
@@ -0,0 +1,124 @@
+import type { FxLevel } from '../state/contracts';
+import { presets } from '../demo/script';
+interface Renderer { draw(t: number, density: number): void; destroy(): void }
+type Tier = 'webgl' | 'canvas' | 'css' | 'static';
+// Pure timeline coordinates: the scene never reads or writes chat state.
+function drops(t: number, count: number, w: number, h: number, line: (x: number, y: number, length: number, alpha: number) => void) {
+ for (let i = 0; i < count; i++) {
+ const speed = 48 + (i * 37 % 110);
+ line((i * 137.51 % w), ((i * 87.37 + t * speed) % (h + 70)) - 35, 9 + i % 24, .1 + i % 5 * .045);
+ }
+}
+export class Scene {
+ private renderer?: Renderer;
+ private frame = 0;
+ private generation = 0;
+ private tier: Tier = 'static';
+ private level: FxLevel = 'full';
+ private time = 0;
+ private last = 0;
+ private sample = 0;
+ private frames = 0;
+ private slow = 0;
+ private reduced = matchMedia('(prefers-reduced-motion: reduce)');
+ private mood = presets[0];
+ private px = 0; private py = 0; private x = 0; private y = 0;
+ constructor(private host: HTMLElement, private notify: (text: string) => void) {
+ this.reduced.addEventListener('change', () => { void this.set(this.level); });
+ document.addEventListener('visibilitychange', () => this.visibility());
+ window.addEventListener('resize', () => { void this.configure(this.tier); });
+ window.addEventListener('pointermove', e => {
+ if (e.pointerType === 'mouse' && matchMedia('(pointer:fine)').matches) {
+ this.px = (e.clientX / innerWidth - .5) * -12;
+ this.py = (e.clientY / innerHeight - .5) * -12;
+ }
+ });
+ }
+ async set(level: FxLevel) {
+ this.level = level;
+ await this.configure(this.reduced.matches || level === 'static' ? 'static' : level === 'lite' ? 'canvas' : 'webgl');
+ }
+ preset(index: number) {
+ this.mood = presets[index];
+ document.documentElement.style.setProperty('--lamp-opacity', String(this.mood.warmth));
+ }
+ private async configure(tier: Tier) {
+ const version = ++this.generation;
+ cancelAnimationFrame(this.frame); this.frame = 0;
+ this.renderer?.destroy(); this.renderer = undefined;
+ this.host.replaceChildren();
+ this.tier = tier; this.last = 0; this.sample = 0; this.frames = 0; this.slow = 0;
+ const w = Math.min(innerWidth, 1920), h = Math.min(innerHeight, 1080);
+ let renderer: Renderer | undefined;
+ try {
+ if (tier === 'webgl') {
+ const { Application, Graphics, RendererType } = await import('pixi.js');
+ if (version !== this.generation) return;
+ const app = new Application();
+ try { await app.init({ width: w, height: h, resolution: 1, backgroundAlpha: 0, preference: ['webgl'], autoStart: false, antialias: false }); if (app.renderer.type !== RendererType.WEBGL) throw new Error('WebGL renderer unavailable'); }
+ catch (error) { try { app.destroy(true); } catch { /* incomplete initialization */ } throw error; }
+ if (version !== this.generation) { app.destroy(true); return; }
+ const graphics = new Graphics(); app.stage.addChild(graphics);
+ const contextLost = (e: Event) => {
+ e.preventDefault(); this.notify('图形上下文丢失,已切换轻量雨幕'); void this.configure('canvas');
+ };
+ app.canvas.addEventListener('webglcontextlost', contextLost);
+ this.host.append(app.canvas);
+ renderer = { draw: (t, density) => {
+ graphics.clear();
+ drops(t, Math.round(180 * density), w, h, (x, y, length) => {
+ graphics.moveTo(x, y).lineTo(x - 2, y + length);
+ });
+ graphics.stroke({ color: 0x718fc2, alpha: .24, width: 1 });
+ for (let i = 0; i < 16; i++) {
+ const x = i * 101.3 % w, y = ((i * 137 + t * 12) % h);
+ graphics.ellipse(x, y, 1.5, 4).fill({ color: 0xc9ddff, alpha: .25 + Math.sin(t + i) * .12 });
+ }
+ app.renderer.render(app.stage);
+ }, destroy: () => { app.canvas.removeEventListener('webglcontextlost', contextLost); app.destroy(true, { children: true }); } };
+ } else if (tier === 'canvas') {
+ const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h;
+ const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Canvas2D unavailable');
+ this.host.append(canvas);
+ renderer = { draw: (t, density) => {
+ ctx.clearRect(0, 0, w, h); ctx.lineWidth = 1;
+ drops(t, Math.round(80 * density), w, h, (x, y, length, alpha) => {
+ ctx.strokeStyle = `rgba(113,143,194,${alpha})`; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - 2, y + length); ctx.stroke();
+ });
+ }, destroy: () => canvas.remove() };
+ }
+ } catch {
+ if (version !== this.generation) return;
+ this.notify(tier === 'webgl' ? 'WebGL 不可用,已切换 Canvas2D' : 'Canvas2D 不可用,已切换 CSS 雨幕');
+ await this.configure(tier === 'webgl' ? 'canvas' : 'css'); return;
+ }
+ if (version !== this.generation) { renderer?.destroy(); return; }
+ this.renderer = renderer;
+ document.body.dataset.renderer = tier;
+ document.body.dataset.motion = tier === 'static' ? 'static' : 'active';
+ this.visibility();
+ }
+ private visibility() {
+ cancelAnimationFrame(this.frame); this.frame = 0; this.last = 0;
+ document.body.classList.toggle('paused', document.hidden);
+ this.host.dataset.running = String(!document.hidden && this.tier !== 'static');
+ if (!document.hidden && this.tier !== 'static') this.frame = requestAnimationFrame(this.tick);
+ }
+ private tick = (now: number) => {
+ const dt = this.last ? now - this.last : 16.7; this.last = now;
+ this.time += Math.min(dt, 50) / 1000;
+ this.x += (this.px - this.x) * .08; this.y += (this.py - this.y) * .08;
+ if (matchMedia('(pointer:fine)').matches) this.host.style.transform = `translate(${this.x}px,${this.y}px)`;
+ this.renderer?.draw(this.time, this.mood.density);
+ this.sample += dt; this.frames++;
+ if (this.sample >= 4000) {
+ this.slow = this.frames / (this.sample / 1000) < 30 ? this.slow + 1 : 0;
+ this.sample = 0; this.frames = 0;
+ if (this.slow) {
+ const next = this.tier === 'webgl' ? 'canvas' : this.tier === 'canvas' ? 'css' : 'static';
+ this.notify('设备帧率较低,已自动降低场景动效'); void this.configure(next); return;
+ }
+ }
+ this.frame = requestAnimationFrame(this.tick);
+ };
+}
diff --git a/web/src/state/contracts.ts b/web/src/state/contracts.ts
new file mode 100644
index 0000000..b14aec2
--- /dev/null
+++ b/web/src/state/contracts.ts
@@ -0,0 +1,7 @@
+// Exact data fields from Python contracts; these types do not connect the engine.
+export type Presence = 'listening' | 'speaking' | 'away' | 'asleep';
+export interface State { ts: number; v: number; a: number; d: number; e: number; mood_word: string; presence: Presence; drivers: readonly number[] }
+export type SpeechTag = 'laughter' | 'breath' | 'sigh' | 'pause' | 'cry' | 'hesitate';
+export interface Utterance { text: string; speech_tags: readonly SpeechTag[]; emotion_intent: string | null; withheld: string | null; interruptible: boolean }
+export type FxLevel = 'full' | 'lite' | 'static';
+export interface ChatMessage { role: 'user' | 'assistant'; text: string; source: 'live' | 'demo'; complete: boolean }
diff --git a/web/tests/browser_check.py b/web/tests/browser_check.py
new file mode 100644
index 0000000..634dcef
--- /dev/null
+++ b/web/tests/browser_check.py
@@ -0,0 +1,180 @@
+"""Foreground browser regression using preinstalled Python Playwright; no npm test dependency."""
+import json
+import os
+from pathlib import Path
+import socket
+import subprocess
+import time
+import urllib.request
+from playwright.sync_api import sync_playwright
+
+ROOT = Path(__file__).resolve().parents[1]
+OUT = Path(os.environ.get('CYBERGRIL_ARTIFACT_DIR', ROOT / 'qa-artifacts'))
+OUT.mkdir(parents=True, exist_ok=True)
+with socket.socket() as sock:
+ sock.bind(('127.0.0.1', 0))
+ PORT = sock.getsockname()[1]
+BASE = f'http://127.0.0.1:{PORT}'
+log = open(OUT / 'vite.log', 'w')
+server = subprocess.Popen(['node', 'node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', str(PORT), '--strictPort'], cwd=ROOT, stdout=log, stderr=log)
+report = {'checks': [], 'errors': [], 'browser': '', 'samples': {}}
+
+def check(name, condition):
+ assert condition, name
+ report['checks'].append(name)
+ print('PASS', name, flush=True)
+
+def screenshot(page, name):
+ page.screenshot(path=str(OUT / (name + '.png')), full_page=True)
+
+try:
+ for _ in range(60):
+ try:
+ urllib.request.urlopen(BASE, timeout=.5)
+ break
+ except Exception:
+ time.sleep(.1)
+ with sync_playwright() as pw:
+ browser = pw.chromium.launch(args=['--no-sandbox', '--enable-unsafe-swiftshader'])
+ report['browser'] = browser.version
+ page = browser.new_page(viewport={'width': 1600, 'height': 900}, device_scale_factor=1)
+ page.on('pageerror', lambda e: report['errors'].append(str(e)))
+ # Unreachable probe is a deterministic offline case, not a test-only app mode.
+ page.route('**/v1/models', lambda route: route.abort())
+ page.goto(BASE)
+ page.wait_for_function("document.querySelector('#connection').textContent.includes('非实时模型')")
+ page.wait_for_timeout(5100)
+ check('unreachable probe selects labelled demo', '演示' in page.locator('#connection').inner_text())
+ check('real DOM + permanent engine disclosure', page.locator('.badge').is_visible() and page.locator('.message p').count() == 2)
+ check('renderer starts', page.locator('body').get_attribute('data-renderer') in ['webgl', 'canvas'])
+ screenshot(page, 'desktop-1600')
+ page.locator('#input').fill('中文输入法')
+ page.locator('#input').dispatch_event('compositionstart')
+ page.locator('#input').press('Enter')
+ check('IME Enter does not submit', page.locator('.message.user').count() == 0)
+ page.locator('#input').dispatch_event('compositionend')
+ page.locator('#input').press('Shift+Enter')
+ check('Shift Enter preserves newline', '\n' in page.locator('#input').input_value())
+ page.locator('#input').fill('今天有点累')
+ page.locator('#input').press('Enter')
+ page.locator('#cancel').wait_for(state='visible')
+ page.locator('#input').fill('流式中继续快速输入,不应丢字')
+ screenshot(page, 'desktop-stream-focus')
+ check('input stays editable during stream', page.locator('#input').input_value() == '流式中继续快速输入,不应丢字')
+ page.locator('#cancel').click()
+ page.locator('.error').wait_for()
+ check('cancel keeps partial and offers retry', '已取消' in page.locator('.error').inner_text())
+ screenshot(page, 'desktop-cancel-retry')
+ page.locator('.error button').click()
+ page.locator('#cancel').wait_for(state='hidden', timeout=10000)
+ check('manual retry succeeds without duplicate user turn', page.locator('.message.user').count() == 1 and page.locator('.error').count() == 0)
+ page.locator('#input').fill('长' * 500)
+ check('500 character input available', page.locator('#input').input_value() == '长' * 500)
+ page.locator('#input').fill('')
+ page.locator('#voice').click(force=True)
+ check('unavailable voice explains itself', '不会启动麦克风' in page.locator('#toast').inner_text())
+ page.locator('#collapse').click()
+ check('collapse restores conversation entry', page.locator('#restore').is_visible() and not page.locator('.panel').is_visible())
+ page.locator('#restore').click()
+ page.locator('[data-view=daily]').click()
+ check('daily has explicitly marked demo moments', page.locator('#preview-content small').count() == 3)
+ screenshot(page, 'daily-preview')
+ page.locator('[data-view=fate]').click()
+ check('fate is preview only', '未接入排盘引擎' in page.locator('#preview-content').inner_text())
+ page.locator('[data-view=chat]').click()
+ page.locator('#settings-open').click()
+ original = page.evaluate("getComputedStyle(document.documentElement).getPropertyValue('--accent')")
+ page.get_by_role('button', name='静听蓝城', exact=True).click()
+ check('preset keeps warm UI accent', original == page.evaluate("getComputedStyle(document.documentElement).getPropertyValue('--accent')"))
+ page.locator('#quality').select_option('static')
+ page.wait_for_function("document.body.dataset.renderer === 'static'")
+ check('static stops scene', page.locator('#effects').get_attribute('data-running') == 'false')
+ page.locator('#quality').select_option('lite')
+ page.wait_for_function("document.body.dataset.renderer === 'canvas'")
+ check('lite uses independent Canvas2D', page.locator('#effects canvas').count() == 1)
+ page.locator('#rain-sound').check()
+ check('rain only starts on user action', '已开启' in page.locator('#audio-status').inner_text())
+ page.locator('#volume').fill('50')
+ page.locator('#rain-sound').uncheck()
+ page.locator('#settings-close').click()
+ page.set_viewport_size({'width':1280,'height':800})
+ screenshot(page,'desktop-1280')
+ page.set_viewport_size({'width':390,'height':844})
+ page.evaluate("document.body.classList.remove('expanded')")
+ screenshot(page,'mobile-collapsed')
+ check('mobile default drawer near 30 percent', abs(page.locator('.panel').bounding_box()['height'] / 844 - .30) < .02)
+ page.locator('#drawer').click()
+ screenshot(page,'mobile-expanded')
+ check('mobile expansion at or below 55 percent', page.locator('.panel').bounding_box()['y'] >= 844*.45-1)
+ page.locator('#input').focus()
+ screenshot(page,'mobile-focus')
+ page.set_viewport_size({'width':844,'height':390})
+ screenshot(page,'mobile-landscape')
+ check('landscape is scrollable scene above chat', page.locator('.panel').bounding_box()['y'] >= 300)
+ check('no horizontal overflow', page.evaluate('document.documentElement.scrollWidth <= innerWidth'))
+ page.set_viewport_size({'width':1600,'height':900})
+ page.emulate_media(reduced_motion='reduce')
+ page.wait_for_function("document.body.dataset.renderer === 'static'")
+ screenshot(page,'reduced-motion')
+ check('reduced motion enforces static', page.locator('#effects').get_attribute('data-running') == 'false')
+ page.emulate_media(reduced_motion='no-preference')
+ page.wait_for_function("document.querySelector('#effects').dataset.running === 'true'")
+ # Visibility lifecycle event injected because headless tabs do not reliably hide.
+ page.evaluate("Object.defineProperty(document, 'hidden', {configurable:true,get:()=>true}); document.dispatchEvent(new Event('visibilitychange'))")
+ check('visibility handler pauses scene and CSS', page.locator('#effects').get_attribute('data-running') == 'false' and 'paused' in page.locator('body').get_attribute('class'))
+ page.evaluate("Object.defineProperty(document, 'hidden', {configurable:true,get:()=>false}); document.dispatchEvent(new Event('visibilitychange'))")
+ check('visibility handler restores selected animation', page.locator('#effects').get_attribute('data-running') == 'true')
+ # True API availability failure injection, no app query override.
+ for name, blocked, expected in [('webgl-fallback', ['webgl','webgl2','experimental-webgl'], 'canvas'), ('canvas-fallback', ['webgl','webgl2','experimental-webgl','2d'], 'css')]:
+ ctx = browser.new_context(viewport={'width':1280,'height':800})
+ ctx.add_init_script("const original=HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext=function(type,...args){if("+json.dumps(blocked)+".includes(type))return null;return original.call(this,type,...args)}")
+ p = ctx.new_page(); p.route('**/v1/models', lambda route: route.abort()); p.goto(BASE)
+ p.wait_for_function(f"document.body.dataset.renderer === '{expected}'",timeout=12000)
+ check(name, True); screenshot(p,name); ctx.close()
+ # Live mode cases use model-compatible SSE fixtures.
+ page.unroute('**/v1/models')
+ page.route('**/v1/models', lambda route: route.fulfill(json={'data':[{'id':'halogen-qwen3.8-flash-next'}]}))
+ page.locator('#mode').click()
+ page.wait_for_function("document.querySelector('#connection').textContent.includes('已连接')")
+ def completion(route):
+ report['last_request'] = route.request.post_data_json
+ route.fulfill(content_type='text/event-stream', body='data: {"choices":[{"delta":{"content":"中文"}}]}\r\n\r\ndata: [DONE]\r\n\r\n')
+ page.route('**/v1/chat/completions', completion)
+ page.locator('#input').fill('实时测试'); page.locator('#send').click()
+ page.wait_for_function("document.querySelector('#chat-status').textContent === '回应已完成'")
+ check('live SSE renders unsafe-looking text as text', '' in page.locator('.message.assistant').last.inner_text() and page.locator('.message script').count()==0)
+ check('live request excludes demo replies', all('窗外又下雨' not in m['content'] for m in report['last_request']['messages']))
+ page.unroute('**/v1/chat/completions')
+ page.route('**/v1/chat/completions', lambda route: route.fulfill(status=503,body='Unavailable'))
+ page.locator('#input').fill('失败测试'); page.locator('#send').click(); page.locator('.error').wait_for()
+ check('upstream error visible with retry', '503' in page.locator('.error').inner_text())
+ screenshot(page,'live-failure')
+ page.unroute('**/v1/chat/completions')
+ page.route('**/v1/chat/completions', completion)
+ page.locator('.error button').click(); page.wait_for_function("document.querySelector('#chat-status').textContent === '回应已完成'")
+ check('live retry clears failure', page.locator('.error').count()==0)
+ page.unroute('**/v1/chat/completions')
+ page.route('**/v1/chat/completions', lambda route: route.fulfill(content_type='text/event-stream',body='data: {"choices":[{"delta":{"content":"已经收到的一部分"}}]}\n\n'))
+ page.locator('#input').fill('中断测试'); page.locator('#send').click(); page.locator('.error').wait_for()
+ check('truncated SSE keeps partial text', '已经收到的一部分' in page.locator('.message.assistant').last.inner_text() and '中断' in page.locator('.error').inner_text())
+ # Browser scheduling sample with chat input during independent animation.
+ page.locator('#settings-open').click(); page.locator('#quality').select_option('full'); page.locator('#settings-close').click()
+ page.wait_for_timeout(1000)
+ sample = page.evaluate("""async () => {const gaps=[],tasks=[]; const po=new PerformanceObserver(l=>tasks.push(...l.getEntries().map(e=>e.duration)));po.observe({type:'longtask'});let last=performance.now();await new Promise(resolve=>{const end=last+4000;function frame(t){gaps.push(t-last);last=t;if(ta-b);return {frames:gaps.length,intervalP95:gaps[Math.floor(gaps.length*.95)],longTasks:tasks,renderer:document.body.dataset.renderer}}""")
+ report['samples']['chromium1600'] = sample
+ check('no unhandled page errors', not report['errors'])
+ browser.close()
+ # Firefox scope is reported accurately if the installed browser cannot launch.
+ try:
+ firefox = pw.firefox.launch()
+ fp = firefox.new_page(viewport={'width':1280,'height':800})
+ fp.route('**/v1/models',lambda route:route.abort());fp.goto(BASE); fp.wait_for_timeout(1000)
+ screenshot(fp,'firefox-1280'); report['firefox'] = firefox.version
+ check('Firefox shell usable',fp.locator('#input').is_visible());firefox.close()
+ except Exception as exc:
+ report['firefox_unavailable'] = str(exc)[:500]
+finally:
+ server.terminate()
+ server.wait(timeout=10)
+ log.close()
+ (OUT/'browser-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
diff --git a/web/tests/lifecycle_check.py b/web/tests/lifecycle_check.py
new file mode 100644
index 0000000..84ba1a3
--- /dev/null
+++ b/web/tests/lifecycle_check.py
@@ -0,0 +1,93 @@
+"""Context loss, slow-frame degradation, drawer/keyboard and browser main-thread trace."""
+import json
+import os
+from pathlib import Path
+import socket
+import subprocess
+import time
+import urllib.request
+from playwright.sync_api import sync_playwright
+ROOT=Path(__file__).resolve().parents[1]
+OUT=Path(os.environ.get('CYBERGRIL_ARTIFACT_DIR',ROOT/'qa-artifacts'));OUT.mkdir(parents=True,exist_ok=True)
+with socket.socket() as s:s.bind(('127.0.0.1',0));port=s.getsockname()[1]
+base=f'http://127.0.0.1:{port}'
+log=open(OUT/'lifecycle-vite.log','w')
+server=subprocess.Popen(['node','node_modules/vite/bin/vite.js','--host','127.0.0.1','--port',str(port),'--strictPort'],cwd=ROOT,stdout=log,stderr=log)
+report={'checks':[]}
+def check(name,value):
+ assert value,name
+ report['checks'].append(name);print('PASS',name,flush=True)
+try:
+ for _ in range(60):
+ try:urllib.request.urlopen(base,timeout=.5);break
+ except Exception:time.sleep(.1)
+ with sync_playwright() as pw:
+ browser=pw.chromium.launch(args=['--no-sandbox','--enable-unsafe-swiftshader'])
+ ctx=browser.new_context(viewport={'width':1600,'height':900})
+ # Capture actual AudioContext state without exposing anything in application code.
+ ctx.add_init_script("const Original=AudioContext;window.AudioContext=class extends Original{constructor(...args){super(...args);window.testAudio=this}}")
+ page=ctx.new_page();page.route('**/v1/models',lambda r:r.abort());page.goto(base)
+ page.wait_for_function("document.body.dataset.renderer==='webgl'")
+ page.evaluate("document.querySelector('#effects canvas').getContext('webgl2').getExtension('WEBGL_lose_context').loseContext()")
+ page.wait_for_function("document.body.dataset.renderer==='canvas'")
+ check('real WebGL context loss switches independent canvas',True)
+ page.locator('#settings-open').click();page.locator('#quality').select_option('full');page.locator('#settings-close').click()
+ page.wait_for_function("document.body.dataset.renderer==='webgl'")
+ page.set_viewport_size({'width':1280,'height':800});page.wait_for_timeout(300)
+ check('resize does not report false context loss',page.locator('body').get_attribute('data-renderer')=='webgl')
+ page.locator('#settings-open').click();page.locator('#rain-sound').check();page.locator('#settings-close').click()
+ check('audio context runs only after explicit enable',page.evaluate("testAudio.state==='running'"))
+ page.evaluate("Object.defineProperty(document,'hidden',{configurable:true,get:()=>true});document.dispatchEvent(new Event('visibilitychange'))")
+ page.wait_for_function("testAudio.state==='suspended'")
+ check('hidden audio suspends',True)
+ page.evaluate("Object.defineProperty(document,'hidden',{configurable:true,get:()=>false});document.dispatchEvent(new Event('visibilitychange'))")
+ page.wait_for_function("testAudio.state==='running'")
+ check('visible audio restores user selection',True)
+ page.locator('#settings-open').click();page.locator('#rain-sound').uncheck();page.locator('#settings-close').click()
+ # Deterministic large model body validates internal scroll and safe text.
+ page.unroute('**/v1/models');page.route('**/v1/models',lambda r:r.fulfill(json={'data':[{'id':'halogen-qwen3.8-flash-next'}]}))
+ page.locator('#mode').click();page.wait_for_function("document.querySelector('#connection').textContent.includes('已连接')")
+ body='data: '+json.dumps({'choices':[{'delta':{'content':'长消息。'*400}}]},ensure_ascii=False)+'\n\ndata: [DONE]\n\n'
+ page.route('**/v1/chat/completions',lambda r:r.fulfill(content_type='text/event-stream',body=body))
+ page.locator('#input').fill('请返回长消息');page.locator('#send').click();page.wait_for_function("document.querySelector('#chat-status').textContent==='回应已完成'")
+ check('long streamed message follows bottom',page.evaluate("(()=>{const e=document.querySelector('#messages');return e.scrollHeight-e.scrollTop-e.clientHeight<70})()"))
+ page.locator('#messages').evaluate('(e)=>{e.scrollTop=0}')
+ page.wait_for_timeout(100)
+ check('scrolling up exposes return-to-bottom',page.locator('#bottom').is_visible())
+ page.locator('#bottom').click()
+ check('return-to-bottom restores follow',page.evaluate("(()=>{const e=document.querySelector('#messages');return e.scrollHeight-e.scrollTop-e.clientHeight<70})()"))
+ page.set_viewport_size({'width':390,'height':844});page.evaluate("document.body.classList.remove('expanded')")
+ box=page.locator('#drawer').bounding_box()
+ page.mouse.move(box['x']+40,box['y']+10);page.mouse.down();page.mouse.move(box['x']+40,box['y']-80,steps=5);page.mouse.up();page.wait_for_timeout(100)
+ check('drawer drag expands without click reversal',page.locator('#drawer').get_attribute('aria-expanded')=='true')
+ page.locator('#drawer').press('Space')
+ check('drawer keyboard toggles',page.locator('#drawer').get_attribute('aria-expanded')=='false')
+ page.locator('#input').focus()
+ page.evaluate("Object.defineProperty(visualViewport,'height',{configurable:true,value:400});visualViewport.dispatchEvent(new Event('resize'))")
+ check('software keyboard handler uses scrollable stacked layout','keyboard' in page.locator('body').get_attribute('class') and page.locator('.panel').bounding_box()['y']>=300)
+ page.locator('#input').scroll_into_view_if_needed();page.screenshot(path=str(OUT/'mobile-keyboard-layout.png'),full_page=True)
+ page.evaluate("delete visualViewport.height;visualViewport.dispatchEvent(new Event('resize'))")
+ page.set_viewport_size({'width':1600,'height':900});page.wait_for_timeout(1000)
+ cdp=ctx.new_cdp_session(page);events=[]
+ cdp.on('Tracing.dataCollected',lambda data:events.extend(data['value']))
+ cdp.send('Tracing.start',{'categories':'devtools.timeline,toplevel','transferMode':'ReportEvents'})
+ page.locator('#input').fill('')
+ page.locator('#input').press_sequentially('持续快速打字同时场景运行。'*6,delay=8)
+ page.wait_for_timeout(3000)
+ cdp.send('Tracing.end')
+ page.wait_for_timeout(1000)
+ (OUT/'chromium-main-thread-trace.json').write_text(json.dumps({'traceEvents':events}))
+ raf=sorted(e['dur']/1000 for e in events if e.get('name')=='FireAnimationFrame' and 'dur' in e)
+ main_ids={(e['pid'],e['tid']) for e in events if e.get('name')=='thread_name' and e.get('args',{}).get('name')=='CrRendererMain'}
+ tasks=sorted(e['dur']/1000 for e in events if (e.get('pid'),e.get('tid')) in main_ids and e.get('cat')=='toplevel' and 'dur' in e)
+ report['trace']={'rafCount':len(raf),'rafMaxMs':max(raf,default=0),'rafP95Ms':raf[int(len(raf)*.95)] if raf else None,'mainTaskCount':len(tasks),'mainTaskMaxMs':max(tasks,default=0),'mainTaskOver8ms':sum(t>8 for t in tasks),'scope':'Linux headless Chromium, software WebGL; not low-end Android'}
+ check('rapid keystrokes survive live scene',page.locator('#input').input_value()=='持续快速打字同时场景运行。'*6)
+ # Inject a 20 FPS rAF clock, exercising the real 4-second rolling sampler.
+ slowctx=browser.new_context(viewport={'width':1280,'height':800})
+ slowctx.add_init_script("window.requestAnimationFrame=cb=>setTimeout(()=>cb(performance.now()),50);window.cancelAnimationFrame=id=>clearTimeout(id)")
+ sp=slowctx.new_page();sp.route('**/v1/models',lambda r:r.abort());sp.goto(base)
+ sp.wait_for_function("document.body.dataset.renderer==='static'",timeout=20000)
+ check('slow renderer automatically descends WebGL Canvas CSS static',True)
+ slowctx.close();browser.close()
+finally:
+ server.terminate();server.wait(timeout=10);log.close();(OUT/'lifecycle-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
diff --git a/web/tests/network_check.py b/web/tests/network_check.py
new file mode 100644
index 0000000..a9352d6
--- /dev/null
+++ b/web/tests/network_check.py
@@ -0,0 +1,70 @@
+"""Actual same-origin upstream check + deterministic stream faults with real timeout durations."""
+import json
+import os
+from pathlib import Path
+import socket
+import subprocess
+import time
+import urllib.request
+from playwright.sync_api import sync_playwright
+ROOT = Path(__file__).resolve().parents[1]
+OUT = Path(os.environ.get('CYBERGRIL_ARTIFACT_DIR', ROOT / 'qa-artifacts'))
+OUT.mkdir(parents=True, exist_ok=True)
+with socket.socket() as sock:
+ sock.bind(('127.0.0.1',0)); port=sock.getsockname()[1]
+base=f'http://127.0.0.1:{port}'
+log=open(OUT/'network-vite.log','w')
+server=subprocess.Popen(['node','node_modules/vite/bin/vite.js','--host','127.0.0.1','--port',str(port),'--strictPort'],cwd=ROOT,stdout=log,stderr=log)
+report={}
+try:
+ for _ in range(60):
+ try: urllib.request.urlopen(base,timeout=.5);break
+ except Exception:time.sleep(.1)
+ with sync_playwright() as pw:
+ browser=pw.chromium.launch(args=['--no-sandbox','--enable-unsafe-swiftshader'])
+ page=browser.new_page()
+ page.goto(base)
+ report['real_upstream']=page.evaluate("""async()=>{const {probe,stream}=await import('/src/net/llm.ts'); const reachable=await probe(); if(!reachable)return {reachable:false,via:'same-origin /v1'}; const start=performance.now();let text='';try{await stream([{role:'user',text:'你好,请用一句话打招呼。',complete:true,source:'live'}],new AbortController().signal,s=>text+=s);return {reachable:true,complete:true,text,ms:performance.now()-start}}catch(e){return {reachable:true,complete:false,text,error:e.message,ms:performance.now()-start}}}""")
+ print('Real upstream:',json.dumps(report['real_upstream'],ensure_ascii=False),flush=True)
+ report['faults']=page.evaluate("""async()=>{
+ const {stream,probe}=await import('/src/net/llm.ts'); const original=window.fetch;
+ window.fetch=async(url,options={})=>{
+ if(String(url).endsWith('/models'))return new Promise((_,reject)=>options.signal.addEventListener('abort',()=>reject(new DOMException('abort','AbortError'))));
+ const mode=JSON.parse(options.body).messages.at(-1).content;
+ if(mode==='network')throw new TypeError('Failed to fetch');
+ let timer,interval;
+ const body=new ReadableStream({start(c){
+ const send=s=>c.enqueue(new TextEncoder().encode(s));
+ const delta=s=>'data: '+JSON.stringify({choices:[{delta:{content:s}}]})+'\\r\\n\\r\\n';
+ options.signal.addEventListener('abort',()=>{clearTimeout(timer);clearInterval(interval);c.error(new DOMException('abort','AbortError'))},{once:true});
+ if(mode==='first-timeout')return;
+ if(mode==='total-timeout'){send(delta('保留首段'));interval=setInterval(()=>send('data: {"choices":[{"delta":{}}]}\\n\\n'),1000);return;}
+ if(mode==='cancel'){send(delta('取消前已显示'));return;}
+ if(mode==='empty'){send('data: [DONE]\\n\\n');c.close();return;}
+ if(mode==='invalid'){send(delta('格式错误前的内容'));send('data: invalid-json\\n\\n');c.close();return;}
+ const bytes=new TextEncoder().encode(delta('中文🌧️,分包正文')+'data: [DONE]\\r\\n\\r\\n');let i=0;
+ const emit=()=>{if(i===bytes.length){c.close();return}c.enqueue(bytes.slice(i,i+1));i++;timer=setTimeout(emit,1)};emit();
+ },cancel(){clearTimeout(timer);clearInterval(interval)}});
+ return new Response(body,{headers:{'Content-Type':'text/event-stream'}});
+ };
+ try{
+ const run=async mode=>{const start=performance.now();const controller=new AbortController();let text='',error=null;if(mode==='cancel')setTimeout(()=>controller.abort(),100);try{await stream([{role:'user',text:mode,complete:true,source:'live'}],controller.signal,s=>text+=s)}catch(e){error=e.message}return {mode,text,error,ms:Math.round(performance.now()-start)}};
+ const faults=await Promise.all(['utf8','network','cancel','empty','invalid','first-timeout','total-timeout'].map(run));
+ const start=performance.now(),reachable=await probe();faults.push({mode:'probe-timeout',reachable,ms:Math.round(performance.now()-start)});return faults;
+ }finally{window.fetch=original}
+ }""")
+ faults={r['mode']:r for r in report['faults']}
+ assert faults['utf8']['text']=='中文🌧️,分包正文' and faults['utf8']['error'] is None
+ assert '网络连接失败' in faults['network']['error']
+ assert faults['cancel']['text']=='取消前已显示' and '已取消' in faults['cancel']['error']
+ assert '未返回正文' in faults['empty']['error']
+ assert faults['invalid']['text']=='格式错误前的内容' and '格式错误' in faults['invalid']['error']
+ assert 7900<=faults['first-timeout']['ms']<10000 and '超时' in faults['first-timeout']['error']
+ assert 39900<=faults['total-timeout']['ms']<43000 and faults['total-timeout']['text']=='保留首段'
+ assert 1900<=faults['probe-timeout']['ms']<4000 and not faults['probe-timeout']['reachable']
+ report['passed']=True
+ print('PASS UTF8 fragmentation, network error, empty reply, invalid SSE, cancel, 8s first-content timeout, 40s total timeout, 2s probe timeout',flush=True)
+ browser.close()
+finally:
+ server.terminate();server.wait(timeout=10);log.close()
+ (OUT/'network-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
diff --git a/web/tests/production_check.py b/web/tests/production_check.py
new file mode 100644
index 0000000..e2422fe
--- /dev/null
+++ b/web/tests/production_check.py
@@ -0,0 +1,64 @@
+"""Built-output smoke and clean screenshots. Preview proxy is not a deployed nginx test."""
+import gzip
+import json
+import os
+from pathlib import Path
+import socket
+import subprocess
+import time
+import urllib.request
+from playwright.sync_api import sync_playwright
+ROOT=Path(__file__).resolve().parents[1]
+OUT=Path(os.environ.get('CYBERGRIL_ARTIFACT_DIR',ROOT/'qa-artifacts'));OUT.mkdir(parents=True,exist_ok=True)
+with socket.socket() as s:s.bind(('127.0.0.1',0));port=s.getsockname()[1]
+base=f'http://127.0.0.1:{port}'
+log=open(OUT/'preview.log','w')
+server=subprocess.Popen(['node','node_modules/vite/bin/vite.js','preview','--host','127.0.0.1','--port',str(port),'--strictPort'],cwd=ROOT,stdout=log,stderr=log)
+report={'checks':[],'errors':[]}
+try:
+ for _ in range(60):
+ try:urllib.request.urlopen(base,timeout=.5);break
+ except Exception:time.sleep(.1)
+ with sync_playwright() as pw:
+ browser=pw.chromium.launch(args=['--no-sandbox','--enable-unsafe-swiftshader'])
+ page=browser.new_page(viewport={'width':1600,'height':900})
+ page.on('pageerror',lambda e:report['errors'].append(str(e)))
+ page.goto(base);page.wait_for_function("document.body.dataset.renderer==='webgl'");page.wait_for_timeout(5200)
+ report['proxy']=page.evaluate("async()=>{try{const r=await fetch('/v1/models',{signal:AbortSignal.timeout(2000)});return {status:r.status,body:await r.json()}}catch(e){return {error:e.message}}}")
+ def shot(name):page.screenshot(path=str(OUT/f'production-{name}.png'),full_page=True)
+ shot('desktop-1600')
+ page.locator('#settings-open').click();shot('desktop-settings')
+ assert page.locator('#settings').bounding_box()['x']>=1100
+ page.locator('#settings-close').click()
+ report['checks'].append('desktop settings align with right panel outside character')
+ page.set_viewport_size({'width':1280,'height':800});page.wait_for_timeout(300);shot('desktop-1280')
+ page.set_viewport_size({'width':768,'height':1024});page.wait_for_timeout(300);shot('tablet-portrait')
+ page.set_viewport_size({'width':390,'height':844});page.wait_for_timeout(300);shot('mobile-collapsed')
+ page.locator('#drawer').click();shot('mobile-expanded')
+ page.locator('#settings-open').click();shot('mobile-settings')
+ assert page.locator('#settings').bounding_box()['y']>=844*.45-1
+ page.locator('#presets button').last.click()
+ page.locator('#quality').select_option('lite')
+ page.locator('#rain-sound').check();page.locator('#rain-sound').uncheck()
+ page.locator('#settings-close').click()
+ report['checks'].append('mobile settings preserve face area and all scrolled controls work')
+ assert page.locator('.panel').bounding_box()['y']>=844*.45-1
+ report['checks'].append('built mobile drawer preserves top 45 percent face area')
+ page.locator('#input').focus();shot('mobile-input-focus')
+ page.set_viewport_size({'width':844,'height':390});page.wait_for_timeout(300);shot('mobile-landscape')
+ assert page.locator('.panel').bounding_box()['y']>=300
+ report['checks'].append('built landscape layout is scene above conversation')
+ page.set_viewport_size({'width':1600,'height':900});page.emulate_media(reduced_motion='reduce');page.wait_for_function("document.body.dataset.renderer==='static'");shot('reduced-motion')
+ report['checks'].append('built reduced-motion is static')
+ assert page.evaluate('document.documentElement.scrollWidth<=innerWidth')
+ assert not report['errors']
+ report['checks'].append('production output has no unhandled errors or horizontal overflow')
+ browser.close()
+ files=list((ROOT/'dist').rglob('*.js'))
+ report['sizes']={'jsGzipBytes':sum(len(gzip.compress(p.read_bytes())) for p in files),'jsRawBytes':sum(p.stat().st_size for p in files),'assetsBytes':sum(p.stat().st_size for p in (ROOT/'public/assets').iterdir()),'visibleWebpBytes':(ROOT/'public/assets/rain-room.webp').stat().st_size,'jpegFallbackBytes':(ROOT/'public/assets/rain-room.jpg').stat().st_size}
+ assert report['sizes']['jsGzipBytes']<=300000 and report['sizes']['assetsBytes']<=1500000 and report['sizes']['visibleWebpBytes']<=900000
+ report['checks'].append('compressed JS and material size budgets met')
+ report['passed']=True
+ print(json.dumps(report,ensure_ascii=False,indent=2),flush=True)
+finally:
+ server.terminate();server.wait(timeout=10);log.close();(OUT/'production-report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2))
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..6fd0535
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": [
+ "ES2022",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true
+ },
+ "include": [
+ "src"
+ ]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..38fa5d7
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,3 @@
+import { defineConfig } from 'vite';
+const proxy = { '/v1': { target: 'http://192.168.200.11:8731', changeOrigin: true } };
+export default defineConfig({ server: { proxy }, preview: { proxy } });