diff --git a/docs/README.md b/docs/README.md
index 479004c..2eb2569 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -2,7 +2,7 @@
> 文档状态:方案基线
> 最后核验:2026-08-04
-> 当前阶段:Android 工程尚未初始化,需求与工程约束已建立
+> 当前阶段:P-1 高保真交互原型已产出、待视觉确认;Android 工程尚未初始化
本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。
@@ -19,6 +19,7 @@ Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个
| [产品规格](product-spec.md) | 判断功能范围和验收结果时 | 目标、非目标、需求编号、MVP 边界 |
| [领域规则](domain-rules.md) | 修改投币、六爻、卦象映射时 | 唯一允许的起卦算法与不变量 |
| [UX 与东方视觉](ux-design.md) | 修改页面、文案、动画和主题时 | 用户流程、文化表达、无障碍标准 |
+| [移动端交互原型](prototype.md) | 评审流程、视觉或开始 Compose 页面前 | 可点击原型、截图入口、确认清单和 Android 映射 |
| [系统架构](architecture.md) | 新增包、依赖、数据源或网络能力时 | 分层、依赖方向、运行时数据流 |
| [数据与内容](data-content.md) | 修改卦库、历史记录或内容来源时 | 数据契约、授权、隐私和迁移规则 |
| [AI 解释与安全](ai-safety.md) | 修改提示词、模型调用或解释结果时 | AI 调用门、输入输出契约和安全边界 |
@@ -31,7 +32,7 @@ Brainwave 是一个以《易经》三枚铜币法为文化背景的 Android 个
## 推荐阅读路径
- 实现起卦:本页 → [领域规则](domain-rules.md) → [系统架构](architecture.md) → [质量门禁](quality-gates.md)
-- 实现界面:本页 → [产品规格](product-spec.md) → [UX 与东方视觉](ux-design.md) → [系统架构](architecture.md)
+- 实现界面:本页 → [产品规格](product-spec.md) → [UX 与东方视觉](ux-design.md) → [移动端交互原型](prototype.md) → [系统架构](architecture.md)
- 接入 AI:本页 → [AI 解释与安全](ai-safety.md) → [数据与内容](data-content.md) → [质量门禁](quality-gates.md)
- 初始化/构建:本页 → [本地开发环境](environment.md) → [系统架构](architecture.md) → [实施计划](implementation-plan.md)
- 修复缺陷:本页 → [失败记忆](failure-memory.md) → 对应领域文档 → [质量门禁](quality-gates.md)
diff --git a/docs/decisions.md b/docs/decisions.md
index 93f50af..69bb8c1 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -90,6 +90,17 @@
- 原因:不同解释传统存在差异,产品不应把一种裁决算法伪装成唯一事实。
- 后果:AI 可以组织内容,但输入和界面保留全部动爻。
+## ADR-011:先确认可点击原型,再固化 Compose 页面
+
+- 状态:`Accepted`
+- 日期:2026-08-04
+- 关联:[移动端交互原型](prototype.md)、[实施计划 P-1](implementation-plan.md#2-p-1可点击原型与视觉确认)
+- 决定:Android 工程初始化前先产出可点击高保真原型和关键状态截图,由用户确认视觉与流程后再实现 Compose 页面。
+- 原因:手动录入、结果层级、AI 同意门和东方文化表达都具有较高体验返工成本,先在无构建成本的原型中验证更易调整。
+- 备选:Android 骨架初始化后直接实现 Compose;低保真线框图。
+- 后果:原型是体验契约与评审证据,但不是生产领域代码;P1 必须独立实现和穷举验证算法,P3 复用已确认的设计令牌与状态关系。
+- 复审条件:用户否定当前流程或目标平台发生变化。
+
## 未决问题
| ID | 问题 | 推荐默认 | 阻塞阶段 |
diff --git a/docs/environment.md b/docs/environment.md
index e45ae85..579418d 100644
--- a/docs/environment.md
+++ b/docs/environment.md
@@ -36,6 +36,8 @@
| `core.autocrlf` | `true` |
| `core.longpaths` | 未显式设置 |
| Python | `3.10.11`,`C:\Python310\python.exe` |
+| Node.js / npm | `22.22.1` / `11.12.1` |
+| Google Chrome | `150.0.7871.188`,`C:\Program Files\Google\Chrome\Application\chrome.exe` |
环境文件写入前,仓库没有 Gradle Wrapper、`build.gradle*`、`settings.gradle*` 或根 `AGENTS.md`。这些属于 Android 工程初始化阶段的交付物,不应被误认为已存在。
@@ -145,6 +147,7 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh
5. 仪器测试优先使用已连接的 API 36、arm64 真机;不假设 Emulator/AVD 存在。
6. 不自动安装系统级工具、SDK 平台或模拟器;确需新增环境能力时,先说明影响。
7. 发布时的 `targetSdk` 和商店要求是独立发布门禁,不能因为本机只有 Platform 34 就永久锁定;工具链升级后必须更新本文件和相关 ADR。
+8. P-1 原型截图使用现有 Node.js 与 Chrome DevTools 协议生成,不额外安装前端依赖;命令见[移动端交互原型](prototype.md#3-运行方式)。
## 10. 已知缺口
@@ -166,6 +169,8 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh
Get-CimInstance Win32_OperatingSystem
$PSVersionTable
git --version
+node --version
+npm --version
java -version
javac -version
Get-ChildItem $env:ANDROID_HOME\platforms -Directory
diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md
index 35f090c..603c85e 100644
--- a/docs/implementation-plan.md
+++ b/docs/implementation-plan.md
@@ -1,11 +1,13 @@
# 分阶段实施计划
-> 状态:未开始
-> 计划原则:先锁定确定性领域核心,再接内容和 UI,最后接网络 AI
+> 状态:P-1 高保真交互原型已完成,待用户视觉确认;P0 尚未开始
+> 计划原则:先用原型确认高返工成本体验,再锁定确定性领域核心,随后接内容和 UI,最后接网络 AI
## 1. 依赖图
```text
+P-1 可点击原型与视觉确认
+ ↓
P0 工程骨架
├── P1 起卦领域核心 ──→ P3 主流程 UI ──→ P4 本地完整 MVP
├── P2 内容数据管线 ──→ P3 主流程 UI
@@ -14,9 +16,30 @@ P0 工程骨架
P4 本地完整 MVP ──→ P5 AI 后端与解释
```
-P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临时算法或硬编码卦辞。
+P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原型算法或硬编码卦辞。P-1 的视觉确认不改变 P1/P2 的工程门禁。
-## 2. P0:仓库与 Android 骨架
+## 2. P-1:可点击原型与视觉确认
+
+目标:在 Android 工程初始化前确认核心流程、信息层级、手动录入方式、AI 同意门和东方文化视觉方向。
+
+任务:
+
+- 建立 360 × 792 基准视口的高保真 HTML 原型和设计令牌。
+- 覆盖欢迎、起念、六轮录入、结果、本地/AI 选择、同意、成功与失败状态。
+- 使用已知夹具核对本卦、之卦、动爻和无动爻布局。
+- 导出关键状态截图,并把评审入口与 Android 映射写入[原型说明](prototype.md)。
+- 收集用户对视觉、录入控件、结果层级与解释语气的明确反馈。
+
+退出条件:
+
+- 可点击主流程及关键异常状态可在 360 × 792 浏览器中复现。
+- `9,8,8,8,8,8` 显示复 24 → 坤 2、初爻动;全 7 显示乾 1 且无之卦。
+- 原型不发起外部请求,不伪装真实 AI,不把工作名当作正式决定。
+- 用户确认或提出一轮可执行的修改意见;确认前不把视觉固化为生产 Compose 页面。
+
+当前交付物已完成,视觉确认仍待用户评审。
+
+## 3. P0:仓库与 Android 骨架
目标:建立可构建、可测试、可导航的原生 Android 项目。
@@ -38,7 +61,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临
- 空应用可在模拟器启动,导航到占位欢迎页。
- 没有生产服务密钥或真实内容。
-## 3. P1:领域核心
+## 4. P1:领域核心
目标:在纯 Kotlin 中完成并证明三枚铜币算法。
@@ -58,7 +81,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临
- 测试无随机、无网络、无系统时间依赖。
- `CastEngine` API 经评审后冻结为 `coin-v1`。
-## 4. P2:内容数据管线
+## 5. P2:内容数据管线
目标:建立可追踪、可校验、可发布的本地内容包。
@@ -77,7 +100,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临
- 缺失、重复、非法顺序和错误映射测试均能失败。
- 内容负责人确认可再分发。
-## 5. P3:核心用户流程与东方设计系统
+## 6. P3:核心用户流程与东方设计系统
目标:完成离线起念、六次录入和结果阅读。
@@ -98,7 +121,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临
- AI/网络代码尚未存在也不影响流程。
- [UX 与东方视觉](ux-design.md)检查表通过。
-## 6. P4:本地解释与历史
+## 7. P4:本地解释与历史
目标:形成不依赖 AI 的完整 MVP。
@@ -117,7 +140,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临
- 删除行为与备份策略一致且经过验证。
- 此阶段已经是可发布的本地版候选。
-## 7. P5:AI 解读
+## 8. P5:AI 解读
目标:在不扩大起卦权限的前提下增加可控 AI 解释。
@@ -142,7 +165,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临
- 抓包显示只有用户明确动作触发请求,载荷与同意说明一致。
- 服务端故障不会改变或隐藏 `CastResult`。
-## 8. P6:质量、发布与运营
+## 9. P6:质量、发布与运营
贯穿所有阶段:
@@ -155,7 +178,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临
发布条件完全遵循[质量门禁](quality-gates.md#6-发布门禁)。
-## 9. 编码任务模板
+## 10. 编码任务模板
后续任务应使用以下结构,减少代理猜测:
@@ -185,7 +208,7 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制临
任务应尽量小到单次变更可完整验证,不以“大致完成页面”作为验收描述。
-## 10. MVP 切分建议
+## 11. MVP 切分建议
最稳妥的发布顺序:
diff --git a/docs/prototype.md b/docs/prototype.md
new file mode 100644
index 0000000..6f3635c
--- /dev/null
+++ b/docs/prototype.md
@@ -0,0 +1,190 @@
+# 移动端交互原型
+
+> 状态:高保真候选 v0.1,待用户视觉与流程确认
+> 基准视口:360 × 792 CSS px(对应当前目标手机的紧凑竖屏体验)
+> 实现位置:[`prototype/`](../prototype/)
+
+## 1. 原型目的
+
+在初始化 Android 工程前,先用可点击原型确认最容易返工的产品决策:信息层级、六次录入方式、结果阅读顺序、AI 同意门和东方文化表达。原型确认后,P3 的 Compose 页面应复用这里的设计令牌和状态契约,而不是重新发明流程。
+
+本轮使用“一问”作为视觉工作名。它不构成正式命名决定;正式名称、应用图标和商店素材仍是 [TBD-001](decisions.md#未决问题)。
+
+## 2. 交付范围
+
+原型已经覆盖:
+
+- 欢迎与方法说明;
+- 开放式问题输入,以及明确的“不写具体内容”入口;
+- 三枚硬币逐枚录入、字/背计值、六轮 bottom-up 进度和撤销上一爻;
+- 本地确定性计算本卦、之卦和全部动爻;
+- 有动爻与无动爻两种结果布局;
+- 经典原文、本地白话和动爻的来源标签;
+- “解”的本地/AI 两种分支;
+- AI 发送范围说明、逐次同意、加载、成功、失败和本地降级;
+- 减少动态效果、键盘焦点、语义标签和最小触控目标。
+
+本轮不包含:
+
+- Android/Compose 生产代码;
+- 深色主题、平板和横屏定稿;
+- 历史记录与设置页面;
+- 真实 AI 请求、账户、服务端或持久化;
+- 完整 64 卦授权内容包;
+- 正式名称、图标、字体授权和商店视觉。
+
+## 3. 运行方式
+
+在仓库根目录运行:
+
+```powershell
+python -m http.server 4173 --bind 127.0.0.1
+```
+
+然后访问:
+
+```text
+http://127.0.0.1:4173/prototype/
+```
+
+原型无构建步骤、无第三方前端依赖,也不会发起外部网络请求。浏览器刷新会清空本次会话,这符合“评审原型”而非生产存储的定位。
+
+服务保持运行时,重新生成截图并执行浏览器门禁:
+
+```powershell
+node prototype\capture.mjs
+```
+
+脚本使用本机 Chrome/Edge 的 DevTools 协议,不安装 npm 依赖。可通过任务专用环境变量 `BRAINWAVE_PROTOTYPE_URL` 指向不同的本地服务地址。
+
+## 4. 评审入口
+
+正常入口可以完整点击。为稳定复现截图,还提供只用于评审的查询参数:
+
+| 状态 | URL | 评审重点 |
+|---|---|---|
+| 欢迎 | `?view=welcome` | 第一印象、文化气质、产品边界 |
+| 起念 | `?view=question` | 输入层级、隐私提示、跳过入口 |
+| 第三爻录入 | `?view=casting` | 手动录入、计值可见性、进度 |
+| 有动爻结果 | `?view=result` | 复 24 → 坤 2、初爻动、内容顺序 |
+| 无动爻结果 | `?view=static` | 乾 1、不出现之卦 |
+| AI 同意 | `?view=consent` | 发送范围与明确同意 |
+| AI 解读 | `?view=explanation` | 来源标签、非裁决语言、行动收束 |
+| 本地解读 | `?view=local` | 离线分支与来源区分 |
+| AI 失败 | `?view=error` | 结果保留、重试和本地降级 |
+
+这些参数只设置初始预览状态,不属于 Android 正式版路由。
+
+## 5. 交互与领域契约
+
+### 5.1 起卦
+
+- 用户必须亲自投币并逐枚录入;页面没有随机、摇一摇或 AI 代投入口。
+- 字记 2,背记 3;每轮三枚硬币全部有值后才能确认。
+- 六轮按初爻到上爻记录,界面以“从下往上”明确提示。
+- 6 为老阴、7 为少阳、8 为少阴、9 为老阳,6/9 标为动爻。
+- 原型包含完整 64 卦 King Wen 序号映射,用于验证交互结果;生产实现仍必须按[领域规则](domain-rules.md)在纯 Kotlin 中重建并穷举测试,不能复制前端代码当作权威领域实现。
+- 已确认的爻可逐步撤销;进入结果后本次结果不可被 AI 改写。
+
+### 5.2 结果阅读
+
+信息优先级固定为:
+
+1. 本卦、之卦和动爻;
+2. 用户本次所问;
+3. 经典原文;
+4. 本地白话;
+5. 动爻文本与提示;
+6. 用户主动点击“解”后的扩展解释。
+
+无动爻时只显示本卦和“卦象稳定”提示,不虚构之卦。多动爻时必须透明保留全部变化位置。
+
+### 5.3 AI 调用门
+
+原型中的 AI 加载是本地模拟,不会调用模型。正式流程必须维持以下顺序:
+
+```text
+本地结果已展示
+ → 用户点击「解」
+ → 用户选择 AI
+ → 页面说明确切发送字段
+ → 用户勾选并确认
+ → 才允许发起一次请求
+```
+
+失败状态不得隐藏或重算卦象,必须同时提供本地解释和重试入口。
+
+## 6. 视觉系统
+
+视觉遵循 [ADR-006](decisions.md#adr-006东方文化采用内容优先的纸墨朱砂设计):
+
+| 令牌 | 值 | 用途 |
+|---|---|---|
+| `paper` | `#F7F2E8` | 页面底色 |
+| `surface` | `#FFFDF7` | 阅读卡片与弹层 |
+| `ink` | `#1F1B16` | 标题、卦象和主要正文 |
+| `ink-soft` | `#655E55` | 次级正文 |
+| `cinnabar` | `#8C2F2B` | 主操作、动爻和关键提示 |
+| `ochre` | `#765D3E` | 辅助标签和次级动作 |
+| `outline` | `#B9AEA0` | 分隔和控件边界 |
+
+标题和文化文本优先使用系统可用的中文宋体/衬线字体,控件与说明使用中文黑体/无衬线字体。正式 Android 版本必须决定可再分发字体或采用平台字体回退,不能把本机字体直接打包。
+
+文化感来自排版、阅读节奏、卦象结构和克制的朱砂强调,不使用龙凤祥云、伪古印章、金色发光、正文毛笔字或抽卡式动效。
+
+## 7. 原型截图
+
+关键状态截图位于 [`prototype/screenshots/`](../prototype/screenshots/),命名按用户旅程排序:
+
+1. `01-welcome.png`
+2. `02-question.png`
+3. `03-casting.png`
+4. `04-result-moving.png`
+5. `05-result-static.png`
+6. `06-ai-consent.png`
+7. `07-ai-explanation.png`
+8. `08-ai-error.png`
+
+截图是 360 CSS px 宽度的评审证据,不是应用商店素材。
+
+## 8. 验收清单
+
+进入 Android P0/P3 前,需要用户明确反馈或确认:
+
+- [ ] “纸、墨、朱砂、留白”的整体气质是否合适;
+- [ ] 工作名只作占位,不把“一问”直接视为正式名称;
+- [ ] 三枚硬币用圆形切换控件录入是否容易理解;
+- [ ] 结果页先卦象、后原文/白话、再点击“解”的层级是否合适;
+- [ ] 本地解读与 AI 解读的选择、同意和来源标签是否足够清楚;
+- [ ] “可以试的一小步”的非预测、非裁决语气是否符合定位;
+- [ ] 是否需要在下一轮原型加入历史记录、深色主题或更大字号状态。
+
+未确认项应继续停留在原型层修改;不应提前固化为 Compose 生产页面。
+
+## 9. 向 Android 实现的映射
+
+| 原型概念 | Compose 目标 |
+|---|---|
+| CSS 颜色/间距/圆角令牌 | `MaterialTheme` 扩展与 design token |
+| `welcome/question/casting/result` | Navigation destination + 状态驱动页面 |
+| 三枚硬币控件 | 具备明确 semantics 的 48dp+ Compose 控件 |
+| 六爻图 | Canvas 绘制 + 完整读屏描述 |
+| 底部选择/同意层 | Material 3 modal bottom sheet/dialog |
+| 本地/AI/错误来源标签 | sealed UI state,不以颜色作为唯一提示 |
+| `prefers-reduced-motion` | 系统动画缩放/无障碍偏好适配 |
+
+Compose 实现必须从 `CastResult` 渲染,不能在 UI 层复制 King Wen 映射或根据文本猜测卦象。
+
+## 10. 本轮验证记录
+
+- `node --check prototype/app.js`:通过。
+- `node --check prototype/capture.mjs`:通过。
+- `node prototype/capture.mjs`:通过;八个状态完成 360px 截图与浏览器审计。
+- `git diff --check`:通过。
+- Chromium 360 × 792:欢迎、起念、录入、有/无动爻结果、AI 同意、成功与失败状态已渲染。
+- 已知夹具 `9,8,8,8,8,8`:显示复 24 → 坤 2,初爻动。
+- 静态夹具 `7,7,7,7,7,7`:显示乾 1,无动爻且无之卦。
+- 浏览器控制台错误:0;运行时异常:0;外部请求:0;模型调用:0。
+- 八个状态的未命名按钮、低于 48px 的按钮触控目标、过小复选框标签:均为 0。
+
+原型验证不能替代 P1 的 4,096 组合领域测试,也不能替代 Android 真机、TalkBack 与最大字体测试。
diff --git a/docs/quality-gates.md b/docs/quality-gates.md
index 44a72d0..0f54e75 100644
--- a/docs/quality-gates.md
+++ b/docs/quality-gates.md
@@ -41,6 +41,19 @@
## 3. 测试层次
+### 3.0 原型验证(P-1 阻塞视觉确认)
+
+- `node --check prototype/app.js` 无语法错误。
+- `node --check prototype/capture.mjs` 无语法错误。
+- `git diff --check` 无空白错误。
+- 本地服务运行时,`node prototype\capture.mjs` 退出码为 0。
+- Chromium 360 × 792 走通问题输入、六次录入、结果和解释选择。
+- `9,8,8,8,8,8` 显示复 24 → 坤 2、初爻动;全 7 显示乾 1、无动爻且无之卦。
+- 欢迎、起念、录入、有/无动爻结果、AI 同意、解释和失败状态均有可复现截图。
+- 浏览器控制台无应用错误;页面不发起外部请求;AI 状态明确标为本地模拟。
+
+原型通过只表示流程可供评审,不替代纯 Kotlin 领域测试、Compose UI 测试或真机无障碍检查。
+
### 3.1 纯 JVM 领域测试(最快,阻塞合并)
- 三枚铜币 8 种排列映射。
diff --git a/prototype/README.md b/prototype/README.md
new file mode 100644
index 0000000..7e857e4
--- /dev/null
+++ b/prototype/README.md
@@ -0,0 +1,26 @@
+# Brainwave 移动端原型
+
+这是一个无构建步骤、无外部依赖的高保真 HTML/CSS/JavaScript 原型。它用于在 Android 开发前确认流程与视觉,不是生产实现。
+
+从仓库根目录启动:
+
+```powershell
+python -m http.server 4173 --bind 127.0.0.1
+```
+
+打开 `http://127.0.0.1:4173/prototype/`。完整状态入口、领域边界、评审清单和 Android 映射见 [`docs/prototype.md`](../docs/prototype.md)。
+
+服务保持运行时,可用当前系统的 Chrome/Edge 重新生成八张截图并执行浏览器审计:
+
+```powershell
+node prototype\capture.mjs
+```
+
+脚本使用 Node 22 自带的 WebSocket 直接连接 Chrome DevTools,不安装 npm 包;它会核对两个已知卦象、AI 同意门、控制台/外部请求、按钮名称和 48px 最小触控目标。任一门禁失败时退出码为非零。
+
+约束:
+
+- 不发起外部请求,不调用 AI;
+- 不生成随机卦,用户逐枚录入硬币;
+- 查询参数只用于稳定预览和截图;
+- 工作名“一问”与当前文字内容均为评审候选,不代表正式命名或内容授权完成。
diff --git a/prototype/app.js b/prototype/app.js
new file mode 100644
index 0000000..c32813d
--- /dev/null
+++ b/prototype/app.js
@@ -0,0 +1,733 @@
+(() => {
+ "use strict";
+
+ const SAMPLE_QUESTION = "我是否要接受这次工作调整?怎样做能更稳妥?";
+ const SAMPLE_LINES = [9, 8, 8, 8, 8, 8];
+
+ const HEXAGRAM_NAMES = [
+ "", "乾", "坤", "屯", "蒙", "需", "讼", "师", "比", "小畜", "履", "泰", "否",
+ "同人", "大有", "谦", "豫", "随", "蛊", "临", "观", "噬嗑", "贲", "剥", "复",
+ "无妄", "大畜", "颐", "大过", "坎", "离", "咸", "恒", "遁", "大壮", "晋",
+ "明夷", "家人", "睽", "蹇", "解", "损", "益", "夬", "姤", "萃", "升", "困",
+ "井", "革", "鼎", "震", "艮", "渐", "归妹", "丰", "旅", "巽", "兑", "涣",
+ "节", "中孚", "小过", "既济", "未济"
+ ];
+
+ // 行为下卦、列为上卦;三位编码均按自下而上排列,1 为阳、0 为阴。
+ const KING_WEN = {
+ "111": { "111": 1, "100": 34, "010": 5, "001": 26, "000": 11, "011": 9, "101": 14, "110": 43 },
+ "100": { "111": 25, "100": 51, "010": 3, "001": 27, "000": 24, "011": 42, "101": 21, "110": 17 },
+ "010": { "111": 6, "100": 40, "010": 29, "001": 4, "000": 7, "011": 59, "101": 64, "110": 47 },
+ "001": { "111": 33, "100": 62, "010": 39, "001": 52, "000": 15, "011": 53, "101": 56, "110": 31 },
+ "000": { "111": 12, "100": 16, "010": 8, "001": 23, "000": 2, "011": 20, "101": 35, "110": 45 },
+ "011": { "111": 44, "100": 32, "010": 48, "001": 18, "000": 46, "011": 57, "101": 50, "110": 28 },
+ "101": { "111": 13, "100": 55, "010": 63, "001": 22, "000": 36, "011": 37, "101": 30, "110": 49 },
+ "110": { "111": 10, "100": 54, "010": 60, "001": 41, "000": 19, "011": 61, "101": 38, "110": 58 }
+ };
+
+ const icons = {
+ lock: '',
+ arrow: '',
+ chevron: '',
+ shield: ''
+ };
+
+ const screen = document.querySelector("#screen");
+ const modalRoot = document.querySelector("#modal-root");
+ const toastRegion = document.querySelector("#toast-region");
+ const backButton = document.querySelector("[data-action='back']");
+
+ const state = {
+ screen: "welcome",
+ question: "",
+ lines: [],
+ coins: [null, null, null],
+ modal: null,
+ consent: false,
+ explanationSource: "ai",
+ aiError: false
+ };
+
+ function escapeHtml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+ }
+
+ function lineIsYang(value) {
+ return value === 7 || value === 9;
+ }
+
+ function lineIsMoving(value) {
+ return value === 6 || value === 9;
+ }
+
+ function transformLines(lines) {
+ return lines.map((value) => {
+ if (value === 6) return 7;
+ if (value === 9) return 8;
+ return value;
+ });
+ }
+
+ function lookupHexagram(lines) {
+ const bits = lines.map((value) => lineIsYang(value) ? "1" : "0");
+ const lower = bits.slice(0, 3).join("");
+ const upper = bits.slice(3, 6).join("");
+ const number = KING_WEN[lower][upper];
+ return { number, name: HEXAGRAM_NAMES[number] };
+ }
+
+ function lineName(value, index) {
+ const position = ["初", "二", "三", "四", "五", "上"][index];
+ const polarity = lineIsYang(value) ? "九" : "六";
+ if (index === 0) return `${position}${polarity}`;
+ if (index === 5) return `${position}${polarity}`;
+ return `${polarity}${position}`;
+ }
+
+ function resultData() {
+ const sourceLines = state.lines.length === 6 ? state.lines : SAMPLE_LINES;
+ const changedLines = transformLines(sourceLines);
+ return {
+ sourceLines,
+ changedLines,
+ primary: lookupHexagram(sourceLines),
+ changed: lookupHexagram(changedLines),
+ movingIndexes: sourceLines
+ .map((value, index) => lineIsMoving(value) ? index : -1)
+ .filter((index) => index >= 0)
+ };
+ }
+
+ function stepper(current, total) {
+ const progress = Math.round((current / total) * 100);
+ return `
+
+ ${String(current).padStart(2, "0")}
+
+ ${String(total).padStart(2, "0")}
+
`;
+ }
+
+ function welcomeView() {
+ return `
+
+
+
慢一点,看清当下
+
把一个犹豫,安静地放在这里。
+
用你手中的三枚硬币,依次记录六次结果。这里提供文化文本与反思线索,不替你预测,也不替你决定。
+
+
+ 观
+
+
+
+
+
+
+
+
工作名“一问”仅用于本轮视觉评审
+
+ `;
+ }
+
+ function questionView() {
+ return `
+
+ ${stepper(1, 2)}
+ 起念
+ 你想看清什么?
+ 写成一个开放的问题,比只问“会不会”更容易照见可行动的部分。
+
+
+
+
+ 只保存在本次原型会话中
+ ${state.question.length}/120
+
+
+
+ ${icons.lock}
+
起卦计算在本地完成。只有你主动选择 AI 解读并再次确认后,所需内容才会被发送。
+
+
+
+
+
+
+
+ `;
+ }
+
+ function lineStack(lines) {
+ return [5, 4, 3, 2, 1, 0].map((index) => {
+ const value = lines[index];
+ if (!value) {
+ return '';
+ }
+ const kind = lineIsYang(value) ? "yang" : "yin";
+ const moving = lineIsMoving(value) ? " line-slot--moving" : "";
+ return `
+ ${lineIsMoving(value) ? `${value}` : ""}
+
`;
+ }).join("");
+ }
+
+ function coinButton(value, index) {
+ const face = value === 2 ? "字" : value === 3 ? "背" : "点选";
+ const score = value ? String(value) : "字 ↔ 背";
+ const valueAttr = value ? `data-value="${value}"` : "";
+ const next = value === null ? "字,2 分" : value === 2 ? "背,3 分" : "字,2 分";
+ return `
+
+
+ 硬币 ${index + 1}
+
`;
+ }
+
+ function castingView() {
+ const round = state.lines.length + 1;
+ const selected = state.coins.every((value) => value !== null);
+ const sum = selected ? state.coins.reduce((total, value) => total + value, 0) : null;
+ const currentType = sum
+ ? ({ 6: "老阴 · 动", 7: "少阳", 8: "少阴", 9: "老阳 · 动" })[sum]
+ : "";
+
+ return `
+
+ ${stepper(2, 2)}
+ ${escapeHtml(state.question)}
+
+
+
+
+
${state.lines.length} / 6
+
从下往上记录
+ ${state.lines.length ? '
' : ""}
+
+
${lineStack(state.lines)}
+
+
+
+
第 ${round} 爻
+
依照手中硬币,逐枚点选“字”或“背”
+
+
+
+ ${state.coins.map(coinButton).join("")}
+
+
+
+ ${sum ? `本次合计 ${sum} · ${currentType}` : "点按硬币可在“字 2”与“背 3”间切换"}
+
+
+
+
+
+
+ `;
+ }
+
+ function hexLine(value, index, showMoving) {
+ const kind = lineIsYang(value) ? "yang" : "yin";
+ const moving = showMoving && lineIsMoving(value) ? " hex-line--moving" : "";
+ return `
`;
+ }
+
+ function hexagramFigure(lines, hexagram, showMoving) {
+ const rendered = [5, 4, 3, 2, 1, 0]
+ .map((index) => hexLine(lines[index], index, showMoving))
+ .join("");
+ return `
+
+
${rendered}
+
${hexagram.name}
+
第 ${hexagram.number} 卦
+
`;
+ }
+
+ function resultCopy(data) {
+ if (data.primary.number === 24) {
+ return {
+ classic: "复:亨。出入无疾,朋来无咎。反复其道,七日来复。利有攸往。",
+ plain: "“复”提醒人把注意力放回可返回、可重新开始之处。它不是替你判断成败,而是邀请你看见:此刻是否有一条更朴素、更合乎本心的路。",
+ moving: "初九:不远复,无祇悔,元吉。",
+ movingPlain: "偏离尚不远时就察觉并回转,代价通常较小。先修正一个最近的动作,比一次解决所有问题更可行。"
+ };
+ }
+ return {
+ classic: `${data.primary.name}卦 · 第 ${data.primary.number} 卦。本原型仅演示排盘与阅读层级,正式经典文本将从经校勘的本地内容包读取。`,
+ plain: "先观察卦象呈现的张力与变化,再回到你能核实的事实、感受和资源。不要把符号当成替你下结论的证据。",
+ moving: data.movingIndexes.length ? `${data.movingIndexes.map((index) => lineName(data.sourceLines[index], index)).join("、")}发生变化。` : "",
+ movingPlain: "变化的位置可作为反思入口:哪里正在松动,哪里仍需要保留余地?"
+ };
+ }
+
+ function errorCard() {
+ return `
+
+
AI 解读暂时没有返回
+
起卦结果仍完整保存在本页。你可以改看本地解读,或稍后重试。
+
+
+
+
+
`;
+ }
+
+ function resultView() {
+ const data = resultData();
+ const copy = resultCopy(data);
+ const hasMoving = data.movingIndexes.length > 0;
+ const question = state.question || "未记录具体问题";
+
+ return `
+
+
+
+ 起卦结果 · 本地计算
+ ${hasMoving ? `${data.movingIndexes.length} 个动爻` : "无动爻"}
+
+
+
+ ${hexagramFigure(data.sourceLines, data.primary, true)}
+ ${hasMoving ? `
之
${hexagramFigure(data.changedLines, data.changed, false)}` : ""}
+
+ ${hasMoving ? "" : '
卦象稳定 · 不显示之卦
'}
+
+
+
+
+
你所问
+ 本次记录
+
+
${escapeHtml(question)}
+
+
+ ${state.aiError ? errorCard() : ""}
+
+
+
+
经典原文
+ 经典文本
+
+
${copy.classic}
+
+
+
+
+ ${hasMoving ? `
+
+
动爻
变化处
+
+
${data.movingIndexes.map((index) => lineName(data.sourceLines[index], index)).join(" · ")}
+
${copy.moving}
+
${copy.movingPlain}
+
+
` : ""}
+
+
+
+
+
+
+
+ `;
+ }
+
+ function loadingView() {
+ return `
+
+
+
+
正在整理反思线索
+
结果已经生成。AI 只在此处协助组织文字,不参与起卦。
+
+ `;
+ }
+
+ function explanationView() {
+ const data = resultData();
+ const ai = state.explanationSource === "ai";
+ return `
+
+
+
+ 眼前更重要的,也许不是立刻选定一条路,而是辨认哪一步能让你重新获得稳定与判断力。
+
+
+ 可以先观察
+
+ - 你对“调整”的担心里,哪些是已经发生的事实,哪些是尚未验证的推测?
+ - 复卦的动处在最初一爻,适合把注意力放在能尽早修正的小偏差。
+ - 之卦为坤,提示行动时给现实条件、协作关系与节奏留出空间。
+
+
+
+
+ 问问自己
+
+ - 如果暂时不追求一次做对,我最想先确认的一个事实是什么?
+ - 哪一种选择更容易保留回转余地,而不是把自己锁死?
+ - 我可以向谁说明顾虑,并获得一条具体信息?
+
+
+
+
+
+
可以试的一小步
+
在今天结束前,写下“已知事实、尚待确认、我的底线”各一条,再约一次不超过 20 分钟的信息沟通。
+
+
+
+ 这是一种文化反思与文字整理,不是预测、诊断或替代你的判断。涉及医疗、法律、财务或人身安全时,请寻求相应专业支持。
+
+
+
+
+ `;
+ }
+
+ function interpretationModal() {
+ return `
+
+
+
+
+
+
+
+
+
+
`;
+ }
+
+ function consentModal() {
+ return `
+
+
+
+
+
+
+ ${icons.shield}
+
将发送以下内容;不会发送设备标识、历史记录或其他本地数据。
+
+
+ - 你本次写下的问题
+ - 本卦、之卦与动爻结果
+ - 用于约束语气和安全边界的提示
+
+
+
+
+
+
+
+
+
+
`;
+ }
+
+ function methodModal() {
+ return `
+
+
+
+
+
+
+ - 准备三枚硬币,在现实中投掷一次。
+ - 在页面逐枚记录字面或背面,确认后形成一爻。
+ - 从下往上重复六次;程序只换算,不替你随机生成。
+
+
+
+
字 · 2字面记两分
+
背 · 3背面记三分
+
+ 合计 6 为老阴、7 为少阳、8 为少阴、9 为老阳;6 与 9 是动爻。
+
+
+
`;
+ }
+
+ function renderModal() {
+ if (state.modal === "interpretation") modalRoot.innerHTML = interpretationModal();
+ else if (state.modal === "consent") modalRoot.innerHTML = consentModal();
+ else if (state.modal === "method") modalRoot.innerHTML = methodModal();
+ else modalRoot.innerHTML = "";
+
+ document.body.style.overflow = state.modal ? "hidden" : "";
+ if (state.modal) {
+ window.setTimeout(() => {
+ modalRoot.querySelector("button, input")?.focus();
+ }, 20);
+ }
+ }
+
+ function render(options = {}) {
+ if (state.screen === "welcome") screen.innerHTML = welcomeView();
+ else if (state.screen === "question") screen.innerHTML = questionView();
+ else if (state.screen === "casting") screen.innerHTML = castingView();
+ else if (state.screen === "result") screen.innerHTML = resultView();
+ else if (state.screen === "loading") screen.innerHTML = loadingView();
+ else if (state.screen === "explanation") screen.innerHTML = explanationView();
+
+ backButton.hidden = state.screen === "welcome";
+ renderModal();
+
+ if (!options.preserveFocus) {
+ screen.focus({ preventScroll: true });
+ window.scrollTo({ top: 0, behavior: "auto" });
+ }
+ }
+
+ function showToast(message) {
+ toastRegion.innerHTML = `${escapeHtml(message)}
`;
+ window.setTimeout(() => {
+ toastRegion.innerHTML = "";
+ }, 1800);
+ }
+
+ function closeModal() {
+ state.modal = null;
+ state.consent = false;
+ renderModal();
+ }
+
+ function goBack() {
+ if (state.modal) {
+ closeModal();
+ return;
+ }
+ if (state.screen === "question") state.screen = "welcome";
+ else if (state.screen === "casting") state.screen = "question";
+ else if (state.screen === "result") state.screen = "question";
+ else if (state.screen === "loading" || state.screen === "explanation") state.screen = "result";
+ render();
+ }
+
+ function beginAiExplanation() {
+ state.modal = null;
+ state.screen = "loading";
+ state.explanationSource = "ai";
+ render();
+ window.setTimeout(() => {
+ if (state.screen !== "loading") return;
+ state.screen = "explanation";
+ render();
+ }, 900);
+ }
+
+ document.addEventListener("input", (event) => {
+ if (!event.target.matches("[data-question-input]")) return;
+ state.question = event.target.value;
+ document.querySelector("[data-char-count]").textContent = `${state.question.length}/120`;
+ const startButton = document.querySelector("[data-action='start-casting']");
+ if (startButton) startButton.disabled = !state.question.trim();
+ });
+
+ document.addEventListener("change", (event) => {
+ if (!event.target.matches("[data-action='consent-toggle']")) return;
+ state.consent = event.target.checked;
+ const confirm = document.querySelector("[data-action='confirm-ai']");
+ if (confirm) confirm.disabled = !state.consent;
+ });
+
+ document.addEventListener("click", (event) => {
+ const actionTarget = event.target.closest("[data-action]");
+ if (!actionTarget) return;
+ const action = actionTarget.dataset.action;
+
+ if (action === "dismiss-modal" && event.target.closest("[data-modal-sheet]")) return;
+
+ if (action === "back") goBack();
+ else if (action === "begin") {
+ state.screen = "question";
+ render();
+ } else if (action === "skip-question") {
+ state.question = "未记录具体问题(仅作自我观察)";
+ state.lines = [];
+ state.coins = [null, null, null];
+ state.screen = "casting";
+ render();
+ } else if (action === "start-casting") {
+ if (!state.question.trim()) return;
+ state.lines = [];
+ state.coins = [null, null, null];
+ state.screen = "casting";
+ render();
+ } else if (action === "toggle-coin") {
+ const index = Number(actionTarget.dataset.index);
+ const current = state.coins[index];
+ state.coins[index] = current === null || current === 3 ? 2 : 3;
+ render({ preserveFocus: true });
+ document.querySelector(`[data-action='toggle-coin'][data-index='${index}']`)?.focus();
+ } else if (action === "confirm-line") {
+ if (!state.coins.every((value) => value !== null)) return;
+ state.lines.push(state.coins.reduce((total, value) => total + value, 0));
+ state.coins = [null, null, null];
+ if (state.lines.length === 6) {
+ state.screen = "result";
+ }
+ render();
+ } else if (action === "undo-line") {
+ state.lines.pop();
+ state.coins = [null, null, null];
+ render();
+ showToast("已撤销上一爻");
+ } else if (action === "open-interpretation") {
+ state.modal = "interpretation";
+ renderModal();
+ } else if (action === "choose-ai") {
+ state.modal = "consent";
+ state.consent = false;
+ renderModal();
+ } else if (action === "choose-local") {
+ state.modal = null;
+ state.explanationSource = "local";
+ state.screen = "explanation";
+ render();
+ } else if (action === "confirm-ai") {
+ if (!state.consent) return;
+ beginAiExplanation();
+ } else if (action === "retry-ai") {
+ state.aiError = false;
+ state.modal = "consent";
+ state.consent = false;
+ renderModal();
+ } else if (action === "restart") {
+ state.question = "";
+ state.lines = [];
+ state.coins = [null, null, null];
+ state.aiError = false;
+ state.screen = "welcome";
+ render();
+ } else if (action === "back-to-result") {
+ state.screen = "result";
+ render();
+ } else if (action === "open-method") {
+ state.modal = "method";
+ renderModal();
+ } else if (action === "close-modal" || action === "dismiss-modal") {
+ closeModal();
+ }
+ });
+
+ document.addEventListener("keydown", (event) => {
+ if (event.key === "Escape" && state.modal) closeModal();
+ });
+
+ function seedPreview() {
+ const view = new URLSearchParams(window.location.search).get("view");
+ if (!view || view === "welcome") return;
+
+ if (view === "question") {
+ state.screen = "question";
+ state.question = SAMPLE_QUESTION;
+ return;
+ }
+
+ state.question = SAMPLE_QUESTION;
+ if (view === "casting") {
+ state.screen = "casting";
+ state.lines = [9, 8];
+ state.coins = [2, 3, null];
+ return;
+ }
+
+ state.lines = view === "static" ? [7, 7, 7, 7, 7, 7] : [...SAMPLE_LINES];
+ state.screen = "result";
+
+ if (view === "consent") state.modal = "consent";
+ else if (view === "explanation") {
+ state.screen = "explanation";
+ state.explanationSource = "ai";
+ } else if (view === "local") {
+ state.screen = "explanation";
+ state.explanationSource = "local";
+ } else if (view === "error") {
+ state.aiError = true;
+ }
+ }
+
+ seedPreview();
+ render();
+})();
diff --git a/prototype/capture.mjs b/prototype/capture.mjs
new file mode 100644
index 0000000..f3bd08c
--- /dev/null
+++ b/prototype/capture.mjs
@@ -0,0 +1,342 @@
+import { spawn } from "node:child_process";
+import { existsSync } from "node:fs";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import net from "node:net";
+import os from "node:os";
+import path from "node:path";
+import process from "node:process";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const screenshotDir = path.join(__dirname, "screenshots");
+const baseUrl = process.env.BRAINWAVE_PROTOTYPE_URL ?? "http://127.0.0.1:4173/prototype/";
+
+const cases = [
+ { view: "welcome", file: "01-welcome.png", fullPage: true },
+ { view: "question", file: "02-question.png", fullPage: true },
+ { view: "casting", file: "03-casting.png", fullPage: true },
+ { view: "result", file: "04-result-moving.png", fullPage: true },
+ { view: "static", file: "05-result-static.png", fullPage: true },
+ { view: "consent", file: "06-ai-consent.png", fullPage: false },
+ { view: "explanation", file: "07-ai-explanation.png", fullPage: true },
+ { view: "error", file: "08-ai-error.png", fullPage: true }
+];
+
+function findChrome() {
+ const candidates = process.platform === "win32"
+ ? [
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
+ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
+ "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"
+ ]
+ : process.platform === "darwin"
+ ? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]
+ : ["/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"];
+
+ const executable = candidates.find(existsSync);
+ if (!executable) {
+ throw new Error("未找到 Chrome/Edge。请安装浏览器,或扩展 capture.mjs 中的候选路径。");
+ }
+ return executable;
+}
+
+function getFreePort() {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.unref();
+ server.on("error", reject);
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ server.close(() => resolve(address.port));
+ });
+ });
+}
+
+function delay(milliseconds) {
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
+}
+
+async function waitForDebugTarget(port) {
+ const endpoint = `http://127.0.0.1:${port}/json/list`;
+ for (let attempt = 0; attempt < 50; attempt += 1) {
+ try {
+ const response = await fetch(endpoint);
+ if (response.ok) {
+ const targets = await response.json();
+ const page = targets.find((target) => target.type === "page");
+ if (page?.webSocketDebuggerUrl) return page.webSocketDebuggerUrl;
+ }
+ } catch {
+ // Chrome may still be starting; retry within the bounded loop.
+ }
+ await delay(100);
+ }
+ throw new Error("Chrome DevTools endpoint did not become ready.");
+}
+
+class CdpClient {
+ constructor(webSocketUrl) {
+ this.socket = new WebSocket(webSocketUrl);
+ this.nextId = 1;
+ this.pending = new Map();
+ this.listeners = new Map();
+ }
+
+ async connect() {
+ await new Promise((resolve, reject) => {
+ this.socket.addEventListener("open", resolve, { once: true });
+ this.socket.addEventListener("error", reject, { once: true });
+ });
+ this.socket.addEventListener("message", (event) => this.#handleMessage(event));
+ }
+
+ #handleMessage(event) {
+ const message = JSON.parse(String(event.data));
+ if (message.id) {
+ const pending = this.pending.get(message.id);
+ if (!pending) return;
+ this.pending.delete(message.id);
+ if (message.error) pending.reject(new Error(message.error.message));
+ else pending.resolve(message.result);
+ return;
+ }
+
+ const listeners = this.listeners.get(message.method) ?? [];
+ for (const listener of listeners) listener(message.params);
+ }
+
+ send(method, params = {}) {
+ const id = this.nextId;
+ this.nextId += 1;
+ return new Promise((resolve, reject) => {
+ this.pending.set(id, { resolve, reject });
+ this.socket.send(JSON.stringify({ id, method, params }));
+ });
+ }
+
+ on(method, listener) {
+ const listeners = this.listeners.get(method) ?? [];
+ listeners.push(listener);
+ this.listeners.set(method, listeners);
+ }
+
+ waitFor(method, timeoutMs = 5000) {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${method}`)), timeoutMs);
+ const listener = (params) => {
+ clearTimeout(timeout);
+ const listeners = this.listeners.get(method) ?? [];
+ this.listeners.set(method, listeners.filter((candidate) => candidate !== listener));
+ resolve(params);
+ };
+ this.on(method, listener);
+ });
+ }
+
+ close() {
+ this.socket.close();
+ }
+}
+
+async function evaluate(client, expression) {
+ const result = await client.send("Runtime.evaluate", {
+ expression,
+ returnByValue: true,
+ awaitPromise: true
+ });
+ if (result.exceptionDetails) {
+ throw new Error(result.exceptionDetails.text ?? "Runtime evaluation failed.");
+ }
+ return result.result.value;
+}
+
+async function navigate(client, view) {
+ const loaded = client.waitFor("Page.loadEventFired");
+ await client.send("Page.navigate", { url: `${baseUrl}?view=${view}` });
+ await loaded;
+ await delay(320);
+}
+
+async function capture(client, item) {
+ await navigate(client, item.view);
+ const dimensions = await evaluate(client, `(() => ({
+ width: document.documentElement.scrollWidth,
+ height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)
+ }))()`);
+ const height = item.fullPage ? dimensions.height : 792;
+ const screenshot = await client.send("Page.captureScreenshot", {
+ format: "png",
+ fromSurface: true,
+ captureBeyondViewport: true,
+ clip: { x: 0, y: 0, width: 360, height, scale: 1 }
+ });
+ await writeFile(path.join(screenshotDir, item.file), Buffer.from(screenshot.data, "base64"));
+ return { view: item.view, file: item.file, width: 360, height };
+}
+
+async function auditView(client, view) {
+ await navigate(client, view);
+ return evaluate(client, `(() => {
+ const visible = (element) => {
+ const style = getComputedStyle(element);
+ const rect = element.getBoundingClientRect();
+ return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
+ };
+ const buttons = [...document.querySelectorAll("button")].filter(visible);
+ const touchIssues = buttons.map((button) => {
+ const rect = button.getBoundingClientRect();
+ return {
+ name: button.getAttribute("aria-label") || button.innerText.trim(),
+ width: Math.round(rect.width),
+ height: Math.round(rect.height)
+ };
+ }).filter((item) => item.width < 48 || item.height < 48);
+ const unnamedButtons = buttons
+ .filter((button) => !(button.getAttribute("aria-label") || button.innerText.trim()))
+ .length;
+ const checkboxes = [...document.querySelectorAll('input[type="checkbox"]')].filter(visible);
+ const smallCheckboxLabels = checkboxes.filter((checkbox) => {
+ const label = checkbox.closest("label");
+ if (!label) return true;
+ const rect = label.getBoundingClientRect();
+ return rect.width < 48 || rect.height < 48;
+ }).length;
+ return { view: ${JSON.stringify(view)}, unnamedButtons, touchIssues, smallCheckboxLabels };
+ })()`);
+}
+
+async function run() {
+ const health = await fetch(baseUrl);
+ if (!health.ok) {
+ throw new Error(`原型服务不可用:${baseUrl} (${health.status})`);
+ }
+
+ const chrome = findChrome();
+ const port = await getFreePort();
+ const profileDir = await mkdtemp(path.join(os.tmpdir(), "brainwave-prototype-capture-"));
+ const chromeProcess = spawn(chrome, [
+ "--headless=new",
+ "--disable-gpu",
+ "--no-first-run",
+ "--no-default-browser-check",
+ `--remote-debugging-port=${port}`,
+ `--user-data-dir=${profileDir}`,
+ "about:blank"
+ ], { stdio: "ignore", windowsHide: true });
+
+ let client;
+ try {
+ const webSocketUrl = await waitForDebugTarget(port);
+ client = new CdpClient(webSocketUrl);
+ await client.connect();
+ await Promise.all([
+ client.send("Page.enable"),
+ client.send("Runtime.enable"),
+ client.send("Network.enable"),
+ client.send("Log.enable"),
+ client.send("Emulation.setDeviceMetricsOverride", {
+ width: 360,
+ height: 792,
+ deviceScaleFactor: 1,
+ mobile: false,
+ screenWidth: 360,
+ screenHeight: 792
+ })
+ ]);
+
+ const consoleErrors = [];
+ const runtimeErrors = [];
+ const externalRequests = [];
+ client.on("Log.entryAdded", ({ entry }) => {
+ if (entry.level === "error") consoleErrors.push(entry.text);
+ });
+ client.on("Runtime.exceptionThrown", ({ exceptionDetails }) => {
+ runtimeErrors.push(exceptionDetails.exception?.description ?? exceptionDetails.text);
+ });
+ client.on("Network.requestWillBeSent", ({ request }) => {
+ const url = new URL(request.url);
+ if (!["127.0.0.1", "localhost"].includes(url.hostname) && url.protocol !== "data:") {
+ externalRequests.push(request.url);
+ }
+ });
+
+ await mkdir(screenshotDir, { recursive: true });
+ const screenshots = [];
+ for (const item of cases) screenshots.push(await capture(client, item));
+
+ await navigate(client, "result");
+ const movingResult = await evaluate(client, `(() => ({
+ names: [...document.querySelectorAll(".hexagram-name")].map((node) => node.textContent.trim()),
+ numbers: [...document.querySelectorAll(".hexagram-number")].map((node) => node.textContent.trim()),
+ moving: document.querySelector(".result-kicker")?.textContent.replace(/\\s+/g, " ").trim()
+ }))()`);
+
+ await navigate(client, "static");
+ const staticResult = await evaluate(client, `(() => ({
+ names: [...document.querySelectorAll(".hexagram-name")].map((node) => node.textContent.trim()),
+ transformCount: document.querySelectorAll(".transform-arrow").length,
+ label: document.querySelector(".static-indicator")?.textContent.trim()
+ }))()`);
+
+ await navigate(client, "consent");
+ const consentBefore = await evaluate(client, "document.querySelector('[data-action=\"confirm-ai\"]')?.disabled");
+ await evaluate(client, `(() => {
+ document.querySelector('[data-action="consent-toggle"]').click();
+ return document.querySelector('[data-action="confirm-ai"]').disabled;
+ })()`);
+ const consentAfter = await evaluate(client, "document.querySelector('[data-action=\"confirm-ai\"]')?.disabled");
+ await evaluate(client, "document.querySelector('[data-action=\"confirm-ai\"]')?.click()");
+ await delay(1050);
+ const explanationSource = await evaluate(client, "document.querySelector('.source-badge')?.textContent.trim()");
+
+ const accessibility = [];
+ for (const item of cases) accessibility.push(await auditView(client, item.view));
+
+ const report = {
+ chrome,
+ baseUrl,
+ screenshots,
+ movingResult,
+ staticResult,
+ consentGate: {
+ disabledBeforeConsent: consentBefore,
+ disabledAfterConsent: consentAfter,
+ explanationSource
+ },
+ consoleErrors,
+ runtimeErrors,
+ externalRequests: [...new Set(externalRequests)],
+ accessibility
+ };
+
+ const failed =
+ movingResult.names.join(",") !== "复,坤" ||
+ movingResult.numbers.join(",") !== "第 24 卦,第 2 卦" ||
+ !movingResult.moving.includes("1 个动爻") ||
+ staticResult.names.join(",") !== "乾" ||
+ staticResult.transformCount !== 0 ||
+ consentBefore !== true ||
+ consentAfter !== false ||
+ explanationSource !== "AI 生成" ||
+ consoleErrors.length > 0 ||
+ runtimeErrors.length > 0 ||
+ externalRequests.length > 0 ||
+ accessibility.some((item) =>
+ item.unnamedButtons > 0 || item.touchIssues.length > 0 || item.smallCheckboxLabels > 0
+ );
+
+ console.log(JSON.stringify(report, null, 2));
+ if (failed) process.exitCode = 1;
+ } finally {
+ client?.close();
+ chromeProcess.kill();
+ await delay(150);
+ await rm(profileDir, { recursive: true, force: true });
+ }
+}
+
+run().catch((error) => {
+ console.error(error.stack ?? error.message);
+ process.exitCode = 1;
+});
diff --git a/prototype/index.html b/prototype/index.html
new file mode 100644
index 0000000..7639b6d
--- /dev/null
+++ b/prototype/index.html
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+ 一问 · Brainwave 高保真原型
+
+
+
+
+ 跳到主要内容
+
+
+
+
+
+
+
+ 一问
+ BRAINWAVE · 原型
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/prototype/screenshots/01-welcome.png b/prototype/screenshots/01-welcome.png
new file mode 100644
index 0000000..6872178
Binary files /dev/null and b/prototype/screenshots/01-welcome.png differ
diff --git a/prototype/screenshots/02-question.png b/prototype/screenshots/02-question.png
new file mode 100644
index 0000000..8f5d028
Binary files /dev/null and b/prototype/screenshots/02-question.png differ
diff --git a/prototype/screenshots/03-casting.png b/prototype/screenshots/03-casting.png
new file mode 100644
index 0000000..ab476f1
Binary files /dev/null and b/prototype/screenshots/03-casting.png differ
diff --git a/prototype/screenshots/04-result-moving.png b/prototype/screenshots/04-result-moving.png
new file mode 100644
index 0000000..6e85b78
Binary files /dev/null and b/prototype/screenshots/04-result-moving.png differ
diff --git a/prototype/screenshots/05-result-static.png b/prototype/screenshots/05-result-static.png
new file mode 100644
index 0000000..6ed6c2a
Binary files /dev/null and b/prototype/screenshots/05-result-static.png differ
diff --git a/prototype/screenshots/06-ai-consent.png b/prototype/screenshots/06-ai-consent.png
new file mode 100644
index 0000000..5612fed
Binary files /dev/null and b/prototype/screenshots/06-ai-consent.png differ
diff --git a/prototype/screenshots/07-ai-explanation.png b/prototype/screenshots/07-ai-explanation.png
new file mode 100644
index 0000000..1877af9
Binary files /dev/null and b/prototype/screenshots/07-ai-explanation.png differ
diff --git a/prototype/screenshots/08-ai-error.png b/prototype/screenshots/08-ai-error.png
new file mode 100644
index 0000000..e9ab970
Binary files /dev/null and b/prototype/screenshots/08-ai-error.png differ
diff --git a/prototype/styles.css b/prototype/styles.css
new file mode 100644
index 0000000..e3ef606
--- /dev/null
+++ b/prototype/styles.css
@@ -0,0 +1,1459 @@
+:root {
+ color-scheme: light;
+ --paper: #f7f2e8;
+ --paper-deep: #eee5d5;
+ --surface: #fffdf7;
+ --surface-muted: #f1eadf;
+ --ink: #1f1b16;
+ --ink-soft: #655e55;
+ --ink-faint: #70675d;
+ --cinnabar: #8c2f2b;
+ --cinnabar-strong: #742521;
+ --cinnabar-soft: #f1ded8;
+ --ochre: #765d3e;
+ --outline: #b9aea0;
+ --outline-soft: #ddd3c4;
+ --success: #3e634a;
+ --error: #9b342d;
+ --shadow-soft: 0 12px 32px rgb(50 36 22 / 8%);
+ --shadow-raised: 0 18px 48px rgb(50 36 22 / 15%);
+ --radius-xs: 8px;
+ --radius-sm: 12px;
+ --radius-md: 18px;
+ --radius-lg: 26px;
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 20px;
+ --space-6: 24px;
+ --space-8: 32px;
+ --space-10: 40px;
+ --sans: "Noto Sans SC", "Source Han Sans SC", "Microsoft YaHei UI", sans-serif;
+ --serif: "Noto Serif SC", "Source Han Serif SC", "Songti SC", SimSun, serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ min-width: 320px;
+ background: #d8d0c4;
+}
+
+body {
+ min-width: 320px;
+ min-height: 100vh;
+ margin: 0;
+ color: var(--ink);
+ background:
+ radial-gradient(circle at 15% 8%, rgb(255 255 255 / 55%), transparent 26%),
+ linear-gradient(145deg, #e8e1d6, #cec5b7);
+ font-family: var(--sans);
+ font-size: 15px;
+ line-height: 1.6;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+}
+
+button,
+textarea,
+input {
+ font: inherit;
+}
+
+button {
+ color: inherit;
+}
+
+button:focus-visible,
+textarea:focus-visible,
+input:focus-visible,
+a:focus-visible {
+ outline: 3px solid rgb(140 47 43 / 30%);
+ outline-offset: 3px;
+}
+
+.skip-link {
+ position: fixed;
+ z-index: 100;
+ top: 8px;
+ left: 8px;
+ padding: 8px 12px;
+ color: white;
+ background: var(--ink);
+ border-radius: var(--radius-xs);
+ transform: translateY(-150%);
+}
+
+.skip-link:focus {
+ transform: translateY(0);
+}
+
+.prototype-stage {
+ min-height: 100vh;
+}
+
+.app-shell {
+ position: relative;
+ width: min(100%, 430px);
+ min-height: 100dvh;
+ margin: 0 auto;
+ overflow: hidden;
+ background:
+ radial-gradient(circle at 20% 25%, rgb(118 93 62 / 3%) 0 1px, transparent 1.5px),
+ radial-gradient(circle at 72% 63%, rgb(118 93 62 / 3%) 0 1px, transparent 1.5px),
+ var(--paper);
+ background-size: 22px 22px, 31px 31px, auto;
+}
+
+.topbar {
+ position: relative;
+ z-index: 10;
+ display: grid;
+ grid-template-columns: 48px 1fr 48px;
+ align-items: center;
+ min-height: calc(60px + env(safe-area-inset-top));
+ padding: env(safe-area-inset-top) 12px 0;
+ border-bottom: 1px solid rgb(185 174 160 / 45%);
+ background: rgb(247 242 232 / 92%);
+ backdrop-filter: blur(14px);
+}
+
+.icon-button {
+ display: inline-grid;
+ width: 48px;
+ height: 48px;
+ padding: 0;
+ place-items: center;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ cursor: pointer;
+ transition: background-color 180ms ease, transform 180ms ease;
+}
+
+.icon-button:hover {
+ background: rgb(118 93 62 / 8%);
+}
+
+.icon-button:active {
+ transform: scale(.96);
+}
+
+.icon-button svg {
+ width: 22px;
+ height: 22px;
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.7;
+}
+
+.topbar__back[hidden] {
+ visibility: hidden;
+ display: inline-grid;
+}
+
+.wordmark {
+ display: flex;
+ min-width: 0;
+ align-items: baseline;
+ justify-content: center;
+ gap: 8px;
+ white-space: nowrap;
+}
+
+.wordmark__name {
+ font-family: var(--serif);
+ font-size: 19px;
+ font-weight: 700;
+ letter-spacing: .14em;
+}
+
+.wordmark__meta {
+ color: var(--ink-faint);
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: .14em;
+}
+
+.screen {
+ min-height: calc(100dvh - 60px - env(safe-area-inset-top));
+ outline: none;
+}
+
+.page {
+ min-height: calc(100dvh - 60px - env(safe-area-inset-top));
+ padding: 24px 20px calc(112px + env(safe-area-inset-bottom));
+ animation: page-in 240ms ease both;
+}
+
+.page--compact {
+ padding-top: 18px;
+}
+
+.page--welcome {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ padding-top: clamp(36px, 9vh, 72px);
+}
+
+@keyframes page-in {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.eyebrow {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 0 0 12px;
+ color: var(--cinnabar);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: .18em;
+}
+
+.eyebrow::before {
+ width: 24px;
+ height: 1px;
+ background: currentColor;
+ content: "";
+}
+
+h1,
+h2,
+h3,
+p {
+ margin-top: 0;
+}
+
+h1,
+h2,
+h3,
+.serif {
+ font-family: var(--serif);
+}
+
+h1 {
+ max-width: 9em;
+ margin-bottom: 16px;
+ font-size: clamp(32px, 10vw, 42px);
+ font-weight: 650;
+ line-height: 1.25;
+ letter-spacing: .03em;
+}
+
+h2 {
+ margin-bottom: 12px;
+ font-size: 27px;
+ font-weight: 650;
+ line-height: 1.35;
+}
+
+h3 {
+ margin-bottom: 8px;
+ font-size: 18px;
+ line-height: 1.45;
+}
+
+.lede {
+ max-width: 29em;
+ color: var(--ink-soft);
+ font-size: 16px;
+ line-height: 1.85;
+}
+
+.fine-print,
+.caption {
+ color: var(--ink-faint);
+ font-size: 12px;
+ line-height: 1.65;
+}
+
+.quiet {
+ color: var(--ink-soft);
+}
+
+.hero-mark {
+ position: relative;
+ width: 132px;
+ height: 132px;
+ margin: 30px auto 22px;
+ border: 1px solid rgb(118 93 62 / 23%);
+ border-radius: 50%;
+}
+
+.hero-mark::before,
+.hero-mark::after {
+ position: absolute;
+ left: 32px;
+ width: 68px;
+ height: 8px;
+ background: var(--ink);
+ content: "";
+}
+
+.hero-mark::before {
+ top: 45px;
+}
+
+.hero-mark::after {
+ top: 78px;
+ background: linear-gradient(90deg, var(--ink) 0 43%, transparent 43% 57%, var(--ink) 57%);
+}
+
+.hero-mark__dot {
+ position: absolute;
+ top: 14px;
+ right: 8px;
+ display: grid;
+ width: 34px;
+ height: 34px;
+ color: white;
+ background: var(--cinnabar);
+ border-radius: var(--radius-xs);
+ font-family: var(--serif);
+ font-size: 15px;
+ place-items: center;
+ transform: rotate(5deg);
+}
+
+.principles {
+ display: grid;
+ margin: 26px 0 0;
+ padding: 0;
+ grid-template-columns: repeat(3, 1fr);
+ list-style: none;
+}
+
+.principles li {
+ padding: 0 10px;
+ color: var(--ink-soft);
+ border-right: 1px solid var(--outline-soft);
+ font-family: var(--serif);
+ font-size: 13px;
+ text-align: center;
+}
+
+.principles li:first-child {
+ padding-left: 0;
+}
+
+.principles li:last-child {
+ padding-right: 0;
+ border-right: 0;
+}
+
+.bottom-action {
+ position: sticky;
+ z-index: 8;
+ bottom: 0;
+ display: grid;
+ gap: 8px;
+ margin: 30px -20px calc(-112px - env(safe-area-inset-bottom));
+ padding: 14px 20px calc(14px + env(safe-area-inset-bottom));
+ background: linear-gradient(to bottom, rgb(247 242 232 / 0%), var(--paper) 18px, var(--paper));
+}
+
+.button {
+ display: inline-flex;
+ min-height: 52px;
+ padding: 12px 18px;
+ align-items: center;
+ justify-content: center;
+ gap: 9px;
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ font-weight: 650;
+ letter-spacing: .02em;
+ text-decoration: none;
+ cursor: pointer;
+ transition:
+ background-color 180ms ease,
+ border-color 180ms ease,
+ color 180ms ease,
+ transform 180ms ease,
+ box-shadow 180ms ease;
+}
+
+.button:hover {
+ transform: translateY(-1px);
+}
+
+.button:active {
+ transform: translateY(0) scale(.99);
+}
+
+.button--primary {
+ color: white;
+ background: var(--cinnabar);
+ box-shadow: 0 8px 22px rgb(140 47 43 / 20%);
+}
+
+.button--primary:hover {
+ background: var(--cinnabar-strong);
+}
+
+.button--secondary {
+ color: var(--ink);
+ background: var(--surface);
+ border-color: var(--outline);
+}
+
+.button--quiet {
+ min-height: 48px;
+ color: var(--ochre);
+ background: transparent;
+}
+
+.button--danger-quiet {
+ color: var(--error);
+ background: transparent;
+ border-color: rgb(155 52 45 / 28%);
+}
+
+.button:disabled {
+ color: #91877a;
+ background: #ded6c9;
+ border-color: transparent;
+ box-shadow: none;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.button svg,
+.text-link svg {
+ width: 18px;
+ height: 18px;
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.7;
+}
+
+.text-link {
+ display: inline-flex;
+ min-height: 48px;
+ padding: 8px 2px;
+ align-items: center;
+ gap: 6px;
+ color: var(--ochre);
+ background: transparent;
+ border: 0;
+ font-weight: 650;
+ text-decoration: underline;
+ text-decoration-color: rgb(118 93 62 / 35%);
+ text-underline-offset: 4px;
+ cursor: pointer;
+}
+
+.stepper {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0 0 22px;
+ color: var(--ink-faint);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: .1em;
+}
+
+.stepper__track {
+ position: relative;
+ height: 2px;
+ flex: 1;
+ overflow: hidden;
+ background: var(--outline-soft);
+}
+
+.stepper__track::after {
+ position: absolute;
+ inset: 0 auto 0 0;
+ width: var(--progress, 0%);
+ background: var(--cinnabar);
+ content: "";
+ transition: width 240ms ease;
+}
+
+.field-label {
+ display: block;
+ margin-bottom: 9px;
+ color: var(--ink-soft);
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.question-input {
+ width: 100%;
+ min-height: 184px;
+ padding: 16px;
+ resize: vertical;
+ color: var(--ink);
+ background: rgb(255 253 247 / 72%);
+ border: 1px solid var(--outline);
+ border-radius: var(--radius-md);
+ font-size: 16px;
+ line-height: 1.8;
+ transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
+}
+
+.question-input:focus {
+ background: var(--surface);
+ border-color: var(--cinnabar);
+ box-shadow: 0 0 0 4px rgb(140 47 43 / 9%);
+ outline: none;
+}
+
+.question-input::placeholder {
+ color: #93897c;
+}
+
+.field-meta {
+ display: flex;
+ min-height: 28px;
+ margin-top: 6px;
+ align-items: center;
+ justify-content: space-between;
+ color: var(--ink-faint);
+ font-size: 12px;
+}
+
+.privacy-note,
+.notice {
+ display: flex;
+ gap: 12px;
+ margin-top: 22px;
+ padding: 14px;
+ color: var(--ink-soft);
+ background: rgb(255 253 247 / 65%);
+ border: 1px solid var(--outline-soft);
+ border-radius: var(--radius-sm);
+ font-size: 13px;
+}
+
+.privacy-note svg,
+.notice svg {
+ width: 20px;
+ height: 20px;
+ flex: 0 0 20px;
+ fill: none;
+ stroke: var(--ochre);
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.6;
+}
+
+.privacy-note p,
+.notice p {
+ margin: 0;
+}
+
+.question-chip {
+ display: block;
+ margin: 2px 0 18px;
+ padding: 10px 13px;
+ overflow: hidden;
+ color: var(--ink-soft);
+ background: rgb(255 253 247 / 62%);
+ border-left: 2px solid var(--outline);
+ font-family: var(--serif);
+ font-size: 13px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.casting-layout {
+ display: grid;
+ gap: 18px;
+}
+
+.line-progress-card {
+ display: grid;
+ min-height: 172px;
+ padding: 16px 16px 14px;
+ grid-template-columns: 1fr 118px;
+ align-items: center;
+ gap: 14px;
+ background: rgb(255 253 247 / 58%);
+ border: 1px solid var(--outline-soft);
+ border-radius: var(--radius-md);
+}
+
+.line-progress-copy strong {
+ display: block;
+ margin-bottom: 3px;
+ font-family: var(--serif);
+ font-size: 20px;
+}
+
+.line-progress-copy p {
+ margin: 0;
+ color: var(--ink-faint);
+ font-size: 12px;
+}
+
+.line-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.line-slot {
+ position: relative;
+ display: flex;
+ width: 112px;
+ height: 13px;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+}
+
+.line-slot::before,
+.line-slot::after {
+ height: 7px;
+ background: var(--outline-soft);
+ border-radius: 1px;
+ content: "";
+}
+
+.line-slot::before {
+ flex: 1;
+}
+
+.line-slot::after {
+ flex: 1;
+}
+
+.line-slot--yang::before {
+ width: 100%;
+ flex: 0 0 100%;
+ background: var(--ink);
+}
+
+.line-slot--yang::after {
+ display: none;
+}
+
+.line-slot--yin::before,
+.line-slot--yin::after {
+ background: var(--ink);
+}
+
+.line-slot--moving::before,
+.line-slot--moving::after {
+ background: var(--cinnabar);
+}
+
+.line-slot--empty {
+ gap: 0;
+}
+
+.line-slot--empty::before {
+ height: 1px;
+ flex: 1;
+ background: repeating-linear-gradient(90deg, var(--outline-soft) 0 5px, transparent 5px 9px);
+}
+
+.line-slot--empty::after {
+ display: none;
+}
+
+.line-slot__number {
+ position: absolute;
+ right: -15px;
+ color: var(--cinnabar);
+ font-size: 9px;
+ font-weight: 800;
+}
+
+.coin-prompt {
+ text-align: center;
+}
+
+.coin-prompt h2 {
+ margin-bottom: 4px;
+ font-size: 22px;
+}
+
+.coin-prompt p {
+ margin-bottom: 0;
+ color: var(--ink-faint);
+ font-size: 12px;
+}
+
+.coins {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 10px;
+}
+
+.coin-wrap {
+ display: grid;
+ justify-items: center;
+ gap: 7px;
+}
+
+.coin {
+ position: relative;
+ display: grid;
+ width: 82px;
+ height: 82px;
+ padding: 7px;
+ place-items: center;
+ color: var(--ink-soft);
+ background:
+ radial-gradient(circle, var(--surface) 46%, transparent 47%),
+ conic-gradient(from 15deg, #d9c9a9, #f6ead1, #c9b58f, #efe1c2, #d9c9a9);
+ border: 1px solid #bca680;
+ border-radius: 50%;
+ box-shadow: inset 0 0 0 4px rgb(255 253 247 / 52%), 0 5px 14px rgb(66 47 27 / 9%);
+ cursor: pointer;
+ transition: transform 180ms ease, box-shadow 180ms ease, color 180ms ease;
+}
+
+.coin::after {
+ position: absolute;
+ inset: 10px;
+ border: 1px solid rgb(118 93 62 / 25%);
+ border-radius: 50%;
+ content: "";
+}
+
+.coin:hover {
+ transform: translateY(-2px);
+ box-shadow: inset 0 0 0 4px rgb(255 253 247 / 52%), 0 8px 16px rgb(66 47 27 / 14%);
+}
+
+.coin[data-value] {
+ color: var(--ink);
+}
+
+.coin[data-value="3"] {
+ color: var(--cinnabar);
+}
+
+.coin__value {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ justify-items: center;
+ font-family: var(--serif);
+ font-weight: 700;
+ line-height: 1.1;
+}
+
+.coin__face {
+ font-size: 21px;
+}
+
+.coin__score {
+ margin-top: 3px;
+ font-family: var(--sans);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.coin-wrap > span {
+ color: var(--ink-faint);
+ font-size: 11px;
+}
+
+.round-sum {
+ min-height: 26px;
+ color: var(--ink-soft);
+ font-size: 13px;
+ text-align: center;
+}
+
+.round-sum strong {
+ color: var(--cinnabar);
+}
+
+.result-hero {
+ margin: 0 -20px;
+ padding: 22px 20px 26px;
+ background:
+ linear-gradient(180deg, rgb(255 253 247 / 54%), rgb(255 253 247 / 90%)),
+ var(--surface);
+ border-bottom: 1px solid var(--outline-soft);
+}
+
+.result-kicker {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ color: var(--ink-faint);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: .08em;
+}
+
+.source-badge {
+ display: inline-flex;
+ min-height: 28px;
+ padding: 3px 9px;
+ align-items: center;
+ color: var(--ochre);
+ background: var(--surface-muted);
+ border: 1px solid var(--outline-soft);
+ border-radius: 999px;
+ font-family: var(--sans);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: .04em;
+}
+
+.hexagram-pair {
+ display: grid;
+ margin-top: 24px;
+ grid-template-columns: 1fr 32px 1fr;
+ align-items: center;
+}
+
+.hexagram-pair--single {
+ grid-template-columns: 1fr;
+ justify-items: center;
+}
+
+.hexagram-view {
+ display: grid;
+ justify-items: center;
+ text-align: center;
+}
+
+.hexagram-lines {
+ display: flex;
+ width: 96px;
+ margin-bottom: 12px;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.hex-line {
+ position: relative;
+ display: flex;
+ width: 96px;
+ height: 9px;
+ gap: 12px;
+}
+
+.hex-line span {
+ display: block;
+ height: 7px;
+ flex: 1;
+ background: var(--ink);
+ border-radius: 1px;
+}
+
+.hex-line--yang span:first-child {
+ flex-basis: 100%;
+}
+
+.hex-line--yang span:last-child {
+ display: none;
+}
+
+.hex-line--moving span {
+ background: var(--cinnabar);
+}
+
+.hex-line--moving::after {
+ position: absolute;
+ top: -5px;
+ right: -14px;
+ color: var(--cinnabar);
+ font-size: 10px;
+ font-weight: 800;
+ content: "动";
+}
+
+.hexagram-name {
+ font-family: var(--serif);
+ font-size: 24px;
+ font-weight: 700;
+}
+
+.hexagram-number {
+ color: var(--ink-faint);
+ font-size: 11px;
+}
+
+.transform-arrow {
+ color: var(--outline);
+ font-family: var(--serif);
+ font-size: 23px;
+ text-align: center;
+}
+
+.static-indicator {
+ display: inline-flex;
+ min-height: 28px;
+ width: max-content;
+ margin: 14px auto 0;
+ padding: 3px 10px;
+ align-items: center;
+ color: var(--success);
+ background: #e7eee6;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.content-section {
+ padding: 25px 0 0;
+}
+
+.section-heading {
+ display: flex;
+ margin-bottom: 12px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.section-heading h2,
+.section-heading h3 {
+ margin: 0;
+}
+
+.section-heading h2 {
+ font-size: 21px;
+}
+
+.classic-quote {
+ margin: 0;
+ padding: 18px 17px;
+ color: #30271e;
+ background: rgb(255 253 247 / 72%);
+ border: 1px solid var(--outline-soft);
+ border-left: 3px solid var(--ochre);
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
+ font-family: var(--serif);
+ font-size: 16px;
+ line-height: 2;
+}
+
+.plain-copy {
+ color: var(--ink-soft);
+ font-size: 15px;
+ line-height: 1.9;
+}
+
+.plain-copy p:last-child,
+.content-card p:last-child {
+ margin-bottom: 0;
+}
+
+.moving-card,
+.content-card,
+.error-card {
+ padding: 17px;
+ background: rgb(255 253 247 / 72%);
+ border: 1px solid var(--outline-soft);
+ border-radius: var(--radius-md);
+}
+
+.moving-card {
+ border-top: 3px solid var(--cinnabar);
+}
+
+.moving-card__label {
+ margin-bottom: 5px;
+ color: var(--cinnabar);
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: .12em;
+}
+
+.error-card {
+ margin-top: 18px;
+ background: #fff8f5;
+ border-color: #e4b9b3;
+}
+
+.error-card h3 {
+ color: var(--error);
+}
+
+.error-actions {
+ display: grid;
+ margin-top: 14px;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+}
+
+.divider {
+ height: 1px;
+ margin: 27px 0 0;
+ background: var(--outline-soft);
+ border: 0;
+}
+
+.interpretation-header {
+ margin-bottom: 20px;
+}
+
+.interpretation-header h1 {
+ max-width: none;
+ margin-bottom: 9px;
+ font-size: 30px;
+}
+
+.interpretation-meta {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ color: var(--ink-faint);
+ font-size: 12px;
+}
+
+.interpretation-lead {
+ margin: 0 -4px 20px;
+ padding: 20px;
+ background: var(--cinnabar-soft);
+ border-radius: var(--radius-md);
+ font-family: var(--serif);
+ font-size: 18px;
+ line-height: 1.8;
+}
+
+.reflection-list {
+ margin: 0;
+ padding-left: 1.25em;
+ color: var(--ink-soft);
+}
+
+.reflection-list li {
+ margin-bottom: 10px;
+ padding-left: 4px;
+}
+
+.question-list {
+ display: grid;
+ margin: 0;
+ padding: 0;
+ gap: 10px;
+ counter-reset: reflection;
+ list-style: none;
+}
+
+.question-list li {
+ display: grid;
+ padding: 14px;
+ grid-template-columns: 30px 1fr;
+ gap: 10px;
+ color: var(--ink-soft);
+ background: rgb(255 253 247 / 66%);
+ border: 1px solid var(--outline-soft);
+ border-radius: var(--radius-sm);
+ counter-increment: reflection;
+}
+
+.question-list li::before {
+ display: grid;
+ width: 28px;
+ height: 28px;
+ color: var(--cinnabar);
+ background: var(--cinnabar-soft);
+ border-radius: 50%;
+ content: counter(reflection, cjk-ideographic);
+ font-family: var(--serif);
+ font-size: 12px;
+ font-weight: 700;
+ place-items: center;
+}
+
+.small-step {
+ position: relative;
+ overflow: hidden;
+ padding: 20px;
+ color: #fffdf7;
+ background: #2e342d;
+ border-radius: var(--radius-md);
+}
+
+.small-step::after {
+ position: absolute;
+ right: -25px;
+ bottom: -42px;
+ width: 120px;
+ height: 120px;
+ border: 1px solid rgb(255 255 255 / 13%);
+ border-radius: 50%;
+ content: "";
+}
+
+.small-step__label {
+ margin-bottom: 8px;
+ color: #d8b18b;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: .12em;
+}
+
+.small-step p {
+ position: relative;
+ z-index: 1;
+ margin: 0;
+ font-family: var(--serif);
+ font-size: 17px;
+ line-height: 1.8;
+}
+
+.safety-note {
+ margin: 22px 0 0;
+ color: var(--ink-faint);
+ font-size: 12px;
+ line-height: 1.7;
+}
+
+.modal-backdrop {
+ position: fixed;
+ z-index: 40;
+ inset: 0;
+ display: flex;
+ padding: 18px;
+ align-items: flex-end;
+ justify-content: center;
+ background: rgb(31 27 22 / 48%);
+ backdrop-filter: blur(2px);
+ animation: fade-in 180ms ease both;
+}
+
+.modal-sheet {
+ width: min(100%, 430px);
+ max-height: min(88dvh, 720px);
+ padding: 12px 20px calc(20px + env(safe-area-inset-bottom));
+ overflow: auto;
+ background: var(--surface);
+ border: 1px solid rgb(255 255 255 / 60%);
+ border-radius: 24px 24px 18px 18px;
+ box-shadow: var(--shadow-raised);
+ animation: sheet-in 240ms cubic-bezier(.2, .8, .2, 1) both;
+}
+
+.modal-handle {
+ width: 42px;
+ height: 4px;
+ margin: 0 auto 16px;
+ background: var(--outline-soft);
+ border-radius: 999px;
+}
+
+.modal-header {
+ display: grid;
+ margin-bottom: 16px;
+ grid-template-columns: 1fr 48px;
+ align-items: start;
+ gap: 12px;
+}
+
+.modal-header h2 {
+ margin: 0;
+ font-size: 24px;
+}
+
+.modal-header p {
+ margin: 6px 0 0;
+ color: var(--ink-soft);
+ font-size: 13px;
+}
+
+.option-list {
+ display: grid;
+ gap: 10px;
+}
+
+.option-card {
+ display: grid;
+ width: 100%;
+ min-height: 80px;
+ padding: 14px;
+ grid-template-columns: 42px 1fr 22px;
+ align-items: center;
+ gap: 12px;
+ color: var(--ink);
+ background: var(--paper);
+ border: 1px solid var(--outline-soft);
+ border-radius: var(--radius-sm);
+ text-align: left;
+ cursor: pointer;
+}
+
+.option-card:hover {
+ border-color: var(--outline);
+ background: #f3ecdf;
+}
+
+.option-card__icon {
+ display: grid;
+ width: 42px;
+ height: 42px;
+ color: var(--cinnabar);
+ background: var(--cinnabar-soft);
+ border-radius: 50%;
+ font-family: var(--serif);
+ font-weight: 700;
+ place-items: center;
+}
+
+.option-card strong,
+.option-card small {
+ display: block;
+}
+
+.option-card small {
+ margin-top: 2px;
+ color: var(--ink-faint);
+ font-size: 11px;
+}
+
+.option-card svg {
+ width: 20px;
+ height: 20px;
+ fill: none;
+ stroke: var(--outline);
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.7;
+}
+
+.data-list {
+ margin: 16px 0;
+ padding: 15px 15px 15px 34px;
+ color: var(--ink-soft);
+ background: var(--paper);
+ border-radius: var(--radius-sm);
+ font-size: 13px;
+}
+
+.data-list li + li {
+ margin-top: 7px;
+}
+
+.consent-check {
+ display: grid;
+ min-height: 56px;
+ padding: 10px 0;
+ grid-template-columns: 24px 1fr;
+ align-items: start;
+ gap: 10px;
+ color: var(--ink-soft);
+ font-size: 13px;
+ cursor: pointer;
+}
+
+.consent-check input {
+ width: 20px;
+ height: 20px;
+ margin: 2px 0 0;
+ accent-color: var(--cinnabar);
+}
+
+.modal-actions {
+ display: grid;
+ margin-top: 14px;
+ gap: 8px;
+}
+
+.method-steps {
+ display: grid;
+ margin: 8px 0 18px;
+ padding: 0;
+ gap: 12px;
+ counter-reset: method;
+ list-style: none;
+}
+
+.method-steps li {
+ display: grid;
+ grid-template-columns: 34px 1fr;
+ gap: 11px;
+ color: var(--ink-soft);
+ counter-increment: method;
+}
+
+.method-steps li::before {
+ display: grid;
+ width: 30px;
+ height: 30px;
+ color: white;
+ background: var(--ochre);
+ border-radius: 50%;
+ content: counter(method);
+ font-size: 12px;
+ font-weight: 800;
+ place-items: center;
+}
+
+.score-table {
+ display: grid;
+ margin: 18px 0;
+ grid-template-columns: 1fr 1fr;
+ overflow: hidden;
+ border: 1px solid var(--outline-soft);
+ border-radius: var(--radius-sm);
+}
+
+.score-table div {
+ padding: 12px;
+ text-align: center;
+}
+
+.score-table div + div {
+ border-left: 1px solid var(--outline-soft);
+}
+
+.score-table strong,
+.score-table span {
+ display: block;
+}
+
+.score-table strong {
+ font-family: var(--serif);
+ font-size: 19px;
+}
+
+.score-table span {
+ color: var(--ink-faint);
+ font-size: 11px;
+}
+
+.loading-state {
+ display: grid;
+ min-height: 280px;
+ align-content: center;
+ justify-items: center;
+ text-align: center;
+}
+
+.loading-orbit {
+ position: relative;
+ width: 84px;
+ height: 84px;
+ margin-bottom: 22px;
+ border: 1px solid var(--outline-soft);
+ border-radius: 50%;
+}
+
+.loading-orbit::before {
+ position: absolute;
+ top: -4px;
+ left: 37px;
+ width: 9px;
+ height: 9px;
+ background: var(--cinnabar);
+ border-radius: 50%;
+ content: "";
+ transform-origin: 5px 46px;
+ animation: orbit 1.4s linear infinite;
+}
+
+.loading-orbit::after {
+ position: absolute;
+ inset: 25px 20px;
+ border-top: 5px solid var(--ink);
+ border-bottom: 5px solid var(--ink);
+ content: "";
+}
+
+@keyframes fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes sheet-in {
+ from { transform: translateY(24px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
+
+@keyframes orbit {
+ to { transform: rotate(360deg); }
+}
+
+.toast-region {
+ position: fixed;
+ z-index: 60;
+ right: 16px;
+ bottom: calc(20px + env(safe-area-inset-bottom));
+ left: 16px;
+ display: grid;
+ justify-items: center;
+ pointer-events: none;
+}
+
+.toast {
+ padding: 10px 14px;
+ color: white;
+ background: rgb(31 27 22 / 93%);
+ border-radius: 999px;
+ box-shadow: var(--shadow-soft);
+ font-size: 12px;
+ animation: toast-in 180ms ease both;
+}
+
+@keyframes toast-in {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@media (min-width: 560px) {
+ .prototype-stage {
+ display: grid;
+ min-height: 100vh;
+ padding: 28px;
+ place-items: start center;
+ }
+
+ .app-shell {
+ min-height: min(792px, calc(100vh - 56px));
+ border: 1px solid rgb(97 78 59 / 24%);
+ border-radius: 28px;
+ box-shadow: 0 28px 70px rgb(47 36 25 / 22%);
+ }
+
+ .screen,
+ .page {
+ min-height: calc(min(792px, 100vh - 56px) - 60px);
+ }
+}
+
+@media (max-width: 350px) {
+ .page {
+ padding-right: 16px;
+ padding-left: 16px;
+ }
+
+ .bottom-action {
+ margin-right: -16px;
+ margin-left: -16px;
+ padding-right: 16px;
+ padding-left: 16px;
+ }
+
+ .coin {
+ width: 76px;
+ height: 76px;
+ }
+
+ .line-progress-card {
+ grid-template-columns: 1fr 110px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ animation-duration: .01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: .01ms !important;
+ }
+}