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 ` +
+ +
${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 ` + `; + } + + 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}
+
+ +
+
+

本地白话

+ 本地内容 +
+

${copy.plain}

+
+ + ${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 ` +
+
+

${ai ? "辅助反思" : "离线阅读"}

+

从“回到起点”开始

+
+ ${ai ? "AI 生成" : "本地解读"} + ${data.primary.name}卦 · 第 ${data.primary.number} 卦 +
+
+ +

眼前更重要的,也许不是立刻选定一条路,而是辨认哪一步能让你重新获得稳定与判断力。

+ +
+

可以先观察

+ +
+ +
+

问问自己

+
    +
  1. 如果暂时不追求一次做对,我最想先确认的一个事实是什么?
  2. +
  3. 哪一种选择更容易保留回转余地,而不是把自己锁死?
  4. +
  5. 我可以向谁说明顾虑,并获得一条具体信息?
  6. +
+
+ +
+
+
可以试的一小步
+

在今天结束前,写下“已知事实、尚待确认、我的底线”各一条,再约一次不超过 20 分钟的信息沟通。

+
+
+ +

这是一种文化反思与文字整理,不是预测、诊断或替代你的判断。涉及医疗、法律、财务或人身安全时,请寻求相应专业支持。

+ +
+ +
+
`; + } + + function interpretationModal() { + return ` + `; + } + + function consentModal() { + return ` + `; + } + + function methodModal() { + return ` + `; + } + + 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; + } +}