소스 검색

feat: skill-study-report v0.1.0 学习复盘报告技能——48h多Agent采集(探测存在才扫)+三问追问(口述×证据关联)+skill-present级HTML PPT(全套lib独立复制)+Storage主/Gogs降级双通道+ZIP分发(07-git自检前置/凭据零暴露/状态门禁分别报告)

liuyuyang 1 주 전
커밋
dca0f9d3f6

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+node_modules/
+.DS_Store
+*.log
+.sts-probe.json
+out/

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 fmodecn
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 108 - 0
README.md

@@ -0,0 +1,108 @@
+# skill-study-report · 学习复盘报告技能 📊
+
+> **把一个学员近 48h 的全部 Agent 工作痕迹,变成一份可转发的 HTML 复盘报告 PPT**——自动采集 → 三问追问 → skill-present 级报告 → Storage+Gogs 双通道发布。
+> 装给任何 Agent(Claude Code / Codex / WorkBuddy / Trae / Hermes / OpenClaw / 元宝)都能跑。
+
+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
+
+## 解决什么
+
+培训/研修结束,学员两天里在 N 个 Agent 工具里干了大量活,但复盘要人肉截图拼 PPT。本技能一条流水线搞定:
+
+```
+采集(48h 跨工具全量扫描,探测存在才扫)
+  → 追问(三问:过去痛点 / 现在感受 / 未来方向;口述自动关联采集证据)
+  → 报告(≥10 屏 HTML PPT:数据全景/交付物墙/时刻线/三问/附录,数字动画+证据链)
+  → 发布(Storage 主 + Gogs 降级/并行 + ZIP 分发,状态门禁分别报告)
+```
+
+## 三分钟跑通
+
+```bash
+# 1. 安装自检(打印安装体检表:平台身份 / Git 账户 / Storage 能力)
+node skills/study-report/scripts/precheck.mjs
+
+# 2. 采集近 48h 全部 Agent 痕迹
+node skills/study-report/scripts/collect.mjs --hours 48 --out /tmp/study-report-collect.json
+
+# 3. 三问追问(用户不答可 --non-interactive,报告标注【待补充】)
+node skills/study-report/scripts/ask.mjs --collect /tmp/study-report-collect.json --answers /tmp/study-report-answers.json
+
+# 4. 生成报告(skill-present 全套 lib 独立复制 + 数据注入)
+node skills/study-report/scripts/build-report.mjs --collect /tmp/study-report-collect.json \
+  --answers /tmp/study-report-answers.json --name "姓名" --enterprise "企业名" --out /tmp/study-report-out
+
+# 5. 双通道发布(Storage 主 + Gogs 降级/并行 + ZIP)
+node skills/study-report/scripts/publish.mjs --dir /tmp/study-report-out --name "姓名"
+```
+
+发布结束打印三通道状态门禁(✅/🟡 各自独立,不伪造成功),交付两条 URL + ZIP 链接。
+
+## 安装(引用 07-git 自检)
+
+### Claude Code / Codex
+
+```bash
+git clone https://git.fmode.cn/fmode/skill-study-report.git
+cp -r skill-study-report/skills/study-report ~/.claude/skills/    # 或 ~/.codex/skills/
+```
+
+装完先跑 `precheck.mjs`:它按 **fmode-studio 07-git.md 已验证链路**自检——
+① 平台身份(`~/.fmode/config/user.json` 的 `fmodeApiToken`)→ ② Git 账户(云函数幂等开户+补发 token,忘密码不找回、重调即得)→ ③ Storage 能力(可选,缺省走 Gogs 降级)。
+任一环节 ❌ 按体检表提示走「对话式验证码登录」恢复后重跑。
+
+### WorkBuddy / Hermes / Trae / 其他
+
+```bash
+git clone https://git.fmode.cn/fmode/skill-study-report.git
+cp -r skill-study-report/skills/study-report <你的工具技能目录>/study-report
+```
+
+读 `skills/study-report/SKILL.md`(六段流程)按工具规范并入。本仓库同时发布 ZIP:`https://fmode.cn/downloads/skill-study-report.zip`(或发布脚本输出的 Storage 直链),解压后把 `skills/study-report` 复制进技能目录即可。
+
+## 一键转发话术(复制发给学员的 Agent)
+
+```text
+请安装并执行 study-report 技能,为我生成近 48h 学习复盘报告:
+
+1. 下载安装包:https://fmode.cn/downloads/skill-study-report.zip
+   (若上面 404,用发布时给出的 Storage 直链;或 git clone https://git.fmode.cn/fmode/skill-study-report.git)
+2. 解压后把 skills/study-report 复制到你的技能目录
+3. 按顺序执行 SKILL.md 六段流程:
+   precheck.mjs(安装自检)→ collect.mjs(48h 采集)→ ask.mjs(三问向我追问,逐问等我回答)
+   → build-report.mjs(生成报告)→ publish.mjs(Storage+Gogs 双通道发布)
+4. 我的姓名是【____】,企业是【____】。发布完成后把全部 URL 发给我。
+注意:全程中文;凭据零暴露;任何环节失败如实报告,不要伪造成功。
+```
+
+## 报告结构(≥10 屏)
+
+封面(姓名/企业/日期)→ 48h 数据全景(会话数/工具分布/产出物,数字动画)→ 交付物墙(HTML 用 iframe 嵌套展示;在线项目明确标记可嵌)→ 精选工作时刻线 → 过去痛点(第一问)→ 两天感受(第二问+关联素材)→ 未来方向(第三问)→ 数据附录(可溯源明细)→ 收口金句。
+视觉/播放体系为 **skill-present 全套 lib 独立复制**(deck.js / canvas-scale / tokens+base 主题 / gsap,逐文件校验复制——v2.4.1 base.css 缺失教训),`file://` 双击可开。
+
+## 凭据纪律
+
+- `fmodeApiToken` / Gogs token / OBS AK/SK **零暴露**:运行时读取,输出只见尾 4 位;绝不入仓库/日志/报告
+- Storage 凭据复用 [skill-storage](https://git.fmode.cn/fmode/skill-storage) 诚实 4 级链;缺省不阻塞(Gogs 降级)
+- 状态门禁分别报告:Storage/Gogs/ZIP 三通道独立 ✅/🟡;**不伪造任何采集与发布结果**
+
+## FAQ
+
+**Q:学员机器上没有任何 OBS 配置能跑吗?**
+能。precheck 环节 3 是 🟡 不阻塞,报告走 Gogs 降级通道;两个通道都失败时给本地文件路径 + 手动上传指引。
+
+**Q:学员不回答三问怎么办?**
+报告照常生成,对应页标注【待补充:用户口述】,采集数据先行。`ask.mjs --non-interactive` 可完全跳过追问。
+
+**Q:没装过 Codex/Trae 等工具会不会报错?**
+不会。所有工具路径**探测存在才扫**,未检出的工具列入「待确认」清单提示,不阻塞。
+
+**Q:报告里的数字真实吗?**
+全部来自本机采集清单(`report-data.json` 随报告归档),无任何估算或编造;采集不到就显示 0 并注明。
+
+**Q:ZIP 的 fmode.cn/downloads/ 链接 404?**
+CDN 源站映射未配置时,发布脚本会自动探测并回退 Storage 直链——以 `publish.mjs` 实际输出为准。
+
+## License
+
+MIT

+ 21 - 0
package.json

@@ -0,0 +1,21 @@
+{
+  "name": "skill-study-report",
+  "version": "0.1.0",
+  "description": "学习复盘报告技能:48h 多 Agent 采集 → 三问追问 → skill-present 级 HTML PPT 报告 → Storage+Gogs 双通道发布",
+  "type": "module",
+  "private": true,
+  "scripts": {
+    "precheck": "node skills/study-report/scripts/precheck.mjs",
+    "collect": "node skills/study-report/scripts/collect.mjs",
+    "ask": "node skills/study-report/scripts/ask.mjs",
+    "build": "node skills/study-report/scripts/build-report.mjs",
+    "publish": "node skills/study-report/scripts/publish.mjs"
+  },
+  "engines": { "node": ">=18" },
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/fmodecn/skill-study-report.git"
+  },
+  "homepage": "https://github.com/fmodecn/skill-study-report#readme"
+}

+ 90 - 0
skills/study-report/SKILL.md

@@ -0,0 +1,90 @@
+---
+name: study-report
+description: "学习复盘报告:自动采集近 48h 全部 Agent 工作痕迹(Claude Code/Codex/Trae/WorkBuddy/OpenClaw/元宝/Hermes/工作区)→ 三问追问感受 → 生成 skill-present 级 HTML 复盘 PPT → Storage+Gogs 双通道发布。适用:(1) 培训/研修结营复盘 (2) 数字生命工作总结 (3) 一键转发给学员 Agent 代跑全流程。"
+description_en: "Learning retrospective: auto-collect 48h of all Agent work traces, ask the user three reflection questions, build a skill-present-grade HTML PPT report, and publish via Storage+Gogs dual channels. Triggers: training retrospective, agent work summary, forwardable one-click skill."
+---
+
+# Study Report — 学习复盘报告技能
+
+> 装给任何 Agent(Claude Code / Codex / WorkBuddy / Trae / Hermes / OpenClaw / 元宝):
+> **自动采集近两天全部 Agent 工作痕迹 → 追问用户感受 → 生成 HTML 复盘报告 PPT → Storage+Gogs 双通道发布。**
+
+## 六段流程(严格按序执行)
+
+### 第 0 段 · 安装前置自检(borrow 07-git.md 已验证链路)
+
+```bash
+node <skill_dir>/scripts/precheck.mjs
+```
+
+按序自检三环节,打印「安装体检表」,任一环节不通按提示走恢复流程:
+
+1. **平台身份**:读 `~/.fmode/config/user.json` 的 **fmodeApiToken**(真实字段名,非 sessionToken)→ HEAD 探测平台可达
+2. **Git 账户**(幂等,07-git.md 已验证链路):`POST server.fmode.cn/api/functions {token, path:"/gogs/admin/proxy", params:{method:"POST", path:"/admin/users", body:{...}}}` → **201 新建 / 200 幂等返回新 token.sha1**(忘密码不找回,重调即得);401/209 → 走「对话式验证码登录」恢复(手机号 → VerifyCode → 换 sessionToken)
+3. **Storage 能力**:探测 obsutil config / OBS_AK 环境变量;无 → **不阻塞**(报告走 Gogs 降级通道),提示可选配置
+
+### 第 1 段 · 采集(近 48h 全量扫描,跨 Agent 工具)
+
+```bash
+node <skill_dir>/scripts/collect.mjs --hours 48 --out /tmp/study-report-collect.json
+```
+
+所有工具路径**探测存在才扫**:Claude Code(`~/.claude/projects/*/*.jsonl`)、Codex、WorkBuddy、Trae、OpenClaw、元宝、Hermes Agent(`~/.fmode-harness-agent/`)、工作区产出物(`~/projects`、`~/git-repos`、`~/Desktop` 48h 内 .md/.html/.pdf/.docx/.xlsx)。
+
+产出**采集清单**(每条:来源工具/时间/类型/摘要/产出物路径/URL)——**先给用户看**,缺哪类工具提示用户确认路径,不阻塞。
+
+### 第 2 段 · 追问(采集完成后,三轮固定问题)
+
+```bash
+node <skill_dir>/scripts/ask.mjs --collect /tmp/study-report-collect.json --answers /tmp/study-report-answers.json
+```
+
+- **第一问·过去**:企业之前 AI 落地**不够原生、不够性感**的地方?痛点或疑惑?
+- **第二问·现在**:这两天**具体感受与收获**?(成果已从采集拿到,只问感受 + 哪个项目有感觉)
+- **第三问·未来**:学到的细节之后,**想回企业落地或探索的方向**?
+
+口述与采集素材自动**关联标记**(说到某项目 → 引用该 session/文件证据)。**用户不答 → 用采集数据先行出报告,标注【待补充:用户口述】**,不阻塞。
+
+### 第 3 段 · 报告(HTML PPT,skill-present 标准)
+
+```bash
+node <skill_dir>/scripts/build-report.mjs --collect /tmp/study-report-collect.json \
+  --answers /tmp/study-report-answers.json --name "姓名" --enterprise "企业名" --out /tmp/study-report-out
+```
+
+≥10 屏结构:封面(姓名/企业/日期)→ 48h 数据全景(数字动画/工具分布)→ 交付物墙(HTML 用 iframe 嵌套,在线项目标记可嵌)→ 精选工作时刻线 → 过去痛点 → 两天感受(关联素材)→ 未来方向 → 数据附录 → 收口金句。
+**lib 全套独立复制**(deck.js/tokens/base.css/canvas-scale/gsap 逐文件校验,v2.4.1 base.css 缺失教训);数据优先/证据链/来源标注。
+
+### 第 4 段 · 发布(双通道)
+
+```bash
+node <skill_dir>/scripts/publish.mjs --dir /tmp/study-report-out --name "姓名" \
+  --gogs-token <precheck所得token> --gogs-user <precheck所得用户名>
+```
+
+1. **Storage(主)**:`uploader.mjs put` → `study-report/<姓名>-<日期>/report.html` → 公开 URL(上传后 HEAD 验证)
+2. **Gogs(降级/并行)**:自检 token 建 `study-report-<姓名拼音>` 仓 → push 报告+素材(ls-remote 验证 SHA)
+3. **ZIP**:打包 → Storage `downloads/skill-study-report.zip`(CDN 映射探测,未映射给 Storage 直链)
+4. **状态门禁分别报告**:Storage/Gogs/ZIP 三个独立状态;Storage 缺权限走 Gogs,Gogs 也失败 → 本地文件路径 + 手动上传指引。**不伪造任何发布结果。**
+
+### 第 5 段 · 交付话术
+
+发布完成后把两条 URL(Storage + Gogs)和 ZIP 链接都给用户,附 README 中的「一键转发话术」。
+
+## 凭据纪律(零暴露)
+
+- fmodeApiToken / Gogs token / STS / AK/SK **只运行时读取,绝不写入仓库、日志、报告或对话**
+- precheck/publish 输出只显示 token 尾 4 位
+- 不伪造任何采集/发布结果;失败如实报告并给恢复指引
+
+## 故障对照
+
+| 症状 | 根因 | 对策 |
+|---|---|---|
+| precheck 环节1 ❌ | user.json 无 fmodeApiToken | 登录 FMODE Studio 保存配置,或对话式验证码登录 |
+| precheck 环节2 401/209 | sessionToken 失效 | 对话式验证码登录恢复(07-git.md 标准节) |
+| precheck 环节3 🟡 | 无 OBS 凭据 | 不阻塞——Gogs 降级通道;可选按 skill-storage init 向导配置 |
+| collect 全 0 条 | 工具路径未覆盖 | 用 `STUDY_REPORT_EXTRA_SCAN_ROOT` 追加扫描根;确认工具安装路径 |
+| build-report 报 lib 缺失 | 安装不完整 | 重新克隆仓库(lib 逐文件校验会精确报缺哪个) |
+| publish Storage 🟡 | 凭据链 4 级全空 | 走 Gogs 通道;两者都失败给本地路径+手动上传指引 |
+| CDN ZIP 404 | fmode.cn/downloads/ 源站未映射 | 用返回的 Storage 直链;官方映射后自动生效 |

+ 122 - 0
skills/study-report/lib/components/chat-window.css

@@ -0,0 +1,122 @@
+/* ============================================================
+ * lib/components/chat-window.css · 仿微信一对一聊天窗
+ * 只使用 tokens.css 的 --chat-* 与 --bg-*/--text-*/--radius-*,双主题自适应。
+ * ============================================================ */
+chat-window { display: block; width: 100%; }
+chat-window .cw {
+  max-width: 400px;
+  margin: 0 auto;
+  background: var(--chat-bg);
+  border: 1px solid var(--border-strong);
+  border-radius: var(--radius-lg);
+  overflow: hidden;
+  font-family: var(--font-body);
+  font-size: 14px;
+  line-height: 1.55;
+  color: var(--text-2);
+}
+chat-window .cw-empty { padding: 24px; text-align: center; color: var(--text-4); }
+
+/* 顶部条 */
+chat-window .cw-bar {
+  padding: 10px 14px;
+  background: var(--chat-bar-bg);
+  border-bottom: 1px solid var(--border-subtle);
+  text-align: center;
+  color: var(--chat-bar-text);
+  font-size: 13px;
+  letter-spacing: .05em;
+}
+chat-window .cw-bar .cw-sub {
+  display: block;
+  font-family: var(--font-mono);
+  font-size: 11px;
+  opacity: .7;
+  letter-spacing: .08em;
+}
+
+/* 消息区 */
+chat-window .cw-body { padding: 14px 13px; display: flex; flex-direction: column; gap: 7px; }
+
+chat-window .cw-time {
+  text-align: center;
+  color: var(--chat-time);
+  font-size: 11px;
+  font-family: var(--font-mono);
+  margin-bottom: 4px;
+}
+chat-window .cw-divider {
+  text-align: center;
+  color: var(--chat-time);
+  font-size: 11px;
+  margin: 6px 0;
+  letter-spacing: .1em;
+}
+
+/* 一条消息 */
+chat-window .cw-msg {
+  display: flex;
+  gap: 8px;
+  align-items: flex-end;
+  max-width: 100%;
+}
+chat-window .cw-msg.out { flex-direction: row-reverse; }
+
+chat-window .cw-ava {
+  flex: 0 0 32px;
+  height: 32px;
+  border-radius: 50%;
+  background: var(--chat-avatar-bg);
+  color: var(--chat-avatar-text);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+chat-window .cw-bub {
+  max-width: 74%;
+  padding: 6px 11px;
+  border-radius: 2px 8px 8px 8px;
+  background: var(--chat-in-bg);
+  color: var(--chat-in-text);
+  word-break: break-word;
+}
+chat-window .cw-msg.out .cw-bub {
+  border-radius: 8px 2px 8px 8px;
+  background: var(--chat-out-bg);
+  color: var(--chat-out-text);
+}
+chat-window .cw-bub b { font-weight: 700; }
+
+/* 转账卡 */
+chat-window .cw-bub-transfer {
+  border: 1px solid var(--chat-card-border);
+  background: var(--chat-card-bg);
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+chat-window .cw-tf-label { color: var(--chat-link); font-weight: 700; font-size: 12px; }
+chat-window .cw-tf-amt { font-size: 17px; font-weight: 700; color: var(--brand-ok); }
+
+/* 图片消息 */
+chat-window .cw-img { width: 100%; border-radius: 6px; }
+chat-window .cw-cap { margin-top: 4px; font-size: 12px; color: var(--chat-time); }
+
+/* 引语 */
+chat-window .cw-bub-quote { border-left: 3px solid var(--chat-link); }
+chat-window .cw-q-src { margin-top: 4px; font-size: 11px; color: var(--chat-time); }
+
+/* 底部条 */
+chat-window .cw-foot {
+  padding: 8px 14px;
+  background: var(--chat-bar-bg);
+  border-top: 1px solid var(--border-subtle);
+  color: var(--chat-time);
+  font-size: 11px;
+  font-family: var(--font-mono);
+  text-align: center;
+  letter-spacing: .06em;
+}

+ 95 - 0
skills/study-report/lib/components/chat-window.js

@@ -0,0 +1,95 @@
+/* ============================================================
+ * lib/components/chat-window.js · 仿微信一对一聊天窗
+ * 用法:
+ *   <link rel="stylesheet" href=".../lib/components/chat-window.css">
+ *   <chat-window data-chat='{...}'></chat-window>          // 短数据
+ *   <script type="application/json" id="chat-x"> {...} </script>
+ *   <chat-window data-ref="#chat-x"></chat-window>         // 长数据
+ * 消息类型:text / transfer(转账卡) / time(时间) / divider(分隔) / image / quote
+ * 全部颜色来自 tokens.css 的 --chat-*(双主题自适应)。
+ * ============================================================ */
+(function () {
+  function esc(s) {
+    return String(s == null ? '' : s)
+      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+  }
+  function nl(s) {
+    return esc(s).replace(/\n/g, '<br>');
+  }
+
+  function parse(el) {
+    if (el.dataset.chat) { try { return JSON.parse(el.dataset.chat); } catch (e) { return null; } }
+    if (el.dataset.ref) {
+      var src = document.querySelector(el.dataset.ref);
+      if (src) { try { return JSON.parse(src.textContent); } catch (e) { return null; } }
+    }
+    return null;
+  }
+
+  function renderMsg(m) {
+    var side = m.side === 'out' ? 'out' : 'in';
+    if (m.type === 'time') {
+      return '<div class="cw-time">' + esc(m.text) + '</div>';
+    }
+    if (m.type === 'divider') {
+      return '<div class="cw-divider">' + esc(m.text) + '</div>';
+    }
+    // 带气泡的消息
+    var ava = '<div class="cw-ava">' + esc(m.avatar || (side === 'out' ? '我' : '')) + '</div>';
+    var inner = '';
+    if (m.type === 'transfer') {
+      inner = '<div class="cw-bub cw-bub-transfer">'
+        + '<span class="cw-tf-label">' + esc(m.text || '微信转账') + '</span>'
+        + '<span class="cw-tf-amt">' + esc(m.amount || '') + '</span>'
+        + '</div>';
+    } else if (m.type === 'image') {
+      inner = '<div class="cw-bub"><img class="cw-img" src="' + esc(m.src) + '" alt="">'
+        + (m.caption ? '<div class="cw-cap">' + esc(m.caption) + '</div>' : '')
+        + '</div>';
+    } else if (m.type === 'quote') {
+      inner = '<div class="cw-bub cw-bub-quote">' + nl(m.text)
+        + (m.source ? '<div class="cw-q-src">' + esc(m.source) + '</div>' : '')
+        + '</div>';
+    } else {
+      var body = nl(m.text);
+      if (m.bold) body = '<b>' + body + '</b>';
+      inner = '<div class="cw-bub">' + body + '</div>';
+    }
+    return '<div class="cw-msg ' + side + '">' + (side === 'in' ? ava + inner : inner + ava) + '</div>';
+  }
+
+  function render(el, d) {
+    if (!d) { el.innerHTML = '<div class="cw cw-empty">数据缺失</div>'; return; }
+    var msgs = (d.msgs || []).map(renderMsg).join('');
+    el.innerHTML =
+      '<div class="cw">' +
+        (d.title || d.subtitle ? (
+          '<div class="cw-bar">' + esc(d.title || '') +
+            (d.subtitle ? '<span class="cw-sub">' + esc(d.subtitle) + '</span>' : '') +
+          '</div>'
+        ) : '') +
+        '<div class="cw-body">' + msgs + '</div>' +
+        (d.footer ? '<div class="cw-foot">' + esc(d.footer) + '</div>' : '') +
+      '</div>';
+  }
+
+  var ChatWindow = function () {
+    return Reflect.construct(HTMLElement, [], ChatWindow);
+  };
+  ChatWindow.prototype = Object.create(HTMLElement.prototype);
+  ChatWindow.prototype.constructor = ChatWindow;
+  ChatWindow.prototype.connectedCallback = function () {
+    var self = this;
+    var d = parse(this);
+    if (!d && this.dataset.ref) {
+      // ref 指向的 JSON 可能晚于组件加载,延时再读一次
+      setTimeout(function () { render(self, parse(self)); }, 0);
+    } else {
+      render(this, d);
+    }
+  };
+
+  if (!customElements.get('chat-window')) {
+    customElements.define('chat-window', ChatWindow);
+  }
+})();

+ 122 - 0
skills/study-report/lib/components/group-chat.css

@@ -0,0 +1,122 @@
+/* ============================================================
+ * lib/components/group-chat.css · 仿企业微信电脑端群聊窗
+ * 布局:顶部群名条 + 左成员列表 + 右消息流(电脑端横版)。
+ * 颜色全部来自 tokens.css 的 --chat-* / --brand-*,双主题自适应。
+ * ============================================================ */
+group-chat { display: block; width: 100%; }
+group-chat .gc {
+  max-width: 720px;
+  margin: 0 auto;
+  background: var(--chat-bg);
+  border: 1px solid var(--border-strong);
+  border-radius: var(--radius-lg);
+  overflow: hidden;
+  font-family: var(--font-body);
+  font-size: 14px;
+  line-height: 1.55;
+  color: var(--text-2);
+}
+group-chat .gc-empty { padding: 24px; text-align: center; color: var(--text-4); }
+
+/* 顶部群名条 */
+group-chat .gc-bar {
+  padding: 10px 16px;
+  background: var(--chat-bar-bg);
+  border-bottom: 1px solid var(--border-subtle);
+  text-align: center;
+  color: var(--chat-bar-text);
+  font-size: 14px;
+  letter-spacing: .05em;
+  font-weight: 700;
+}
+group-chat .gc-bar .gc-sub {
+  display: block;
+  font-family: var(--font-mono);
+  font-size: 11px;
+  font-weight: 400;
+  opacity: .7;
+  letter-spacing: .08em;
+}
+
+/* 主体:左成员 + 右消息 */
+group-chat .gc-main { display: flex; min-height: 260px; }
+
+group-chat .gc-members {
+  flex: 0 0 150px;
+  border-right: 1px solid var(--border-subtle);
+  padding: 10px 8px;
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  background: var(--bg-1);
+}
+group-chat .gc-mem {
+  display: flex; align-items: center; gap: 8px;
+  padding: 5px 6px;
+  border-radius: var(--radius-sm);
+}
+group-chat .gc-mem:hover { background: var(--bg-2); }
+group-chat .gc-mem-info { min-width: 0; }
+group-chat .gc-mem-name { font-size: 13px; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+group-chat .gc-mem-role { font-size: 10px; color: var(--brand-2); font-family: var(--font-mono); }
+
+/* 右侧消息流 */
+group-chat .gc-msgs { flex: 1; padding: 14px; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; max-height: 500px; }
+
+group-chat .gc-msg { display: flex; gap: 10px; }
+group-chat .gc-ava {
+  flex: 0 0 34px; height: 34px;
+  border-radius: 6px;
+  background: var(--chat-avatar-bg);
+  color: var(--chat-avatar-text);
+  display: flex; align-items: center; justify-content: center;
+  font-size: 13px; font-weight: 700;
+}
+group-chat .gc-box { max-width: 78%; }
+group-chat .gc-name { font-size: 12px; color: var(--brand-2); margin-bottom: 2px; }
+group-chat .gc-text {
+  display: inline-block;
+  background: var(--chat-in-bg);
+  color: var(--chat-in-text);
+  padding: 6px 11px;
+  border-radius: 2px 8px 8px 8px;
+  word-break: break-word;
+}
+group-chat .gc-at {
+  color: var(--brand-1);
+  font-weight: 600;
+  border-radius: 3px;
+  background: var(--chat-card-bg);
+  padding: 0 3px;
+}
+
+/* 名片卡 */
+group-chat .gc-card {
+  border: 1px solid var(--chat-card-border);
+  background: var(--chat-card-bg);
+  border-radius: var(--radius-md);
+  padding: 9px 12px;
+  display: inline-block;
+  max-width: 100%;
+}
+group-chat .gc-card-t { color: var(--chat-link); font-weight: 700; font-size: 13px; }
+group-chat .gc-card-s { color: var(--text-3); font-size: 12px; margin-top: 2px; }
+group-chat .gc-card-g { color: var(--brand-ok); font-size: 11px; margin-top: 4px; font-family: var(--font-mono); }
+
+/* 底部 */
+group-chat .gc-foot {
+  padding: 8px 16px;
+  background: var(--chat-bar-bg);
+  border-top: 1px solid var(--border-subtle);
+  color: var(--chat-time);
+  font-size: 11px;
+  font-family: var(--font-mono);
+  text-align: center;
+  letter-spacing: .06em;
+}
+
+/* 收窄版(放入 .s-side 竖形素材列时用,收起左成员栏只留气泡流) */
+group-chat.gc-narrow .gc-members { display: none; }
+group-chat.gc-narrow .gc-main { min-height: auto; }
+group-chat.gc-narrow .gc-msgs { max-height: clamp(320px, 55vh, 520px); overflow-y: auto; padding: 12px; }
+group-chat.gc-narrow .gc-bar { padding: 8px 12px; font-size: 13px; }

+ 105 - 0
skills/study-report/lib/components/group-chat.js

@@ -0,0 +1,105 @@
+/* ============================================================
+ * lib/components/group-chat.js · 仿企业微信电脑端群聊窗
+ * 用法:
+ *   <link rel="stylesheet" href=".../lib/components/group-chat.css">
+ *   <group-chat data-chat='{...}'></group-chat>
+ * JSON:{ group, subtitle, members:[{name,avatar,role}],
+ *         msgs:[{name,avatar,type:'text'|'card', text, at:[names], title, sub, tag}], footer }
+ * 消息文本内 @成员名 自动高亮为提及标签。
+ * 颜色全部来自 tokens.css 的 --chat-* / --brand-*(双主题自适应)。
+ * ============================================================ */
+(function () {
+  function esc(s) {
+    return String(s == null ? '' : s)
+      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+  }
+  function nl(s) {
+    return esc(s).replace(/\n/g, '<br>');
+  }
+
+  function parse(el) {
+    if (el.dataset.chat) { try { return JSON.parse(el.dataset.chat); } catch (e) { return null; } }
+    if (el.dataset.ref) {
+      var src = document.querySelector(el.dataset.ref);
+      if (src) { try { return JSON.parse(src.textContent); } catch (e) { return null; } }
+    }
+    return null;
+  }
+
+  // 把文本里的 @成员名 渲染成高亮提及
+  function mention(text, members) {
+    var s = esc(text);
+    members.forEach(function (m) {
+      var name = esc(m.name);
+      if (!name) return;
+      var re = new RegExp('(@' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')(?![^<]*>)', 'g');
+      s = s.replace(re, '<span class="gc-at">$1</span>');
+    });
+    return s;
+  }
+
+  function renderMsg(m, members) {
+    var text = '';
+    if (m.type === 'card') {
+      text = '<div class="gc-card">'
+        + (m.title ? '<div class="gc-card-t">' + esc(m.title) + '</div>' : '')
+        + (m.sub ? '<div class="gc-card-s">' + esc(m.sub) + '</div>' : '')
+        + (m.tag ? '<div class="gc-card-g">' + esc(m.tag) + '</div>' : '')
+        + '</div>';
+    } else {
+      text = '<div class="gc-text">' + mention(m.text, members) + '</div>';
+    }
+    return '<div class="gc-msg">'
+      + '<span class="gc-ava">' + esc(m.avatar || (m.name || '?').slice(0, 1)) + '</span>'
+      + '<div class="gc-box">'
+      +   '<div class="gc-name">' + esc(m.name || '') + '</div>'
+      +   text
+      + '</div>'
+      + '</div>';
+  }
+
+  function render(el, d) {
+    if (!d) { el.innerHTML = '<div class="gc gc-empty">数据缺失</div>'; return; }
+    var members = d.members || [];
+    var memHTML = members.map(function (m) {
+      return '<div class="gc-mem">'
+        + '<span class="gc-ava">' + esc(m.avatar || (m.name || '?').slice(0, 1)) + '</span>'
+        + '<div class="gc-mem-info">'
+        +   '<div class="gc-mem-name">' + esc(m.name || '') + '</div>'
+        +   (m.role ? '<div class="gc-mem-role">' + esc(m.role) + '</div>' : '')
+        + '</div>'
+        + '</div>';
+    }).join('');
+    var msgs = (d.msgs || []).map(function (m) { return renderMsg(m, members); }).join('');
+    el.innerHTML =
+      '<div class="gc">' +
+        '<div class="gc-bar">' + esc(d.group || '') +
+          (d.subtitle ? '<span class="gc-sub">' + esc(d.subtitle) + '</span>' : '') +
+        '</div>' +
+        '<div class="gc-main">' +
+          '<div class="gc-members">' + memHTML + '</div>' +
+          '<div class="gc-msgs">' + msgs + '</div>' +
+        '</div>' +
+        (d.footer ? '<div class="gc-foot">' + esc(d.footer) + '</div>' : '') +
+      '</div>';
+  }
+
+  var GroupChat = function () {
+    return Reflect.construct(HTMLElement, [], GroupChat);
+  };
+  GroupChat.prototype = Object.create(HTMLElement.prototype);
+  GroupChat.prototype.constructor = GroupChat;
+  GroupChat.prototype.connectedCallback = function () {
+    var self = this;
+    var d = parse(this);
+    if (!d && this.dataset.ref) {
+      setTimeout(function () { render(self, parse(self)); }, 0);
+    } else {
+      render(this, d);
+    }
+  };
+
+  if (!customElements.get('group-chat')) {
+    customElements.define('group-chat', GroupChat);
+  }
+})();

+ 40 - 0
skills/study-report/lib/components/logic-flow.css

@@ -0,0 +1,40 @@
+/* ============================================================
+ * lib/components/logic-flow.css · 逻辑图组件样式
+ * 全部 var(--...) token,双主题自适应。零硬编码色值。
+ * ============================================================ */
+logic-flow {
+  display: block;
+  width: 100%;
+  /* 纵向高度预算:TB 方向逻辑图不撑出 .s-wrap,被 .screen{overflow:hidden} 裁掉。
+   * 页面可用 style="--lf-max-h:46vh" 按需覆盖。 */
+  --lf-max-h: 54vh;
+}
+
+logic-flow .logic-flow svg {
+  display: block;
+  margin: 0 auto;
+  max-width: 100%;
+  max-height: var(--lf-max-h);
+}
+
+/* mermaid htmlLabels 的 foreignObject 标签文字随 token 字体 */
+logic-flow .logic-flow .nodeLabel,
+logic-flow .logic-flow .edgeLabel {
+  font-family: var(--font-body);
+}
+
+/* 降级 / 报错提示(mermaid 缺失或渲染失败时) */
+logic-flow .lf-degraded,
+logic-flow .lf-error {
+  font-family: var(--font-mono);
+  font-size: clamp(11px, 1vw, 14px);
+  color: var(--text-3);
+  white-space: pre-wrap;
+  padding: clamp(10px, 1.4vw, 18px);
+  border: 1px dashed var(--border-subtle);
+  border-radius: var(--radius-card);
+  background: var(--bg-2);
+}
+
+/* 报错用警示红 */
+logic-flow .lf-error { color: var(--brand-3); border-color: var(--border-danger); }

+ 181 - 0
skills/study-report/lib/components/logic-flow.js

@@ -0,0 +1,181 @@
+/* ============================================================
+ * lib/components/logic-flow.js · 逻辑图组件(mermaid 封装)
+ *
+ * 契约:一行标签 + 一段 JSON,file:// 可用,零 fetch 零 module。
+ *   <logic-flow data-json='{ "dir":"LR", "nodes":[...], "edges":[...] }'>
+ *   <logic-flow data-mermaid='flowchart LR ...'>(进阶:直接写 mermaid 原文)
+ *
+ * 节点 cls 可选值:warn(红)/ ok(绿)/ brand(金)/ sub(紫)/ plain(灰)
+ * 颜色全部读取 tokens.css 的 var(--...)(getComputedStyle),不硬编码,
+ * 因此暗/浅主题切换自动生效(MutationObserver 监听 html[data-theme] 重绘)。
+ * mermaid 需先于本脚本加载(lib/vendor/mermaid/mermaid.min.js);缺失时降级显示原文。
+ * ============================================================ */
+(function () {
+  if (window.customElements && customElements.get('logic-flow')) return;
+
+  function cssVar(name) {
+    var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+    return v || '#888888';
+  }
+
+  /* 两个 hex 色按比例混合(mermaid classDef 不支持 rgba 小数 alpha,用实体 hex 表达 tint) */
+  function mix(hex1, hex2, r) {
+    function p(h) { return [parseInt(h.substr(1, 2), 16), parseInt(h.substr(3, 2), 16), parseInt(h.substr(5, 2), 16)]; }
+    function toHex(n) { var s = Math.round(n).toString(16); return s.length === 1 ? '0' + s : s; }
+    var a = p(hex1), b = p(hex2);
+    return '#' + a.map(function (c, i) { return toHex(c * (1 - r) + b[i] * r); }).join('');
+  }
+
+  function esc(s) {
+    return String(s).replace(/"/g, '&quot;').replace(/\n/g, '<br/>');
+  }
+
+  /* 结构化 JSON → mermaid flowchart 文本 */
+  function buildMermaid(json) {
+    var dir = json.dir || 'LR';
+    var lines = ['flowchart ' + dir];
+    (json.nodes || []).forEach(function (n) {
+      var cls = n.cls ? ':::' + n.cls : '';
+      lines.push('  ' + n.id + '["' + esc(n.label) + '"]' + cls);
+    });
+    (json.edges || []).forEach(function (e) {
+      var label = e.label ? '|"' + esc(e.label) + '"|' : '';
+      var arr = e.dotted ? '-.->' : '-->';
+      lines.push('  ' + e.from + ' ' + arr + label + ' ' + e.to);
+    });
+    return lines.join('\n');
+  }
+
+  function readTokens() {
+    return {
+      bg0: cssVar('--bg-0'), bg1: cssVar('--bg-1'), bg2: cssVar('--bg-2'), bg3: cssVar('--bg-3'),
+      text1: cssVar('--text-1'), text2: cssVar('--text-2'), text3: cssVar('--text-3'),
+      brand1: cssVar('--brand-1'), brand2: cssVar('--brand-2'), brand3: cssVar('--brand-3'),
+      ok: cssVar('--brand-ok'), border: cssVar('--border-subtle'),
+      fontBody: cssVar('--font-body'), fontMono: cssVar('--font-mono')
+    };
+  }
+
+  function initMermaid(t) {
+    window.mermaid.initialize({
+      startOnLoad: false,
+      theme: 'base',
+      securityLevel: 'loose',
+      htmlLabels: true,
+      flowchart: { curve: 'basis', padding: 14, nodeSpacing: 40, rankSpacing: 52, useMaxWidth: true },
+      themeVariables: {
+        background: t.bg0,
+        primaryColor: t.bg2,
+        primaryTextColor: t.text1,
+        primaryBorderColor: t.brand1,
+        secondaryColor: t.bg1,
+        secondaryTextColor: t.text2,
+        secondaryBorderColor: t.brand2,
+        tertiaryColor: t.bg3,
+        tertiaryTextColor: t.text2,
+        tertiaryBorderColor: t.brand3,
+        lineColor: t.brand1,
+        textColor: t.text2,
+        edgeLabelBackground: t.bg2,
+        fontFamily: t.fontBody,
+        fontSize: '15px',
+        clusterBkg: t.bg1,
+        clusterBorder: t.border
+      }
+    });
+  }
+
+  function classDefs(t) {
+    return [
+      'classDef warn fill:' + mix(t.brand3, t.bg2, .88) + ',stroke:' + t.brand3 + ',color:' + t.text1 + ',stroke-width:1.5px',
+      'classDef ok fill:' + mix(t.ok, t.bg2, .88) + ',stroke:' + t.ok + ',color:' + t.text1 + ',stroke-width:1.5px',
+      'classDef brand fill:' + mix(t.brand1, t.bg2, .86) + ',stroke:' + t.brand1 + ',color:' + t.text1 + ',stroke-width:1.5px',
+      'classDef sub fill:' + mix(t.brand2, t.bg2, .88) + ',stroke:' + t.brand2 + ',color:' + t.text1 + ',stroke-width:1.5px',
+      'classDef plain fill:' + t.bg2 + ',stroke:' + t.text3 + ',color:' + t.text2 + ',stroke-width:1px'
+    ].join('\n');
+  }
+
+  /* 读取组件可用的纵向高度预算(CSS 变量 --lf-max-h,支持 px/vh/%),默认 54vh。
+   * 防止 TB 方向逻辑图按宽等比后高度超过 .s-wrap 剩余空间,被 .screen{overflow:hidden} 裁掉。 */
+  function parseMaxH(el) {
+    var v = getComputedStyle(el).getPropertyValue('--lf-max-h').trim() || '54vh';
+    var m = v.match(/^([\d.]+)\s*(px|vh|%)$/);
+    if (!m) return 0.54 * window.innerHeight;
+    var n = parseFloat(m[1]);
+    if (m[2] === 'vh') return n / 100 * window.innerHeight;
+    if (m[2] === '%') return n / 100 * (el.clientHeight || window.innerHeight);
+    return n;
+  }
+
+  /* 按 viewBox 等比缩放 svg,同时满足容器宽与纵向预算(只缩不放) */
+  function fitSvg(svg) {
+    var vb = svg.viewBox && svg.viewBox.baseVal;
+    if (!vb || !vb.width || !vb.height) return;
+    var wrap = svg.parentElement;
+    var maxW = wrap.clientWidth || 600;
+    var maxH = parseMaxH(wrap);
+    var scale = Math.min(maxW / vb.width, maxH / vb.height);
+    if (scale > 1) scale = 1;                        // 只缩不放
+    svg.style.width = Math.round(vb.width * scale) + 'px';
+    svg.style.height = Math.round(vb.height * scale) + 'px';
+    svg.style.maxWidth = '100%';
+    svg.style.maxHeight = maxH + 'px';               // 兜底:CSS 也约束
+    svg.style.margin = '0 auto';
+  }
+
+  class LogicFlow extends HTMLElement {
+    connectedCallback() {
+      this._box = null;
+      this._timer = null;
+      this._observer = new MutationObserver(function () { this._schedule(); }.bind(this));
+      this._observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
+      this._fitHandler = function () { this._schedule(); }.bind(this);
+      window.addEventListener('resize', this._fitHandler);
+      this._schedule();
+    }
+    disconnectedCallback() {
+      if (this._observer) this._observer.disconnect();
+      if (this._timer) clearTimeout(this._timer);
+      if (this._fitHandler) window.removeEventListener('resize', this._fitHandler);
+    }
+    _schedule() {
+      // 主题切换防抖:等 mermaid 就绪
+      if (this._timer) clearTimeout(this._timer);
+      this._timer = setTimeout(function () { this._render(); }.bind(this), 30);
+    }
+    _text() {
+      var raw = this.getAttribute('data-mermaid');
+      if (raw) return raw;
+      try { return buildMermaid(JSON.parse(this.getAttribute('data-json') || '{}')); }
+      catch (e) { return null; }
+    }
+    _render() {
+      var text = this._text();
+      if (!this._box) {
+        this._box = document.createElement('div');
+        this._box.className = 'logic-flow';
+        this.innerHTML = '';
+        this.appendChild(this._box);
+      }
+      var target = this._box;
+      if (!window.mermaid || !text) {
+        target.innerHTML = '<pre class="lf-degraded">' + (text || this.getAttribute('data-json') || '') + '</pre>';
+        return;
+      }
+      initMermaid(readTokens());
+      target.innerHTML = '';
+      var full = text + '\n' + classDefs(readTokens());
+      window.mermaid.render('lf_' + Date.now().toString(36), full).then(function (res) {
+        target.innerHTML = res.svg;
+        var svg = target.querySelector('svg');
+        if (svg) fitSvg(svg);
+        this.dispatchEvent(new CustomEvent('logicflow-rendered', { detail: { ok: true } }));
+      }.bind(this)).catch(function (e) {
+        target.innerHTML = '<pre class="lf-error">逻辑图渲染失败:' + e.message + '</pre>';
+        this.dispatchEvent(new CustomEvent('logicflow-rendered', { detail: { ok: false } }));
+      }.bind(this));
+    }
+  }
+
+  customElements.define('logic-flow', LogicFlow);
+})();

+ 81 - 0
skills/study-report/lib/docs/components-api.md

@@ -0,0 +1,81 @@
+# components-api · 组件 API
+
+> 实现:`lib/components/*`。规则见 `.claude/rules/components.md`。
+
+## chat-window · 仿微信一对一
+
+```html
+<link rel="stylesheet" href="<相对>/lib/components/chat-window.css">
+<script src="<相对>/lib/components/chat-window.js"></script>
+<chat-window data-chat='{...}'></chat-window>        <!-- 短数据 -->
+<!-- 长数据 -->
+<script type="application/json" id="chat-x"> {...} </script>
+<chat-window data-ref="#chat-x"></chat-window>
+```
+
+JSON schema:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `title` / `subtitle` | string | 顶部条标题 / 副题 |
+| `msgs[]` | array | 消息 |
+| `footer` | string | 底部脚注 |
+
+`msgs[]` 元素(`side: 'in'|'out'`,`avatar` 头像文字):
+
+| type | 字段 | 渲染 |
+|---|---|---|
+| `time` | text | 居中时间 |
+| `text` | text, bold? | 气泡文本(\n 换行,bold 加粗) |
+| `transfer` | text, amount | 转账卡(金额绿色) |
+| `divider` | text | 剧情推进分隔线 |
+| `image` | src, caption? | 气泡内图片 |
+| `quote` | text, source? | 引语强调 |
+
+## group-chat · 仿企业微信电脑端群聊
+
+```html
+<group-chat data-chat='{...}'></group-chat>
+```
+
+JSON schema:`{ group, subtitle, members:[{name,avatar,role}], msgs:[...], footer }`
+
+`msgs[]` 元素:`{ name, avatar, type }`
+- `type:'text'`:`text`,文本内 `@成员名` 自动高亮提及
+- `type:'card'`:`title, sub, tag` 名片卡
+
+## logic-flow · 逻辑图(mermaid 封装)
+
+> 通用规则见 `.claude/rules/logic-diagram.md`。mermaid 需先加载:`<script src="<相对>/lib/vendor/mermaid/mermaid.min.js"></script>`
+
+```html
+<link rel="stylesheet" href="<相对>/lib/components/logic-flow.css">
+<script src="<相对>/lib/components/logic-flow.js"></script>
+<!-- 结构化 JSON(推荐) -->
+<logic-flow data-json='{
+  "dir": "LR",
+  "nodes": [ { "id": "hw", "label": "AI学习机\n几十亿研发", "cls": "warn" } ],
+  "edges": [ { "from": "hw", "to": "sink", "label": "用户落灰" } ]
+}'></logic-flow>
+<!-- 进阶:直接写 mermaid 原文 -->
+<logic-flow data-mermaid='flowchart LR
+  A["题库"] --> B["大模型"]'></logic-flow>
+```
+
+JSON schema:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `dir` | string | `LR`(左右)/ `TB`(上下),默认 LR |
+| `nodes[]` | array | `id` 节点 key、`label` 显示文本(`\n` 换行)、`cls` 节点配色 |
+| `edges[]` | array | `from`/`to` 节点 id、`label` 边标签、`dotted` 虚线 |
+
+`cls` 可选值(配色随 token,双主题自适应):`warn`(红·卡点/警示)/ `ok`(绿·解法/通过)/ `brand`(金·主角/核心)/ `sub`(紫·次级/支撑)/ `plain`(灰·默认)。
+
+特点:色全部读 `var(--*)`(getComputedStyle),`html[data-theme]` 变化自动重绘(MutationObserver);mermaid 缺失时降级显示原文。
+
+## 组件纪律
+
+- CSS 零硬编码色,只用 `var(--*)`(含 `--chat-*` 组件 token),双主题自适应。
+- 不依赖 fetch / module,file:// 可用。
+- 需要新组件:先在 `.claude/rules/components.md` 登记 → 定义 schema → 实现 → 在 smoke.html 冒烟。

+ 101 - 0
skills/study-report/lib/docs/deck-api.md

@@ -0,0 +1,101 @@
+# deck-api · 播放器 API
+
+> 实现:`lib/player/deck.js`。规则见 `.claude/rules/player.md`。
+
+## 页面接入
+
+```html
+<script src="<相对>/lib/vendor/gsap/gsap.min.js"></script>
+<script src="<相对>/lib/theme/theme.js"></script>
+<script src="<相对>/lib/player/deck.js"></script>
+<script>
+  window.SCREEN_TIMELINES = {
+    s2: function (ctx) {
+      var tl = gsap.timeline();
+      tl.from(ctx.q(".s-title"), { y: 20, opacity: 0, duration: .5, ease: "var(--ease-out)" });
+      return tl;                 // 必须返回 timeline
+    }
+  };
+  deck.init();
+</script>
+```
+
+## window.SCREEN_TIMELINES
+
+- 键 = 屏的 `data-tl` 属性值;值为 `function(ctx) -> gsap.timeline`。
+- `ctx`:`{ el, q(sel), qAll(sel), count(sel, to, opts) }`。
+  - `q/qAll` 只在屏内查询。
+  - `count` 数字滚动,返回 gsap tween,`tl.add(ctx.count(...), pos)` 使用;`opts: {to, duration, ease, dec, sep}`。
+
+## window.deck API
+
+| 方法 | 说明 |
+|---|---|
+| `deck.init()` | 初始化:收集 `.screen`、建导航、绑键盘。页面必须在 SCREEN_TIMELINES 定义后调用 |
+| `deck.init({ mode:'shell', planEl, planUrl })` | 总控容器模式(见下「总控容器模式」) |
+| `deck.next()` / `deck.prev()` | 下一屏 / 上一屏(门控) |
+| `deck.jump(i)` | 跳第 i 屏 |
+| `deck.nextChapter()` / `deck.prevChapter()` | 下一章 / 上一章(读 deck-config 的 next/prev) |
+| `deck.goFile(file, dir)` | 跳指定章节文件 |
+| `deck.showPage(i)` / `deck.loadPage(i)` | 总控模式:切到第 i 页 / 预加载第 i 页(iframe) |
+
+## 屏与门控
+
+- 屏:`<section class="screen" id="..." data-tl="key">`。无 `data-tl` 的屏 = 无动画屏,↓ 即切。
+- 视频门控:屏内 `<video data-gate src="...">`;`data-gate-autoplay` 进入即自动播;播放中再 ↓ 跳过。
+- 状态机:`idle → playing → done`;`↓` 在 idle 播放动画/视频,playing 忽略(防连击),done 才切屏。
+- 进度条与章节 tab 由 deck 渲染,状态机驱动(不依赖 scroll)。
+- gsap 缺失时 `data-tl` 屏直接 done,不白屏。
+
+## 总控容器模式(shell)
+
+> 把多个单页拼成一场演示:总控页 iframe 加载各页 + 预加载下一页(隐藏)+ 翻页切换可见性 →
+> 无白屏无缝衔接;iframe 不销毁,翻回保留状态;**导航顺序由编排配置决定,单页零改动可复用**。
+
+### 接入(总控页,如 `course/deck-shell.html`)
+
+```html
+<script type="application/json" class="deck-plan">
+{ "id": "cbec-full", "title": "跨境电商 · 完整版",
+  "pages": [
+    { "file": "cases/cbec/demo/p0-prologue.html", "title": "序 · 三个时代同时存在" },
+    { "file": "cases/cbec/demo/p1-west-board.html", "title": "01 · 欧美棋盘" }
+  ] }
+</script>
+<script src="<相对>/lib/theme/theme.js"></script>
+<script src="<相对>/lib/player/deck.js"></script>
+<script> deck.init({ mode: 'shell', planEl: '.deck-plan' }); </script>
+```
+
+### 编排配置(plan.json)
+
+- `pages[].file` **相对总控页**;`title` 用于顶栏 tab。不同编排 = 不同 plan.json,单页不改。
+- 示例:`course/plans/cbec-full.json`、`course/plans/health-fmc-full.json`。
+- 改 plan.json 后运行 `scripts/embed-deck-plan.js <plan.json> <总控页> --write` 重新内嵌(file:// 零 fetch)。
+- http 下可 `?plan=plans/health-fmc-full.json` 切换编排(file:// 自动回退内嵌)。
+
+### 运行时行为
+
+| 交互 | 行为 |
+|---|---|
+| 页内 `↓`/`↑`/滚轮/触摸 | 页内屏级翻页(页自己的 deck 状态机) |
+| 最后一屏 `↓` / 页内 `→`/`←` | 按 **plan 顺序**切页(忽略页内 deck-config 的文件跳转) |
+| 总控顶栏 tab / 底部 ‹ › 按钮 | 直接切页 |
+| 首次展示某页 | 重演入场动画(预加载已渲染好内容 → 无白屏) |
+| 翻回已看过的页 | 保留原状态(iframe 不销毁) |
+| 总控主题按钮 | 同步广播所有 iframe 页面 |
+
+### 页侧兼容(单页模式不变)
+
+- 页面在 iframe 内被加载时:`deck.js` 自动进入容器子模式——不渲染自身顶栏/进度条(由总控提供),
+  翻章请求改发 postMessage 给总控;**独立打开时行为与之前完全一致**。
+- 页内 `deck-config` 保留(单页独立导航仍然可用),总控场景下其 next/prev 仅作参考、不生效。
+
+### 通信协议(postMessage,跨源/跨 file:// 均可用)
+
+| 方向 | type | 载荷 |
+|---|---|---|
+| 页 → 总控 | `deck-progress` | `{ index, total, state }` 屏级进度 |
+| 页 → 总控 | `deck-go` | `{ file, dir }` 翻章请求(dir>0 下页 / <0 上页) |
+| 总控 → 页 | `deck-register` | 确认容器模式(页收到后才接管翻章) |
+| 总控 → 页 | `deck-cmd` | `{ cmd: 'next'|'prev'|'jump'|'home'|'end'|'theme', index?, theme? }` |

+ 54 - 0
skills/study-report/lib/docs/manifest-schema.md

@@ -0,0 +1,54 @@
+# manifest-schema · 两级索引 schema
+
+> 权威源文档,规则见 `.claude/rules/index.md`。
+
+## 课程级 `course/manifest.json`
+
+```json
+{
+  "id": "hundun-aiceo",
+  "course": "混沌AI院 · Day1 下午+晚上 案例大课",
+  "version": "0.4",
+  "themes": ["dark", "light"],
+  "chapters": [
+    {
+      "id": "job-agent",
+      "title": "蓝领招聘 · AI 复制知心大姐",
+      "hook": "几百人拿着电话卷获客的行业,最强的经纪人,两年没打过一个拉新电话。",
+      "outline": "cases/蓝领招聘-AI经纪人.md",
+      "manifest": "cases/job-agent/manifest.json"
+    }
+  ]
+}
+```
+
+## 案例级 `cases/<case>/manifest.json`
+
+```json
+{
+  "id": "job-agent",
+  "course": "hundun-aiceo",
+  "title": "蓝领招聘案例 · 从几百人打电话到 AI 复制知心大姐",
+  "outline": "../蓝领招聘-AI经纪人.md",
+  "pages": [
+    { "id": "index", "file": "demo/index.html", "title": "序 · 行业大棋盘",
+      "screens": [ { "id": "s1-cover", "title": "封面钩子" },
+                   { "id": "s2-chessboard", "title": "供需连·三道墙" } ] }
+  ]
+}
+```
+
+## 页内嵌 `deck-config`(运行时快照,每页必带)
+
+```html
+<script type="application/json" class="deck-config">
+{ "case": "job-agent",
+  "page": { "id": "sop", "title": "01 · 卷疯了的行业" },
+  "prev": "index.html",
+  "next": "agent.html",
+  "chapters": [ { "id": "index", "file": "index.html", "title": "序" },
+                { "id": "sop", "file": "sop.html", "title": "01 卡点" } ] }
+</script>
+```
+- `chapters` 全量列出本案例所有章(供顶栏 tab 与 →/← 翻章);`prev`/`next` 为相邻章文件。
+- 一致性:`manifest.json` 权威,`deck-config` 是快照,改动需两者同步。

+ 182 - 0
skills/study-report/lib/player/canvas-scale.js

@@ -0,0 +1,182 @@
+/* ============================================================
+ * lib/player/canvas-scale.js · 画布等比缩放(固定 1280×720 画布)
+ * ------------------------------------------------------------
+ * 问题:demo 页内部用 vw/vh,iframe/窗口尺寸一变(手机横屏 926×428、
+ *       16:10 等),版式随视口重排,与电脑 16:9 观感不一致。
+ * 方案:固定 1280×720 画布,JS 按 min(innerW/1280, innerH/720) 等比
+ *       scale 并居中(letterbox)。任何视口下版式与 1280×720 完全一致,
+ *       只整体缩放;16:10 / 手机横竖屏自动适配,无需特判。
+ * 关键一步(画布内 vw/vh 全量归零):初始化时把本页样式表(含
+ *   tokens/base/style.css 与页内 <style>)和行内 style 里的
+ *   vw/vh/dvh 按 1280×720 等效换算成 px(1vw=12.8px,1vh=7.2px),
+ *   并删除全部 @media 规则(画布恒 1280 宽,窄屏规则永不生效)。
+ *   此后版式与窗口彻底解耦,缩放只由 transform 完成。
+ * 两种模式(自动检测):
+ *   A. 包裹模式(普通 demo 页):body 现有内容包进 .cv-stage 缩放。
+ *   B. shell 模式(总控页,含 .shell-stage):直接把 .shell-stage 当画布
+ *      (1280×720 + scale),嵌入的 section / iframe 全部随之缩放;
+ *      !important 压过 deck.js 全屏 letterbox 的 width/height 声明,
+ *      全屏仅负责隐藏系统 UI,缩放始终由本脚本负责。
+ * 规则(见 .claude/rules/page-craft.md「画布等比缩放规范」):
+ *   - 新页面禁止再写 vw/vh 与窄屏 @media;画布内 1vw=12.8px、1vh=7.2px。
+ * 用法:demo 页在 deck.js 之前引一次;shell 页在 deck.init() 之后引一次。
+ *   <script src="../../../../../../lib/player/canvas-scale.js"></script>
+ * 纪律:只用 var(--...),不新增色值;file:// 直接可用;零依赖;幂等。
+ * ============================================================ */
+(function () {
+  'use strict';
+  if (window.__CANVAS_SCALE__) return;   // 幂等
+  window.__CANVAS_SCALE__ = true;
+
+  var W = 1280, H = 720, PXW = 12.8, PXH = 7.2;
+  var VW_RE = /(\d*\.?\d+)(d?v[hw])\b/g;
+
+  /* Nvw/Nvh/Ndvh → 1280×720 画布等效 px(1vw=12.8px,1vh=7.2px) */
+  function pxify(text) {
+    return text.replace(VW_RE, function (m, num, unit) {
+      var n = parseFloat(num);
+      if (unit === 'vw') return round1(n * PXW) + 'px';
+      return round1(n * PXH) + 'px';           // vh / dvh 都按画布高
+    });
+  }
+  function round1(n) { return Math.round(n * 10) / 10; }
+  function hasViewportUnit(text) { return /\d[ \t]*d?v[hw]\b/.test(text); }
+
+  /* 样式表改写:vw/vh→px;@media 整条删除(画布恒 1280 宽)。
+     仅处理同源样式表(本仓库全部本地),跨域/解析失败静默跳过。 */
+  function rewriteSheet(sheet) {
+    if (!sheet) return;
+    if (sheet.ownerNode && sheet.ownerNode.id === 'cv-scale-style') return; // 跳过本脚本注入的样式
+    var rules;
+    try { rules = sheet.cssRules; } catch (e) { return; }
+    if (!rules) return;
+    rewriteRuleList(rules);
+  }
+  function rewriteRuleList(list) {
+    Array.prototype.forEach.call(list, function (rule) {
+      var type = rule.type;
+      if (type === 4 /* CSSMediaRule */ || type === 12 /* CSSSupportsRule */) {
+        deleteRule(list, rule);              // @media/@supports 整条移除
+        return;
+      }
+      if ((type === 1 /* CSSStyleRule */ || type === 5 /* CSSFontFaceRule */) &&
+          hasViewportUnit(rule.cssText)) {
+        var css = pxify(rule.cssText);
+        try { rule.style.cssText = css.slice(css.indexOf('{') + 1, css.lastIndexOf('}')); }
+        catch (e) { /* 保底:无法就地改则跳过 */ }
+      }
+    });
+  }
+  function deleteRule(list, rule) {
+    try {
+      var idx = Array.prototype.indexOf.call(list, rule);
+      if (idx >= 0 && list.deleteRule) list.deleteRule(idx);
+    } catch (e) { /* 忽略 */ }
+  }
+  /* 行内 style:clamp(...vw...) 等直接写在 style 属性上,样式表改写覆盖不到 */
+  function rewriteInlineStyles(doc) {
+    var els = doc.querySelectorAll('[style]');
+    Array.prototype.forEach.call(els, function (el) {
+      var s = el.getAttribute('style');
+      if (s && hasViewportUnit(s)) el.setAttribute('style', pxify(s));
+    });
+  }
+
+  var CSS_TEXT =
+    /* 画布页高度基准:--screen-h 由「视口高」改为「画布高」(仅本页覆盖,不动 tokens.css) */
+    'html.cv-fit { --screen-h: 720px; }\n' +
+    'html.cv-fit, html.cv-fit body { width:100%; height:100%; margin:0; padding:0; overflow:hidden; background:var(--bg-0); }\n' +
+    /* 包裹模式:画布舞台固定 1280×720,transform-origin 左上,JS scale + 居中 */
+    'html.cv-fit .cv-stage {\n' +
+    '  position: fixed; left: 0; top: 0;\n' +
+    '  width: 1280px; height: 720px;\n' +
+    '  transform-origin: 0 0;\n' +
+    '  background: var(--bg-0);\n' +
+    '  overflow: hidden;\n' +
+    '}\n' +
+    /* shell 模式:.shell-stage 本身即画布(!important 压过 deck.js 全屏规则,
+       全屏时 deck 只隐藏系统 UI;top/left 由 fit() 内联控制,优先级更高) */
+    'html.cv-fit .shell-stage {\n' +
+    '  width: 1280px !important; height: 720px !important;\n' +
+    '  transform-origin: 0 0 !important;\n' +
+    '  overflow: hidden;\n' +
+    '}\n' +
+    /* 兜底:base.css 的 .screen 宽高已由 vw/vh 归零改写,这里再锚一次 */
+    'html.cv-fit .screen { width: 1280px; }\n' +
+    /* 页内 style.css 的 100vh 高度基准已归零,这里再锚一次 */
+    'html.cv-fit .page  { height: 720px; }\n' +
+    'html.cv-fit .screen{ min-height: 720px; }\n' +
+    'html.cv-fit .cover { min-height: 720px; }\n';
+
+  function makeFit(el) {
+    return function fit() {
+      var s = Math.min(window.innerWidth / W, window.innerHeight / H);
+      if (!isFinite(s) || s <= 0) s = 1;
+      el.style.transform = 'scale(' + s + ')';
+      el.style.left = Math.round((window.innerWidth - W * s) / 2) + 'px';
+      el.style.top = Math.round((window.innerHeight - H * s) / 2) + 'px';
+      document.documentElement.style.setProperty('--cv-scale', String(Math.round(s * 1000) / 1000));
+    };
+  }
+
+  function init() {
+    var docEl = document.documentElement;
+    if (docEl.classList.contains('cv-fit')) return;      // 已初始化
+    var body = document.body;
+    if (!body) { document.addEventListener('DOMContentLoaded', init, { once: true }); return; }
+
+    docEl.classList.add('cv-fit');
+
+    // 1) 画布内 vw/vh 全量归零(样式表 + 行内 style),@media 删除
+    Array.prototype.forEach.call(document.styleSheets, rewriteSheet);
+    rewriteInlineStyles(document);
+
+    // 2) 注入画布样式(在改写之后,自身不受 pxify 影响)
+    var st = document.createElement('style');
+    st.id = 'cv-scale-style';
+    st.textContent = CSS_TEXT;
+    document.head.appendChild(st);
+
+    // 3) shell 模式:.shell-stage 即画布(shell chrome 在画布外,保持原生尺寸)
+    var shellStage = body.querySelector('.shell-stage');
+    if (shellStage) {
+      var fitShell = makeFit(shellStage);
+      window.addEventListener('resize', fitShell, { passive: true });
+      window.addEventListener('orientationchange', fitShell);
+      fitShell();
+      // shell embed 模式:deck.init() 异步把 demo 屏克隆进 .shell-stage,
+      // 屏内行内 style 携带 vw/vh → 到达后重写一遍(幂等)
+      var moTimer = null;
+      var mo = new MutationObserver(function () {
+        clearTimeout(moTimer);
+        moTimer = setTimeout(function () {
+          Array.prototype.forEach.call(document.styleSheets, rewriteSheet);
+          rewriteInlineStyles(document);
+          fitShell();
+        }, 80);
+      });
+      mo.observe(shellStage, { childList: true, subtree: true });
+      return;
+    }
+
+    // 4) 包裹模式:body 现有全部子节点移入 .cv-stage(保留顺序与事件绑定)
+    var stage = document.createElement('div');
+    stage.className = 'cv-stage';
+    stage.id = 'cv-stage';
+    var moving = Array.prototype.slice.call(body.childNodes);
+    for (var i = 0; i < moving.length; i++) stage.appendChild(moving[i]);
+    body.appendChild(stage);
+
+    var fit = makeFit(stage);
+    window.addEventListener('resize', fit, { passive: true });
+    window.addEventListener('orientationchange', fit);
+    fit();
+  }
+
+  // 同步脚本:body 已在 DOM 即刻执行;未就绪则等 DOMContentLoaded
+  if (document.readyState === 'loading' && !document.body) {
+    document.addEventListener('DOMContentLoaded', init, { once: true });
+  } else {
+    init();
+  }
+})();

+ 1291 - 0
skills/study-report/lib/player/deck.js

@@ -0,0 +1,1291 @@
+/* ============================================================
+ * lib/player/deck.js · 混沌AI院演示播放器核心
+ *
+ * 职责:收集 .screen → 每屏状态机 idle→playing→done;
+ *       进入新屏自动播放入场动画/视频(首屏不黑屏);↓ 先演完当前动画再切屏(门控);
+ *       动画运行中 ↓ 快进到结尾;上下键/翻页器全兼容;进度条;两级导航(读 deck-config)。
+ *       鼠标支持:滚轮上/下 = 上/下一屏;侧键 前进=下章、后退=上章(防抖,兼容翻页笔)。
+ *
+ * 页面接入:
+ *   <section class="screen" data-tl="s2">...</section>
+ *   <script src=".../deck.js"></script>
+ *   <script>
+ *     window.SCREEN_TIMELINES = { s2: function(ctx){ var tl=gsap.timeline(); ...; return tl; } };
+ *     deck.init();
+ *   </script>
+ * 普通 script,非 module,file:// 可用。禁止页面自写 keydown/scroll。
+ * ============================================================ */
+(function () {
+  var screens = [];
+  var current = 0;
+  var total = 0;
+  var cfg = null;
+  var inited = false;
+
+  /* ---------- 总控容器模式(shell)状态 ---------- */
+  var shellMode = !!(window.parent && window.parent !== window); // 页面被 iframe 装载
+  var shellPlan = null;   // 编排配置(deck-plan)
+  var shellCur = 0;       // 当前页序号
+  var shellFrames = [];   // iframe 元素(不销毁,翻回保留状态)
+  var shellShown = [];    // 是否已展示过(首次展示重演入场,无白屏)
+  var shellLast = [];     // 各页最近上报的进度 {index,total,state}
+  var pendingGo = null;   // 容器注册前的翻章请求缓冲(防止注册竞态丢失)
+  var shellFrom = 0;      // 容器模式:切片起始屏(0-based,总控 deck-range 下发)
+  var shellTo = -1;       // 容器模式:切片结束屏(-1 = 整节)
+  /* ---------- embed(JS 注入)模式状态 ---------- */
+  var embedMode = false;      // 总控用 JS 注入单页 HTML(非 iframe)
+  var embedPages = [];        // 注入后的屏信息 {page, screens, tlName}
+  var embedReady = false;
+
+  /* ---------- 工具 ---------- */
+  function readConfig() {
+    var el = document.querySelector('script.deck-config');
+    if (!el) return null;
+    try { return JSON.parse(el.textContent); } catch (e) { return null; }
+  }
+
+  /* 与总控容器(deck-shell)通信:postMessage 跨源/跨 file:// 均可用 */
+  function postShell(msg) {
+    try { window.parent.postMessage(msg, '*'); } catch (e) {}
+  }
+
+  function fmt(n, dec, sep) {
+    var s = n.toFixed(dec == null ? 0 : dec);
+    if (sep) s = s.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
+    return s;
+  }
+
+  function makeCtx(s) {
+    var el = s.el;
+    return {
+      el: el,
+      q: function (sel) { return el.querySelector(sel); },
+      qAll: function (sel) { return el.querySelectorAll(sel); },
+      // 数字滚动,返回 gsap tween,可用 tl.add(ctx.count(...), pos)
+      count: function (sel, to, opts) {
+        opts = opts || {};
+        var node = el.querySelector(sel);
+        if (!node || !window.gsap) return null;
+        var obj = { val: 0 };
+        var target = (opts.to != null) ? opts.to : to;
+        var tw = window.gsap.to(obj, {
+          val: target,
+          duration: opts.duration || 1.2,
+          ease: opts.ease || 'power1.out',
+          onUpdate: function () { node.textContent = fmt(obj.val, opts.dec || 0, opts.sep); }
+        });
+        return tw;
+      }
+    };
+  }
+
+  /* ---------- 状态 ---------- */
+  function collect() {
+    var els = Array.prototype.slice.call(document.querySelectorAll('.screen'));
+    screens = els.map(function (el) {
+      var tlKey = el.getAttribute('data-tl');
+      return {
+        el: el,
+        tlKey: tlKey,
+        hasTL: !!(tlKey && window.SCREEN_TIMELINES && typeof window.SCREEN_TIMELINES[tlKey] === 'function'),
+        video: el.querySelector('video[data-gate]'),
+        auto: el.hasAttribute('data-gate-autoplay'),
+        state: 'idle',
+        tl: null
+      };
+    });
+    total = screens.length;
+  }
+
+  /* ---------- 展示 ---------- */
+  function showScreen(i) {
+    screens.forEach(function (s, j) {
+      s.el.style.visibility = (j === i) ? 'visible' : 'hidden';
+      // 根因修复:`.screen[data-tl].in > .s-wrap { visibility: visible }`(base.css)是 .s-wrap 的
+      // 直接声明,会覆盖从屏级继承的 hidden。动画播完 .in 类残留 → 旧屏屏级 hidden 但 .s-wrap
+      // 仍 visible,内容叠在新屏上(旧屏盖新屏的根因)。此处对 .s-wrap 直接强制隐藏/恢复。
+      var wrap = s.el.querySelector('.s-wrap');
+      if (wrap) wrap.style.visibility = (j === i) ? '' : 'hidden';
+    });
+    current = i;
+    updateProgress();
+    highlightTab();
+  }
+
+  // 屏激活:进入新一页/新屏直接走入场动画加载(不黑屏)。
+  // 有时间线 → 自动播放;autoplay 视频 → 自动播;data-gate 视频 → 保持 idle 等 ↓;
+  // 无动画 → 直接视为完成。↓ 在动画运行中可快进(见 next)。
+  function activate(s) {
+    if (s.state !== 'idle') return;
+    if (s.hasTL) { playTimeline(s); return; }
+    if (s.video && s.auto) { playVideo(s); return; }
+    if (s.video) return;               // data-gate 视频:保持 idle,等 ↓ 播放
+    s.state = 'done'; updateProgress();
+  }
+
+  /* ---------- 动画 / 视频门控 ---------- */
+  function playTimeline(s) {
+    if (!s.hasTL) return false;
+    s.el.classList.add('in');
+    var fn = window.SCREEN_TIMELINES[s.tlKey];
+    var ctx = makeCtx(s);
+    var tl = null;
+    try { tl = fn(ctx); } catch (e) { tl = null; }
+    if (!tl || !window.gsap) { s.state = 'done'; updateProgress(); return false; }
+    s.tl = tl;
+    s.state = 'playing';
+    tl.eventCallback('onComplete', function () {
+      // 有时间线 + data-gate-autoplay:时间线播完自动接播视频(再按 ↓ 才切屏)
+      if (s.video && s.auto) { playVideo(s); return; }
+      s.state = 'done'; updateProgress();
+    });
+    tl.play();
+    updateProgress();
+    return true;
+  }
+
+  function playVideo(s) {
+    var v = s.video;
+    if (!v) return;
+    var ended = function () { s.state = 'done'; updateProgress(); };
+    v.removeEventListener('ended', ended);
+    v.addEventListener('ended', ended);
+    var p = v.play();
+    if (p && p.catch) p.catch(function () { s.state = 'done'; updateProgress(); });
+    s.state = 'playing';
+    updateProgress();
+  }
+
+  function resetScreen(s) {
+    if (s.tl) {
+      // 先播到结尾让 from-tween 渲染到最终态(opacity 1),再 kill:
+      // 直接 kill 一个播放中的 timeline 会残留内联 opacity:0(immediateRender),
+      // 且后续新建的 timeline 无法推进 → 重演 / jump / prev 时整屏空白(CBEC p0/p1 实测)。
+      try { s.tl.progress(1); } catch (e) {}
+      try { s.tl.kill(); } catch (e) {}
+      s.tl = null;
+    }
+    s.el.classList.remove('in');
+    s.state = 'idle';
+    if (s.video) { s.video.pause(); s.video.currentTime = 0; }
+  }
+
+  /* ---------- 导航动作 ---------- */
+  function next() {
+    if (current >= total) return;
+    var s = screens[current];
+    if (s.state === 'done') { advance(); return; }
+    if (s.state === 'playing') {
+      // 入场动画运行中按 ↓:快进到结尾并切下一屏(新屏入场自动播放);视频保持本屏待重播
+      if (s.video && !s.video.paused) { s.video.currentTime = s.video.duration; return; }
+      if (s.tl) s.tl.progress(1);
+      advance();
+      return;
+    }
+    // idle(正常已被 activate 自动播放;防御 data-gate 视频屏)
+    if (s.hasTL && playTimeline(s)) return;
+    if (s.video) { playVideo(s); return; }
+    s.state = 'done';
+    advance();
+  }
+
+  function advance() {
+    // 容器切片:当前屏已是切片结束屏 → 直接翻章(交给总控切下一节),不进入切片外屏
+    if (shellTo >= 0 && current >= shellTo) { nextChapter(); return; }
+    if (current < total - 1) {
+      showScreen(current + 1);
+      activate(screens[current]);      // showScreen 已把 current 指向新屏 → 自动播放入场
+    } else {
+      nextChapter();
+    }
+  }
+
+  function prev() {
+    // 容器切片:当前屏已是切片起始屏 → 直接翻上一章
+    if (shellFrom > 0 && current <= shellFrom) { prevChapter(); return; }
+    if (current > 0) {
+      resetScreen(screens[current]);
+      current--;
+      resetScreen(screens[current]);
+      showScreen(current);
+      activate(screens[current]);      // 上一屏重演入场(可重演)
+    } else {
+      prevChapter();
+    }
+  }
+
+  function jump(i) {
+    // 容器切片:跳转范围钳制在 [from, to]
+    if (shellTo >= 0) i = Math.max(shellFrom, Math.min(i, shellTo));
+    if (i < 0 || i >= total) return;
+    resetScreen(screens[current]);
+    current = i;
+    resetScreen(screens[current]);
+    showScreen(current);
+    activate(screens[current]);
+  }
+
+  /* ---------- 章节导航 ---------- */
+  function goFile(file, dir) {
+    if (!file) return;
+    if (shellMode) {
+      // 容器模式:翻章请求交给总控(总控按 plan 顺序编排),不在 iframe 内跳文件
+      pendingGo = { type: 'deck-go', file: file, dir: dir };
+      postShell(pendingGo);
+      return;
+    }
+    var cur = location.pathname.split('/').pop();
+    if (file === cur) { dir > 0 ? jump(0) : jump(total - 1); return; }
+    location.href = file;
+  }
+  function nextChapter() {
+    if (embedMode && embedReady) { embedJumpSection(1); return; }
+    goFile(cfg ? cfg.next : null, 1);
+  }
+  function prevChapter() {
+    if (embedMode && embedReady) { embedJumpSection(-1); return; }
+    goFile(cfg ? cfg.prev : null, -1);
+  }
+
+  /* embed(JS注入)模式:节级跳转统一走这里(←→/侧键/触摸横滑/末屏翻章)。
+   * 节 = plan.pages 一项;embed 下所有屏 flat 注入,按节累计屏数定位「该节第一屏」。
+   * 不用 next()/prev()(那在 embed 下是切屏,不是切节)。 */
+  function embedSectionIndex() {
+    var acc = 0;
+    for (var q = 0; q < shellPlan.pages.length; q++) {
+      var cnt = (embedPages[q] && embedPages[q].count) || 0;
+      if (current < acc + cnt) return q;
+      acc += cnt;
+    }
+    return shellPlan.pages.length - 1;
+  }
+  function embedJumpSection(dir) {
+    var sec = embedSectionIndex() + dir;
+    if (sec < 0 || sec >= shellPlan.pages.length) return;   // 边界节不翻,与 iframe showPage 一致
+    var target = 0;
+    for (var q = 0; q < sec; q++) target += (embedPages[q] && embedPages[q].count) || 0;
+    jump(target);
+  }
+
+  /* ---------- 界面 ---------- */
+  function ensureThemeBtn() {
+    if (document.querySelector('.theme-toggle')) return;
+    var topbar = document.querySelector('.deck-topbar');
+    if (!topbar) return;
+    var btn = null;
+    // 优先 theme.js 的图标按钮(太阳/月亮),缺失时退化为文字兜底
+    if (window.HDTheme && window.HDTheme.createToggleBtn) {
+      btn = window.HDTheme.createToggleBtn();
+    } else {
+      btn = document.createElement('button');
+      btn.className = 'theme-toggle';
+      btn.type = 'button';
+      btn.textContent = '主题';
+      btn.addEventListener('click', function () { if (window.HDTheme) window.HDTheme.toggle(); });
+    }
+    var left = topbar.querySelector('.deck-left');
+    if (!left) { left = document.createElement('div'); left.className = 'deck-left'; topbar.insertBefore(left, topbar.firstChild); }
+    left.appendChild(btn);
+  }
+
+  function highlightTab() {
+    if (!cfg) return;
+    var tabs = document.querySelectorAll('.deck-tab');
+    tabs.forEach(function (b) {
+      b.classList.toggle('cur', b.getAttribute('data-ch') === cfg.page.id);
+    });
+    scrollActiveTabIntoView();
+  }
+
+  /* 顶栏章节/页面 tab 自动横向滚动:把高亮当前 tab 滚动到居中(不触边)。
+   * 溢出检测用 scrollWidth(横向不溢出时忽略,含窄屏折叠面板纵向列)。 */
+  function scrollActiveTabIntoView() {
+    var nav = document.querySelector('.deck-tabs');
+    var cur = document.querySelector('.deck-tab.cur');
+    if (!nav || !cur) return;
+    if (nav.scrollWidth <= nav.clientWidth) return;          // 无需滚动
+    var navRect = nav.getBoundingClientRect();
+    var curRect = cur.getBoundingClientRect();
+    var target = nav.scrollLeft + (curRect.left + curRect.width / 2) - (navRect.left + navRect.width / 2);
+    var max = nav.scrollWidth - nav.clientWidth;
+    target = Math.max(0, Math.min(target, max));             // 不触边:钳制在可滚范围
+    if (Math.abs(nav.scrollLeft - target) > 1) {
+      nav.scrollTo({ left: target, behavior: 'smooth' });
+    }
+  }
+
+  function buildChrome() {
+    if (shellMode) return;   // 容器模式:顶栏/进度条由总控渲染,页面只保留 .screen 内容
+    var topbar = document.querySelector('.deck-topbar');
+    if (!topbar) { topbar = document.createElement('div'); topbar.className = 'deck-topbar'; document.body.appendChild(topbar); }
+    if (cfg && cfg.chapters && !topbar.querySelector('.deck-tabs')) {
+      var nav = document.createElement('nav');
+      nav.className = 'deck-tabs';
+      cfg.chapters.forEach(function (ch) {
+        var b = document.createElement('button');
+        b.className = 'deck-tab' + (ch.id === cfg.page.id ? ' cur' : '');
+        b.type = 'button';
+        b.setAttribute('data-ch', ch.id);
+        b.textContent = ch.title;
+        b.addEventListener('click', function () { goFile(ch.file, 1); });
+        nav.appendChild(b);
+      });
+      topbar.appendChild(nav);
+    }
+    ensureThemeBtn();
+    var right = topbar.querySelector('.deck-right');
+    if (!right) { right = document.createElement('div'); right.className = 'deck-right'; topbar.appendChild(right); }
+    // 右上角品牌 logo(theme.js 注入,随主题切换暗/浅图)
+    if (window.HDTheme && window.HDTheme.ensureLogo) window.HDTheme.ensureLogo(right);
+
+    var bottom = document.querySelector('.deck-bottom');
+    if (!bottom) {
+      bottom = document.createElement('div');
+      bottom.className = 'deck-bottom';
+      document.body.appendChild(bottom);
+      var prog = document.createElement('div');
+      prog.className = 'deck-progress';
+      var bar = document.createElement('div');
+      bar.className = 'bar';
+      prog.appendChild(bar);
+      bottom.appendChild(prog);
+      var pos = document.createElement('span');
+      pos.className = 'deck-pos';
+      bottom.appendChild(pos);
+      if (cfg && cfg.page) {
+        var pn = document.createElement('span');
+        pn.className = 'deck-pagename';
+        pn.textContent = cfg.page.title;
+        bottom.appendChild(pn);
+      }
+    }
+  }
+
+  function updateProgress() {
+    var pct = total ? (current / total) * 100 : 0;
+    var bar = document.querySelector('.deck-progress .bar');
+    if (bar) bar.style.width = pct + '%';
+    var pos = document.querySelector('.deck-pos');
+    if (pos) pos.textContent = (current + 1) + ' / ' + total;
+    // 容器模式:把本页进度上报总控(总控据此算全局进度条)
+    if (shellMode) {
+      var s = screens[current] || null;
+      postShell({ type: 'deck-progress', index: current, total: total, state: s ? s.state : 'done' });
+    }
+    // embed 模式:屏进度变化同步总控 chrome
+    if (embedMode && embedReady) renderShellChrome();
+  }
+
+  /* ---------- 输入 ---------- */
+  function bindKeys() {
+    document.addEventListener('keydown', function (e) {
+      var t = e.target;
+      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
+      var k = e.key;
+      if (k === 'ArrowDown' || k === 's' || k === 'S' || k === 'PageDown' || k === ' ') {
+        e.preventDefault(); next();
+      } else if (k === 'ArrowUp' || k === 'w' || k === 'W' || k === 'PageUp') {
+        e.preventDefault(); prev();
+      } else if (k === 'ArrowRight' || k === 'd' || k === 'D') {
+        e.preventDefault(); nextChapter();
+      } else if (k === 'ArrowLeft' || k === 'a' || k === 'A') {
+        e.preventDefault(); prevChapter();
+      } else if (k === 'Home') {
+        e.preventDefault(); jump(0);
+      } else if (k === 'End') {
+        e.preventDefault(); jump(total - 1);
+      }
+    });
+  }
+
+  function bindClick() {
+    document.addEventListener('click', function (e) {
+      var t = e.target;
+      if (t && t.closest && (t.closest('a') || t.closest('button') || t.closest('.deck-topbar') || t.closest('.deck-bottom'))) return;
+      if (e.clientX > window.innerWidth * 0.78 && e.clientX < window.innerWidth) next();
+    });
+  }
+
+  function bindMouse() {
+    // 滚轮:累计方向,超过阈值触发一次,冷却期防连击(轨迹板/鼠标均稳定)
+    var wheelAcc = 0, lastDir = 0, wheelLock = 0;
+    var STEP = 60, COOLDOWN = 550;
+    document.addEventListener('wheel', function (e) {
+      var t = e.target;
+      // 顶栏章节/页面 tab 上滚轮 → 横向滚动 tabs(顶栏 hover 本就不翻屏;横向不溢出时忽略)
+      if (t && t.closest && t.closest('.deck-tabs')) {
+        var nav = t.closest('.deck-tabs');
+        if (nav.scrollWidth > nav.clientWidth) {
+          e.preventDefault();
+          var step = e.deltaMode === 0 ? (e.deltaY || e.deltaX) : e.deltaY * 40;
+          nav.scrollLeft += step;
+        }
+        return;
+      }
+      if (t && t.closest && (t.closest('a') || t.closest('button') || t.closest('.deck-topbar') || t.closest('.deck-bottom'))) return;
+      var now = Date.now();
+      if (now - wheelLock < COOLDOWN) return;
+      if (e.deltaY === 0) return;
+      var dir = e.deltaY > 0 ? 1 : -1;          // 下滚 = 下一屏,上滚 = 上一屏
+      wheelAcc = (lastDir === dir) ? wheelAcc + e.deltaY : e.deltaY;
+      lastDir = dir;
+      if (Math.abs(wheelAcc) < STEP) return;    // 未到阈值先攒着
+      wheelAcc = 0; wheelLock = now;
+      e.preventDefault();
+      if (dir > 0) next(); else prev();
+    }, { passive: false });
+    // 鼠标侧键:button 3(前进) → 下章,button 4(后退) → 上章(auxclick/mouseup 双绑兼容,防抖)
+    var lastSide = 0;
+    function side(e) {
+      if (e.button !== 3 && e.button !== 4) return;
+      var t = e.target;
+      if (t && t.closest && (t.closest('a') || t.closest('button') || t.closest('.deck-topbar') || t.closest('.deck-bottom'))) return;
+      var now = Date.now();
+      if (now - lastSide < 450) return;
+      lastSide = now;
+      e.preventDefault();
+      if (e.button === 3) nextChapter(); else prevChapter();
+    }
+    document.addEventListener('auxclick', side);
+    document.addEventListener('mouseup', side);
+  }
+
+  function bindTouch() {
+    // 触摸手势:上滑/下滑 = 下/上一屏(触屏习惯:上滑进下一页),左滑/右滑 = 下/上一章(与方向键一致)。
+    // 用 Touch Events 而非 Pointer Events:touchmove + passive:false 才能真正 preventDefault
+    // 阻止浏览器原生滚动/缩放;pointermove 的 preventDefault 对滚动无效(需 touch-action CSS,
+    // 而全局 touch-action:none 会破坏 chat-window/group-chat 内部滚动)。
+    if (!('ontouchstart' in window)) return;     // 无触摸能力(桌面)直接跳过,不打扰鼠标
+    var startX = 0, startY = 0, axis = 0;        // axis: 0=未锁定 1=水平 2=垂直
+    var tracking = false;
+    var touchLock = 0;
+    var THRESHOLD = 50, COOLDOWN = 550;          // 阈值 50px、冷却 550ms(与滚轮一致)
+
+    function isIgnored(t) {                       // 与 wheel/click 相同的忽略集,避免误触
+      return !!(t && t.closest &&
+        (t.closest('a') || t.closest('button') || t.closest('.deck-topbar') || t.closest('.deck-bottom')));
+    }
+
+    document.addEventListener('touchstart', function (e) {
+      if (isIgnored(e.target)) return;            // 交互元素不接管
+      if (e.touches.length > 1) return;            // 多点(捏合缩放)不接管
+      tracking = true;
+      axis = 0;
+      startX = e.touches[0].clientX;
+      startY = e.touches[0].clientY;
+    });
+
+    document.addEventListener('touchmove', function (e) {
+      if (!tracking) return;
+      if (e.touches.length > 1) { tracking = false; return; }   // 双指出现 = 放弃手势
+      var t = e.touches[0];
+      var dx = t.clientX - startX;
+      var dy = t.clientY - startY;
+      if (axis === 0) {
+        // 方向锁定:位移超 8px 判定轴向(水平/垂直二选一),防斜滑误触发
+        if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
+        axis = Math.abs(dx) > Math.abs(dy) ? 1 : 2;
+      }
+      e.preventDefault();                          // 轴向锁定后才阻断原生滚动/缩放(passive:false)
+    }, { passive: false });
+
+    function finish(e) {
+      if (!tracking) return;
+      tracking = false;
+      if (axis === 0) return;                      // 未到锁定阈值 = tap,放行 click
+      var now = Date.now();
+      if (now - touchLock < COOLDOWN) return;      // 防连击
+      var t = e.changedTouches[0];
+      var dx = t.clientX - startX;
+      var dy = t.clientY - startY;
+      if (axis === 1) {                            // 水平:左滑=下章,右滑=上章
+        if (Math.abs(dx) < THRESHOLD) return;
+        touchLock = now;
+        if (dx < 0) nextChapter(); else prevChapter();
+      } else {                                     // 垂直:上滑=下一屏,下滑=上一屏
+        if (Math.abs(dy) < THRESHOLD) return;
+        touchLock = now;
+        if (dy < 0) next(); else prev();
+      }
+    }
+    document.addEventListener('touchend', finish);
+    document.addEventListener('touchcancel', function () { tracking = false; });
+  }
+
+  /* ---------- 入口 ---------- */
+  function init(opts) {
+    if (inited) return;
+    inited = true;
+    opts = opts || {};
+    window.addEventListener('message', onPageMessage);
+    if (opts.mode === 'shell') { initShell(opts); return; }
+    cfg = readConfig();
+    collect();
+    if (total === 0) return;
+    screens.forEach(function (s) {
+      s.el.style.position = 'absolute';
+      s.el.style.inset = '0';
+    });
+    buildChrome();
+    bindKeys();
+    bindClick();
+    bindMouse();
+    bindTouch();
+    showScreen(0);
+    activate(screens[0]);              // 首屏自动播放入场,不黑屏
+  }
+
+  // 安全网:若页面未显式 init,DOM 就绪后自动启动
+  function auto() {
+    if (inited) return;
+    if (document.querySelectorAll('.screen').length) init();
+  }
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', auto);
+  } else {
+    setTimeout(auto, 0);
+  }
+
+  /* ============================================================
+   * 总控容器模式(shell):总控页用 iframe 加载多页 + 预加载无缝衔接 + 导航可编排。
+   *
+   * 用法(总控页,如 course/deck-shell.html):
+   *   <script type="application/json" class="deck-plan">{ "title": "...", "pages":[ {"file":"demo/p1.html","title":"01 ..."}, ... ] }</script>
+   *   <script src=".../deck.js"></script>
+   *   <script> deck.init({ mode: 'shell', planEl: '.deck-plan' }); </script>
+   *
+   * - plan.pages[].file 相对总控页;iframe 不销毁,翻回保留状态;预加载下一页(隐藏)→ 翻页无白屏。
+   * - 页内 deck 保持单页模式不变(可独立打开);容器内通过 postMessage 上报进度、转发翻章请求。
+   * - file:// 双击读内嵌 JSON(零 fetch);http 下可加 ?plan=plans/xxx.json 切换编排(fetch 失败回退内嵌)。
+   * ============================================================ */
+
+  function urlParam(name) {
+    try {
+      var m = location.search.match(new RegExp('[?&]' + name + '=([^&]*)'));
+      return m ? decodeURIComponent(m[1]) : null;
+    } catch (e) { return null; }
+  }
+
+  function readPlan(opts) {
+    if (opts.plan && typeof opts.plan === 'object' && opts.plan.pages) return opts.plan;
+    var url = opts.planUrl || urlParam('plan');
+    if (url && location.protocol !== 'file:') {
+      try {
+        var xhr = new XMLHttpRequest();
+        xhr.open('GET', url, false);       // 同步,保持零构建的确定性(仅 http 生效)
+        xhr.send(null);
+        if (xhr.status >= 200 && xhr.status < 300) {
+          var p = JSON.parse(xhr.responseText);
+          if (p && p.pages) return p;
+        }
+      } catch (e) {}
+    }
+    var el = opts.planEl ? document.querySelector(opts.planEl) : document.querySelector('script.deck-plan');
+    if (el) {
+      try {
+        var p2 = JSON.parse(el.textContent);
+        if (p2 && p2.pages) return p2;
+      } catch (e) {}
+    }
+    return null;
+  }
+
+  function initShell(opts) {
+    var plan = readPlan(opts);
+    if (!plan) {
+      var err = document.createElement('div');
+      err.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;'
+        + 'font:16px var(--font-mono);color:var(--text-3);background:var(--bg-0);text-align:center;padding:40px;';
+      err.textContent = '缺少编排配置:请在总控页内嵌 <script class="deck-plan"> JSON,或 http 下加 ?plan=plans/xxx.json。';
+      document.body.appendChild(err);
+      return;
+    }
+    shellPlan = plan;
+    window.addEventListener('message', onShellMsg);
+    buildShellChrome(plan);
+    bindShellKeys();
+    if (isMobileUA() || urlParam('mobile') === '1') buildFullscreenBtn();   // 手机端:右下角全屏切换(?mobile=1 供桌面调试)
+    // embed 模式:plan 任一页带 picks(精确选屏)→ 走 JS 注入,不走 iframe
+    var useEmbed = plan.pages.some(function (p) { return p && p.picks; });
+    if (useEmbed) { initEmbed(); return; }
+    showPage(0);
+  }
+
+  function buildShellChrome(plan) {
+    var isNarrow = isMobileUA() || urlParam('mobile') === '1' || (window.matchMedia && window.matchMedia('(max-width: 768px)').matches);
+    var topbar = document.createElement('div');
+    topbar.className = 'deck-topbar shell-topbar' + (isNarrow ? ' shell-collapsed' : '');
+    var left = document.createElement('div');
+    left.className = 'deck-left';
+    var title = document.createElement('span');
+    title.className = 'shell-title';
+    title.textContent = plan.title || '演示';
+    left.appendChild(title);
+    var btn = null;
+    if (window.HDTheme && window.HDTheme.createToggleBtn) {
+      btn = window.HDTheme.createToggleBtn();
+      btn.addEventListener('click', function () { setTimeout(broadcastTheme, 0); });
+    } else {
+      btn = document.createElement('button');
+      btn.className = 'theme-toggle';
+      btn.type = 'button';
+      btn.textContent = '主题';
+      btn.addEventListener('click', function () {
+        var cur = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
+        document.documentElement.setAttribute('data-theme', cur);
+        broadcastTheme();
+      });
+    }
+    left.insertBefore(btn, title);           // 主题按钮最左
+    // 窄屏:汉堡按钮(折叠导航),点击展开/收起节列表
+    if (isNarrow) {
+      var burger = document.createElement('button');
+      burger.className = 'shell-burger';
+      burger.type = 'button';
+      burger.setAttribute('aria-label', '展开页面导航');
+      burger.innerHTML = '<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 5h15M2.5 10h15M2.5 15h15"/></svg>';
+      burger.addEventListener('click', function (ev) {
+        ev.stopPropagation();
+        topbar.classList.toggle('shell-open');
+      });
+      left.insertBefore(burger, left.firstChild);
+      // 点击非折叠区关闭
+      document.addEventListener('click', function (e) {
+        if (!topbar.classList.contains('shell-open')) return;
+        if (!topbar.contains(e.target)) topbar.classList.remove('shell-open');
+      });
+    }
+    topbar.appendChild(left);
+    var nav = document.createElement('nav');
+    nav.className = 'deck-tabs';
+    nav.setAttribute('aria-label', '页面导航');
+    plan.pages.forEach(function (p, i) {
+      var b = document.createElement('button');
+      b.className = 'deck-tab shell-tab' + (i === 0 ? ' cur' : '');
+      b.type = 'button';
+      b.textContent = p.title || ('第 ' + (i + 1) + ' 页');
+      b.title = p.file || '';
+      b.addEventListener('click', function () {
+        if (embedMode && embedReady) {
+          // embed:跳到该节第一屏
+          var acc = 0, target = 0;
+          for (var q = 0; q < i; q++) { acc += (embedPages[q] && embedPages[q].count) || 0; }
+          target = acc;
+          jump(target);
+        } else {
+          showPage(i);
+        }
+        topbar.classList.remove('shell-open');   // 选完收起(窄屏)
+      });
+      nav.appendChild(b);
+    });
+    topbar.appendChild(nav);
+    var right = document.createElement('div');
+    right.className = 'deck-right';
+    topbar.appendChild(right);
+    document.body.appendChild(topbar);
+    if (window.HDTheme && window.HDTheme.ensureLogo) window.HDTheme.ensureLogo(right);
+
+    var bottom = document.createElement('div');
+    bottom.className = 'deck-bottom shell-bottom';
+    var prog = document.createElement('div');
+    prog.className = 'deck-progress';
+    var bar = document.createElement('div');
+    bar.className = 'bar';
+    prog.appendChild(bar);
+    bottom.appendChild(prog);
+    var pos = document.createElement('span');
+    pos.className = 'deck-pos shell-pos';
+    bottom.appendChild(pos);
+    var pn = document.createElement('span');
+    pn.className = 'deck-pagename shell-pagename';
+    bottom.appendChild(pn);
+    var prevB = document.createElement('button');
+    prevB.className = 'shell-navbtn';
+    prevB.type = 'button';
+    prevB.textContent = '‹ 上一页';
+    prevB.addEventListener('click', function () {
+      if (embedMode && embedReady) prev(); else showPage(shellCur - 1);
+    });
+    bottom.appendChild(prevB);
+    var nextB = document.createElement('button');
+    nextB.className = 'shell-navbtn';
+    nextB.type = 'button';
+    nextB.textContent = '下一页 ›';
+    nextB.addEventListener('click', function () {
+      if (embedMode && embedReady) next(); else showPage(shellCur + 1);
+    });
+    bottom.appendChild(nextB);
+    document.body.appendChild(bottom);
+  }
+
+  function postToFrame(fr, msg) {
+    try { fr.contentWindow.postMessage(msg, '*'); } catch (e) {}
+  }
+
+  /* ============================================================
+   * embed(JS 注入)模式:把单页 HTML 拉为字符串,DOMParser 解析,
+   * 按 picks 精确选屏注入总控 DOM;timeline 合并进全局;节样式注入。
+   * 相比 iframe:可精确到任意屏组合、可跨节跳着选屏、预渲染可控。
+   * ============================================================ */
+  function fetchText(url, cb) {
+    var xhr = new XMLHttpRequest();
+    xhr.open('GET', url, true);
+    xhr.onreadystatechange = function () {
+      if (xhr.readyState !== 4) return;
+      if (xhr.status >= 200 && xhr.status < 300) cb(null, xhr.responseText);
+      else cb('HTTP ' + xhr.status);
+    };
+    xhr.onerror = function () { cb('net'); };
+    xhr.send(null);
+  }
+
+  // 把节 HTML 的相对资源路径转绝对(相对该节文件所在目录;用 URL 解析避免拼接错误)
+  function absolutize(baseURL, el, attr) {
+    var v = el.getAttribute(attr);
+    if (!v || /^(https?:|data:|blob:|#)/.test(v)) return;
+    // 页面内锚点 href="#s1-..." 保留
+    if (v.charAt(0) === '#') return;
+    try {
+      var abs = new URL(v, baseURL).href;
+      el.setAttribute(attr, abs);
+    } catch (e) {}
+  }
+
+  // 提取节内联 <style>(含 style.css 内容合并)
+  function extractStyles(doc, baseURL, out) {
+    var links = doc.querySelectorAll('link[rel="stylesheet"]');
+    var inlines = doc.querySelectorAll('style');
+    links.forEach(function (l) {
+      var href = l.getAttribute('href') || '';
+      absolutize(baseURL, l, 'href');
+      out.push(l.outerHTML);
+    });
+    inlines.forEach(function (s) { out.push(s.outerHTML); });
+  }
+
+  // 提取 SCREEN_TIMELINES(把 IIFE 的 TL 注册名改成节级命名,避免全局覆盖)
+  function extractTimeline(html, ns) {
+    // 找出 window.SCREEN_TIMELINES = (function(){...})(); 整段
+    var m = html.match(/window\.SCREEN_TIMELINES\s*=\s*\(function[\s\S]*?\}\)\(\);/);
+    if (!m) return '';
+    return m[0].replace(/window\.SCREEN_TIMELINES\s*=\s*/, 'window["' + ns + '"] = ');
+  }
+
+  // 执行节的内联脚本(数据/渲染/初始化),让动态屏在 shell 里也能渲染。
+  // 跳过:外链 src、deck-config JSON、SCREEN_TIMELINES 块、deck.init 引导块。
+  // 数据与函数挂到 window(与单页一致);脚本在 DOMParser 解析后的 doc 上查询 DOM
+  // 时可能拿到 doc 而非总控 DOM——因此这些脚本里的 DOM 初始化统一改由 timeline
+  // 在总控 DOM 上触发(页内已有的 window 数据声明与纯函数保持原样执行即可)。
+  function execInlineScripts(doc, pageFile) {
+    var scripts = doc.querySelectorAll('script:not([src])');
+    Array.prototype.forEach.call(scripts, function (sc) {
+      if (sc.getAttribute('type') === 'application/json') return; // JSON 数据块,非 JS
+      var code = sc.textContent || '';
+      if (!code.trim()) return;
+      if (/deck-config/.test(code)) return;                 // 页内导航 JSON,不执行
+      // SCREEN_TIMELINES 定义段已由 extractTimeline 改名执行(window["__EMB_TL_N"])。
+      // 本函数执行节的数据/渲染脚本,但做两处安全化:
+      //   1) 挖掉 SCREEN_TIMELINES 段与 deck.init 引导(embed 由总控接管)
+      //   2) 把 document.getElementById( 替换为安全版本:找不到返回 null(让
+      //      立即渲染用 if(el) 守卫自然跳过,不抛错);把 document.addEventListener(
+      //      替换为空操作(embed 键盘由总控接管,避免页面级监听冲突)
+      // 这样数据声明、window.X 赋值、函数定义都正常执行挂到全局,供 timeline 渲染。
+      if (!window.__embGEL) {
+        // 安全 getElementById:找不到返回「幽灵元素」(属性/方法访问一律 noop),
+        // 让 embed 下找不到容器的立即渲染不抛错(数据声明仍执行,渲染由 timeline 接管)。
+        window.__embGEL = function (id) {
+          var el = document.getElementById(id);
+          if (el) return el;
+          if (window.__embGhost) return window.__embGhost;
+          window.__embGhost = new Proxy({}, {
+            get: function (t, k) {
+              if (k === 'children') return [];
+              if (k === 'style') return new Proxy({}, { get: function () { return ''; }, set: function () { return true; } });
+              return function () {};
+            },
+            set: function () { return true; }
+          });
+          return window.__embGhost;
+        };
+        window.__embNoop = function () {};
+      }
+      var execCode = code.replace(/window\.SCREEN_TIMELINES\s*=\s*\(function[\s\S]*?\}\)\(\);/g, '')
+        .replace(/deck\.init\s*\(\s*\{?\s*\}?\s*\)\s*;/g, '')
+        .replace(/document\.documentElement\.classList\.add\([^)]*\)\s*;?/g, '')
+        .replace(/document\.getElementById\s*\(/g, 'window.__embGEL(')
+        .replace(/document\.addEventListener\s*\(/g, 'window.__embNoop(');
+      if (!execCode.trim()) return;
+      try {
+        // 间接 eval 全局作用域执行:数据声明与函数声明挂到 window,
+        // 供 SCREEN_TIMELINES 里的渲染函数调用。
+        (0, eval)(execCode);
+      } catch (e) {
+        console.error('[embed] inline ' + (pageFile || ''), e);
+      }
+    });
+  }
+
+  // 收集一节的屏 DOM(按 picks 或整节),并注入总控 stage
+  function embedChapter(page, i, done) {
+    var file = page.file;
+    var baseURL = null;
+    try { baseURL = new URL(file, location.href).href; } catch (e) {}
+    fetchText(file, function (err, html) {
+      if (err) { console.error('[embed] ' + file + ' ' + err); if (done) done(); return; }
+      var doc = new DOMParser().parseFromString(html, 'text/html');
+      var ns = '__EMB_TL_' + i;
+      // 1. 注入节样式(link + style,基于节绝对 URL 解析相对路径)
+      var styles = [];
+      extractStyles(doc, baseURL, styles);
+      if (styles.length) {
+        var styleBox = document.getElementById('emb-styles');
+        if (!styleBox) {
+          styleBox = document.createElement('div');
+          styleBox.id = 'emb-styles';
+          styleBox.style.display = 'none';
+          document.head.appendChild(styleBox);
+        }
+        styleBox.insertAdjacentHTML('beforeend', styles.join('\n'));
+      }
+      // 2. 注册节 timeline(节级命名空间,避免不同节同名 data-tl 互相覆盖)
+      var tlCode = extractTimeline(html, ns);
+      if (tlCode) {
+        try {
+          var fn = new Function(tlCode);
+          fn();
+        } catch (e) { console.error('[embed] TL ' + file, e); }
+        // 合并进全局 SCREEN_TIMELINES:key = ns + '_' + 原key(如 __EMB_TL_11_s1)
+        if (window[ns]) {
+          window.SCREEN_TIMELINES = window.SCREEN_TIMELINES || {};
+          var tlObj = window[ns];
+          Object.keys(tlObj).forEach(function (k) {
+            window.SCREEN_TIMELINES[ns + '_' + k] = tlObj[k];
+          });
+          delete window[ns];
+        }
+      }
+      // 3. 按 picks 选屏注入 stage(屏 data-tl 改为节前缀命名,避免全局 timeline key 冲突)
+      var all = doc.querySelectorAll('section.screen');
+      var picks = page.picks || null;
+      var chosen = [];
+      if (picks && picks.length) {
+        picks.forEach(function (idx) { if (all[idx]) chosen.push(all[idx]); });
+      } else {
+        chosen = Array.prototype.slice.call(all);
+      }
+      var stage = document.querySelector('.shell-stage') || document.body;
+      chosen.forEach(function (sec) {
+        // 克隆到总控 DOM(保留原样,动画/计时靠 SCREEN_TIMELINES + gsap)
+        var clone = doc.importNode(sec, true);
+        // data-tl 加节前缀:__E<i>_<原tl>,对应下方 timeline 的节级命名
+        var t = clone.getAttribute('data-tl');
+        if (t) clone.setAttribute('data-tl', ns + '_' + t);
+        // 资源绝对化(基于节 HTML 绝对 URL)——video 直接 src 与 source 子元素都处理
+        clone.querySelectorAll('img').forEach(function (im) { absolutize(baseURL, im, 'src'); });
+        clone.querySelectorAll('video[src]').forEach(function (vd) { absolutize(baseURL, vd, 'src'); });
+        clone.querySelectorAll('video source').forEach(function (im) { absolutize(baseURL, im, 'src'); });
+        clone.querySelectorAll('a[href]').forEach(function (a) { absolutize(baseURL, a, 'href'); });
+        stage.appendChild(clone);
+      });
+      // 3b. 注入页面级 JSON 数据块(如 tt-data / 商品数据),供动态屏 IIFE 读取。
+      //     deck-config 是页内导航,不注入;id 冲突时第一个生效(跨节数据各自独立)。
+      doc.querySelectorAll('script[type="application/json"]').forEach(function (jb) {
+        if (/deck-config/.test(jb.textContent || '')) return;
+        var jid = jb.getAttribute('id');
+        if (jid && document.getElementById(jid)) return;   // 已注入(首章优先)
+        stage.appendChild(doc.importNode(jb, true));
+      });
+      // 4. 注入完成后执行节的其他内联脚本(数据/渲染函数/初始化)。
+      //    此刻页面的 JSON 数据块与各 grid/详情容器已在总控 DOM,页内 IIFE
+      //    (KOC 矩阵 / 商品墙 / 六国商品等立即渲染)能正常找到目标元素。
+      //    排除:deck-config JSON、SCREEN_TIMELINES 块、外链脚本、deck.init 引导块。
+      execInlineScripts(doc, file);
+      embedPages[i] = { page: page, count: chosen.length };
+      if (done) done();
+    });
+  }
+
+  function checkEmbedReady() {
+    var loaded = embedPages.filter(function (p) { return p; }).length;
+    if (loaded === shellPlan.pages.length) {
+      // 全部注入完成 → 用单页模式播放(屏状态机),进度按所有注入屏
+      embedReady = true;
+      collect();
+      if (total === 0) return;
+      screens.forEach(function (s) {
+        s.el.style.position = 'absolute';
+        s.el.style.inset = '0';
+      });
+      // 复用单页导航(无 topbar 页内导航,进度用总控 renderShellChrome)。
+      // 键盘不放这里绑:embed 模式下 bindShellKeys 已在 document 上监听 keydown,
+      // 再 bindKeys 会对同一次 ↓/↑ 触发两次 next/prev(每按一次跳两屏,
+      // 播放位置与页码/章节错位 —— 2026-08-30 回归修复)。点击/滚轮/触摸无此冲突。
+      bindClick();
+      bindMouse();
+      bindTouch();
+      showScreen(0);
+      activate(screens[0]);
+      renderShellChrome();
+    }
+  }
+
+  function initEmbed() {
+    embedMode = true;
+    embedPages = [];
+    // 若 http 可用 fetch 则注入;file:// 下 fetch 受限 → 提示用 http
+    if (location.protocol === 'file:') {
+      var msg = document.createElement('div');
+      msg.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;font:14px var(--font-mono);color:var(--text-3);text-align:center;padding:40px;';
+      msg.textContent = 'embed(JS注入)模式需要 http 访问(file:// 下 fetch 受限)。请用 http 打开总控页。';
+      document.body.appendChild(msg);
+      return;
+    }
+    // 串行加载:保证注入 DOM 顺序 = plan.pages 顺序(并发 fetch 会乱序导致故事线错位)
+    var embIdx = 0;
+    function nextEmb() {
+      if (embIdx >= shellPlan.pages.length) { checkEmbedReady(); return; }
+      embedChapter(shellPlan.pages[embIdx], embIdx, nextEmb);
+      embIdx++;
+    }
+    nextEmb();
+  }
+
+  function loadPage(i, visible) {
+    if (i < 0 || i >= shellPlan.pages.length) return;
+    if (shellFrames[i]) return;              // 不重复创建;不销毁(翻回保留状态)
+    var p = shellPlan.pages[i];
+    var fr = document.createElement('iframe');
+    fr.className = 'shell-frame';
+    fr.setAttribute('data-page', String(i));
+    fr.setAttribute('aria-label', p.title || ('第 ' + (i + 1) + ' 页'));
+    fr.title = p.file || '';
+    // 隐藏用 visibility:hidden 而非 display:none:保留布局尺寸,避免 mermaid/logic-flow
+    // 在 0×0 上下文渲染产生 NaN transform;不可见也不可交互。
+    fr.style.visibility = visible ? 'visible' : 'hidden';
+    fr.__createdVisible = !!visible;
+    fr.src = p.file;                         // 相对总控页
+    var stage = document.querySelector('.shell-stage') || document.body;
+    stage.appendChild(fr);
+    shellFrames[i] = fr;
+    fr.addEventListener('load', function () {
+      postToFrame(fr, { type: 'deck-register' });   // 确认容器模式(页收到后才接管翻章)
+    });
+  }
+
+  function showPage(i) {
+    if (i < 0 || i >= shellPlan.pages.length) return;
+    if (!shellFrames[i]) loadPage(i, true);  // 未预加载 → 直接创建并可见(加载时自然播放入场)
+    shellFrames.forEach(function (f, j) {
+      if (f) f.style.visibility = (j === i) ? 'visible' : 'hidden';
+    });
+    var first = !shellShown[i];
+    shellShown[i] = true;
+    shellCur = i;
+    var p = shellPlan.pages[i];
+    if (first && !shellFrames[i].__createdVisible) {
+      // 预加载(隐藏)期间入场已悄悄放完 → 首次展示重演入场,翻页无白屏
+      // 若编排定义了屏级切片 [from,to] → 下发 range 让页内从起始屏开始并在边界自动翻章
+      if (typeof p.from === 'number' || typeof p.to === 'number') {
+        postToFrame(shellFrames[i], { type: 'deck-cmd', cmd: 'range',
+          from: (typeof p.from === 'number' ? p.from : 0),
+          to: (typeof p.to === 'number' ? p.to : -1) });
+      } else {
+        postToFrame(shellFrames[i], { type: 'deck-cmd', cmd: 'jump', index: 0 });
+      }
+    }
+    loadPage(i + 1, false);                  // 预加载下一页(隐藏)
+    try { shellFrames[i].contentWindow.focus(); } catch (e) {}   // 键盘焦点进 iframe
+    renderShellChrome();
+  }
+
+  // 有效屏数/已完成屏数(支持 plan 屏级切片 from/to)
+  function effTotal(p, st) {
+    var raw = p.screens || (st && st.total) || 0;
+    if (!raw) return 0;
+    var from = (typeof p.from === 'number') ? p.from : 0;
+    var to = (typeof p.to === 'number') ? Math.min(p.to, raw - 1) : raw - 1;
+    if (to < from) return 0;
+    return to - from + 1;
+  }
+  function effDone(p, st) {
+    var raw = p.screens || (st && st.total) || 0;
+    if (!raw) return 0;
+    var idx = st ? st.index : 0;
+    var from = (typeof p.from === 'number') ? p.from : 0;
+    var to = (typeof p.to === 'number') ? Math.min(p.to, raw - 1) : raw - 1;
+    if (idx < from) return 0;
+    return Math.min(to - from + 1, idx - from + 1);
+  }
+
+  function renderShellChrome() {
+    var pages = shellPlan.pages;
+    var tabs = document.querySelectorAll('.shell-tab');
+    // embed 模式:注入后所有屏 flat 排列,按当前屏反推所在节高亮
+    if (embedMode && embedReady) {
+      var curScreen = current;
+      var acc = 0, curPage = 0;
+      for (var q = 0; q < pages.length; q++) {
+        var cnt = (embedPages[q] && embedPages[q].count) || 0;
+        if (curScreen < acc + cnt) { curPage = q; break; }
+        acc += cnt;
+        curPage = q + 1;
+      }
+      tabs.forEach(function (b, j) { b.classList.toggle('cur', j === curPage); });
+      var totalE = total, doneE = Math.min(totalE, curScreen + 1);
+      var bar = document.querySelector('.shell-bottom .deck-progress .bar');
+      var pos = document.querySelector('.shell-pos');
+      var pn = document.querySelector('.shell-pagename');
+      if (bar) bar.style.width = Math.min(100, (doneE / totalE) * 100) + '%';
+      if (pos) pos.textContent = (curPage + 1) + '/' + pages.length + ' 节 · ' + doneE + '/' + totalE + ' 屏';
+      if (pn) pn.textContent = pages[curPage].title || '';
+      scrollActiveTabIntoView();
+      return;
+    }
+    tabs.forEach(function (b, j) { b.classList.toggle('cur', j === shellCur); });
+    // 进度按「屏幕(screen)」颗粒度计算:每节=1个 iframe,内含多屏(每屏≈30s)。
+    // 屏数优先级:plan.pages[i].screens(编排权威标注)> iframe 上报 total(实测量);
+    // plan.pages[i].from/to(屏级切片)只计切片内的有效屏。
+    var totalScreens = 0, doneScreens = 0, loadedAny = false;
+    pages.forEach(function (p, i) {
+      var st = shellLast[i];
+      var total = effTotal(p, st);
+      if (!total) return;
+      loadedAny = true;
+      totalScreens += total;
+      if (i < shellCur) doneScreens += total;          // 已翻完的节:全屏计入
+      else if (i === shellCur) doneScreens += effDone(p, st);
+    });
+    var bar = document.querySelector('.shell-bottom .deck-progress .bar');
+    var pos = document.querySelector('.shell-pos');
+    var pn = document.querySelector('.shell-pagename');
+    if (loadedAny && totalScreens > 0) {
+      if (bar) bar.style.width = Math.min(100, (doneScreens / totalScreens) * 100) + '%';
+      if (pos) pos.textContent = (shellCur + 1) + '/' + pages.length + ' 节 · ' + Math.min(doneScreens, totalScreens) + '/' + totalScreens + ' 屏';
+    } else {
+      // 完全未知 → 按节回退(首屏上报前短暂过渡)
+      var done = 0;
+      pages.forEach(function (p, i) {
+        if (i < shellCur) { done += 1; return; }
+        if (i === shellCur) {
+          var st2 = shellLast[i];
+          if (st2 && st2.total) done += Math.min(1, ((st2.index || 0) + 1) / st2.total);
+        }
+      });
+      if (bar) bar.style.width = ((done / pages.length) * 100) + '%';
+      if (pos) pos.textContent = (shellCur + 1) + ' / ' + pages.length + ' 页';
+    }
+    if (pn) pn.textContent = pages[shellCur].title || '';
+    scrollActiveTabIntoView();
+  }
+
+  function broadcastTheme() {
+    var t = window.HDTheme ? window.HDTheme.get() : (document.documentElement.getAttribute('data-theme') || 'dark');
+    shellFrames.forEach(function (f) { if (f) postToFrame(f, { type: 'deck-cmd', cmd: 'theme', theme: t }); });
+  }
+
+  function bindShellKeys() {
+    document.addEventListener('keydown', function (e) {
+      var t = e.target;
+      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
+      if (t && t.closest && (t.closest('button') || t.closest('a'))) return;   // 焦点在按钮上交给原生
+      var k = e.key;
+      if (k === 'ArrowDown' || k === 's' || k === 'S' || k === 'PageDown' || k === ' ') {
+        e.preventDefault(); if (embedMode && embedReady) next(); else cmdActive('next');
+      } else if (k === 'ArrowUp' || k === 'w' || k === 'W' || k === 'PageUp') {
+        e.preventDefault(); if (embedMode && embedReady) prev(); else cmdActive('prev');
+      } else if (k === 'ArrowRight' || k === 'd' || k === 'D') {
+        if (embedMode && embedReady) nextChapter(); else showPage(shellCur + 1);
+      } else if (k === 'ArrowLeft' || k === 'a' || k === 'A') {
+        if (embedMode && embedReady) prevChapter(); else showPage(shellCur - 1);
+      } else if (k === 'Home') {
+        e.preventDefault();
+        if (embedMode && embedReady) jump(0); else cmdActive('home');
+      } else if (k === 'End') {
+        e.preventDefault();
+        if (embedMode && embedReady) jump(total - 1); else cmdActive('end');
+      }
+    });
+  }
+
+  /* ---------- 手机端:右下角「全屏」切换(不旋转——手机自带横屏) ---------- */
+  function isMobileUA() {
+    return /Android|iPhone|iPad|iPod|Mobile|Mobi|Opera Mini|IEMobile/i.test(navigator.userAgent)
+      || (navigator.platform && /iPhone|iPad|iPod|Android/i.test(navigator.platform));
+  }
+
+  var fullscreenBtn = null;
+
+  function ensureFullscreenStyle() {
+    if (document.getElementById('shell-fullscreen-style')) return;
+    var st = document.createElement('style');
+    st.id = 'shell-fullscreen-style';
+    st.textContent =
+      // ============ 全屏:不旋转舞台,16:9 等比 fit 视口居中(letterbox) ============
+      // 手机系统自带横屏:用户转手机即横屏。全屏模式只负责放大 + 隐藏系统 UI,
+      // 舞台始终按 16:9 contain(--fs-w/--fs-h 由 JS 计算),避免拉伸变形。
+      'html.shell-fullscreen .shell-stage{'
+      + 'position:fixed;top:50%;left:50%;'
+      + 'transform:translate(-50%,-50%);'
+      + 'width:var(--fs-w,100vw);height:var(--fs-h,100vh);'
+      + 'background:var(--bg-0);'
+      + 'box-shadow:0 0 0 2000px var(--bg-0);}'
+      // 顶栏/底栏:全屏时保持可用,收窄/半透明,不遮挡内容
+      + 'html.shell-fullscreen .shell-topbar{'
+      + 'opacity:.9;}'
+      + 'html.shell-fullscreen .shell-bottom{'
+      + 'opacity:.9;}'
+      // 常规右下角全屏按钮
+      + '.shell-fs-btn{position:fixed;right:14px;bottom:56px;z-index:80;'
+      + 'pointer-events:auto;cursor:pointer;'
+      + 'font:600 12px var(--font-mono);color:var(--text-3);'
+      + 'background:var(--glass-bg);border:1px solid var(--border-subtle);'
+      + 'border-radius:var(--radius-pill);padding:5px 12px;'
+      + 'backdrop-filter:blur(var(--glass-blur));opacity:.82;'
+      + 'box-shadow:0 2px 8px rgba(0,0,0,.18);}'
+      + '.shell-fs-btn:active{opacity:1;}'
+      // 窄屏折叠导航:汉堡按钮 + 下拉面板
+      + '.shell-collapsed .deck-tabs{'
+      + 'position:fixed;top:52px;left:12px;right:12px;'
+      + 'flex-direction:column;align-items:stretch;gap:4px;'
+      + 'background:var(--bg-1);border:1px solid var(--border-subtle);'
+      + 'border-radius:var(--radius-md);padding:8px;'
+      + 'display:none;max-height:62vh;overflow-y:auto;'
+      + 'box-shadow:0 8px 28px rgba(0,0,0,.35);z-index:60;}'
+      + '.shell-collapsed .deck-tab{width:100%;text-align:left;padding:8px 12px;border-radius:var(--radius-sm);}'
+      + '.shell-collapsed.shell-open .deck-tabs{display:flex;}'
+      + '.shell-burger{pointer-events:auto;display:inline-flex;align-items:center;justify-content:center;'
+      + 'width:34px;height:30px;padding:0;margin-right:4px;flex-shrink:0;'
+      + 'color:var(--text-3);border:1px solid var(--border-subtle);'
+      + 'border-radius:var(--radius-pill);background:var(--glass-bg);cursor:pointer;}'
+      + '.shell-burger:hover{color:var(--brand-1);border-color:var(--border-strong);}';
+    document.head.appendChild(st);
+  }
+
+  function applyFullscreenSize() {
+    // 舞台 16:9 contain 于视口(不旋转):宽高按视口 fit,letterbox 留黑边
+    var vw = window.innerWidth, vh = window.innerHeight;
+    var w, h;
+    if (vw / vh > 16 / 9) { h = vh; w = h * 16 / 9; }
+    else { w = vw; h = w * 9 / 16; }
+    var el = document.documentElement;
+    el.style.setProperty('--fs-w', Math.round(w) + 'px');
+    el.style.setProperty('--fs-h', Math.round(h) + 'px');
+  }
+
+  function toggleFullscreen() {
+    var el = document.documentElement;
+    var inFs = el.classList.contains('shell-fullscreen');
+    if (!inFs) {
+      applyFullscreenSize();
+      el.classList.add('shell-fullscreen');
+      var fs = el.requestFullscreen || el.webkitRequestFullscreen || el.msRequestFullscreen;
+      if (fs) { try { fs.call(el); } catch (e) {} }
+      if (fullscreenBtn) fullscreenBtn.textContent = '退出全屏';
+    } else {
+      el.classList.remove('shell-fullscreen');
+      var ex = document.exitFullscreen || document.webkitExitFullscreen || document.msExitFullscreen;
+      if (ex && (document.fullscreenElement || document.webkitFullscreenElement)) {
+        try { ex.call(document); } catch (e) {}
+      }
+      if (fullscreenBtn) fullscreenBtn.textContent = '全屏';
+    }
+  }
+
+  function buildFullscreenBtn() {
+    if (fullscreenBtn) return;
+    ensureFullscreenStyle();
+    var b = document.createElement('button');
+    b.className = 'shell-fs-btn';
+    b.type = 'button';
+    b.textContent = '全屏';
+    b.title = '切换全屏(手机端)';
+    b.addEventListener('click', toggleFullscreen);
+    document.body.appendChild(b);
+    fullscreenBtn = b;
+    // 全屏状态变化时同步按钮文案(浏览器返回键退出全屏等)
+    document.addEventListener('fullscreenchange', syncFullscreenBtn);
+    document.addEventListener('webkitfullscreenchange', syncFullscreenBtn);
+    // 尺寸变化时重算 16:9(转屏、全屏高度变化)
+    window.addEventListener('resize', function () {
+      if (document.documentElement.classList.contains('shell-fullscreen')) applyFullscreenSize();
+    });
+    window.addEventListener('orientationchange', function () {
+      setTimeout(function () {
+        if (document.documentElement.classList.contains('shell-fullscreen')) applyFullscreenSize();
+      }, 300);
+    });
+  }
+
+  function syncFullscreenBtn() {
+    if (!fullscreenBtn) return;
+    var inFs = !!(
+      document.fullscreenElement || document.webkitFullscreenElement || document.msFullscreenElement
+    );
+    fullscreenBtn.textContent = inFs ? '退出全屏' : '全屏';
+  }
+
+  function cmdActive(cmd) {
+    var f = shellFrames[shellCur];
+    if (f) postToFrame(f, { type: 'deck-cmd', cmd: cmd });
+  }
+
+  /* 总控侧:接收页面上报 */
+  function onShellMsg(e) {
+    var d = e.data;
+    if (!d || typeof d !== 'object') return;
+    var idx = -1;                              // e.source 是 iframe 的 window,需与 contentWindow 比对
+    for (var k = 0; k < shellFrames.length; k++) {
+      if (shellFrames[k] && shellFrames[k].contentWindow === e.source) { idx = k; break; }
+    }
+    if (idx < 0) return;                       // 只认自己管理的 iframe
+    if (d.type === 'deck-progress') {
+      shellLast[idx] = { index: d.index, total: d.total, state: d.state };
+      if (idx === shellCur) renderShellChrome();
+    } else if (d.type === 'deck-go') {
+      // 页内翻章(或最后一屏 ↓):按 plan 顺序切页,忽略页内 deck-config 的文件跳转
+      showPage(idx + (d.dir >= 0 ? 1 : -1));
+    }
+  }
+
+  /* 页侧:接收总控指令(总控页自身也挂此监听,靠 source 过滤互不干扰) */
+  function onPageMessage(e) {
+    var d = e.data;
+    if (!d || typeof d !== 'object') return;
+    if (e.source !== window.parent) return;    // 只接受父窗口指令
+    if (d.type === 'deck-register') {
+      shellMode = true;                        // 父窗口确认为总控容器
+      if (pendingGo) { postShell(pendingGo); pendingGo = null; }
+      return;
+    }
+    if (d.type !== 'deck-cmd') return;
+    if (d.cmd === 'next') next();
+    else if (d.cmd === 'prev') prev();
+    else if (d.cmd === 'jump') { if (typeof d.index === 'number') jump(d.index); }
+    else if (d.cmd === 'home') jump(0);
+    else if (d.cmd === 'end') jump(total - 1);
+    else if (d.cmd === 'range') {
+      // 屏级切片:总控下发 [from,to](0-based),页内翻屏被钳制在该范围,两端自动翻章
+      shellFrom = (typeof d.from === 'number') ? d.from : 0;
+      shellTo = (typeof d.to === 'number') ? d.to : -1;
+      jump(shellFrom);
+    }
+    else if (d.cmd === 'theme') { if (window.HDTheme && d.theme) window.HDTheme.set(d.theme); }
+  }
+
+  /* 字体/logo 加载后 tab 宽度变化,窗口 load 时校正一次居中(直接打开深层页的场景) */
+  window.addEventListener('load', function () { scrollActiveTabIntoView(); });
+
+  window.deck = {
+    init: init,
+    next: next,
+    prev: prev,
+    jump: jump,
+    nextChapter: nextChapter,
+    prevChapter: prevChapter,
+    goFile: goFile,
+    // 总控容器模式
+    showPage: showPage,
+    loadPage: loadPage
+  };
+})();

+ 599 - 0
skills/study-report/lib/theme/base.css

@@ -0,0 +1,599 @@
+/* ============================================================
+ * lib/theme/base.css · 全局基础层
+ * reset + 全屏 .screen 容器 + 通用演绎类(暗/浅由 tokens.css 驱动)
+ * 纪律:本文件不出现十六进制色值,只用 var(--...)。
+ * ============================================================ */
+
+/* ---------- Reset ---------- */
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+html, body { height: 100%; }
+body {
+  font-family: var(--font-body);
+  background: var(--bg-0);
+  color: var(--text-2);
+  overflow: hidden;                 /* 屏内定位,不滚动页面 */
+  -webkit-font-smoothing: antialiased;
+  line-height: 1.6;
+}
+h1,h2,h3,h4 { font-family: var(--font-display); font-weight: var(--font-display-weight); color: var(--text-1); line-height: 1.25; }
+img, video { max-width: 100%; display: block; }
+a { color: var(--brand-1); text-decoration: none; }
+button { font-family: inherit; cursor: pointer; }
+
+/* ---------- 屏幕容器(16:9 / 16:10 全屏) ---------- */
+.screen {
+  width: 100vw;
+  height: var(--screen-h);
+  overflow: hidden;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: var(--bg-0);
+}
+.screen[data-tl] > .s-wrap { visibility: hidden; }  /* 初始隐藏,时间线表演(deck 取消) */
+.screen[data-tl].in > .s-wrap { visibility: visible; }
+
+.s-wrap {
+  width: 82%;
+  max-width: 1720px;
+  height: 88%;
+  position: relative;              /* 插画/飘浮物定位锚点(absolute 元素贴内容容器而非叠放屏) */
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  gap: clamp(14px, 2.2vh, 28px);
+}
+
+/* ---------- 屏内基础元素 ---------- */
+.s-tag {
+  font-family: var(--font-mono);
+  font-size: clamp(12px, 1.2vw, 16px);
+  letter-spacing: .14em;
+  color: var(--brand-1);
+  text-transform: uppercase;
+}
+.s-title { font-size: clamp(30px, 3.6vw, 52px); }
+.s-sub   { font-size: clamp(16px, 1.5vw, 22px); color: var(--text-3); max-width: 78%; }
+.s-body  { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: clamp(12px, 1.8vh, 22px); }
+
+/* ---------- 双栏编导布局:内容列 + 插画列(插画参与布局,天然不重叠) ---------- */
+.s-split { flex: 1; min-height: 0; display: flex; flex-direction: row; align-items: center; gap: clamp(20px, 3vw, 44px); }
+.s-split > .s-main { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: clamp(12px, 1.8vh, 22px); }
+.s-split > .s-side { flex: none; width: clamp(190px, 24vw, 360px); display: flex; align-items: center; justify-content: center; }
+.s-split > .s-side svg.ill, .s-split > .s-side .ill { position: static; width: 100%; height: auto; }
+/* 窄屏(16:10 或小窗)时插画列收窄、靠边,避免挤压正文 */
+.s-split > .s-side.wide { width: clamp(240px, 30vw, 460px); }
+.s-split > .s-side.narrow { width: clamp(150px, 18vw, 280px); }
+@media (max-width: 1100px) { .s-split { gap: 16px; } .s-split > .s-side { width: clamp(150px, 20vw, 260px); } }
+/* 插画列内部:可叠加角标 / 说明文字(.s-cap 图注) */
+.s-side .s-cap { position: absolute; left: 0; right: 0; bottom: -24px; text-align: center; font-family: var(--font-mono); font-size: clamp(11px, 1vw, 14px); color: var(--text-4); }
+
+.s-foot  {
+  font-family: var(--font-mono);
+  font-size: clamp(12px, 1.1vw, 15px);
+  color: var(--text-4);
+  letter-spacing: .06em;
+  text-align: right;
+}
+
+/* 素材屏底部来源标注(放 .s-main 内,紧贴卡片下方) */
+.s-foot-note {
+  font-family: var(--font-mono);
+  font-size: clamp(10px, .9vw, 12px);
+  color: var(--text-4);
+  margin-top: 6px;
+}
+
+/* ---------- 通用卡片 / 网格 / 标签 ---------- */
+.card {
+  background: var(--bg-2);
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-card);
+  padding: clamp(16px, 2vw, 28px);
+  box-shadow: var(--shadow-card);
+}
+.card.gold   { border-color: var(--border-strong); }
+.card.warn   { border-color: var(--border-focus); }
+.card.hi     { box-shadow: var(--shadow-glow); }
+
+.grid { display: grid; gap: clamp(14px, 1.6vw, 22px); }
+.grid.cols-2 { grid-template-columns: repeat(2, 1fr); }
+.grid.cols-3 { grid-template-columns: repeat(3, 1fr); }
+.grid.cols-4 { grid-template-columns: repeat(4, 1fr); }
+
+.tag {
+  display: inline-flex; align-items: center; gap: 6px;
+  font-family: var(--font-mono);
+  font-size: clamp(11px, 1vw, 14px);
+  color: var(--brand-1);
+  border: 1px solid var(--border-strong);
+  background: var(--bg-2);
+  border-radius: var(--radius-sm);
+  padding: 2px 10px;
+  white-space: nowrap;
+}
+.tag.ok   { color: var(--brand-ok); border-color: rgba(127,209,160,.4); }
+.tag.brand-2 { color: var(--brand-2); border-color: rgba(175,138,232,.4); }
+
+.notice {
+  font-size: clamp(15px, 1.4vw, 19px);
+  color: var(--text-2);
+  border-left: 3px solid var(--brand-1);
+  padding: 6px 0 6px 16px;
+  background: var(--bg-2);
+}
+.notice.warn { border-left-color: var(--brand-3); color: var(--brand-3); font-weight: 600; }
+
+/* ---------- 模型图例(显化思维模型:符号 / 颜色含义) ----------
+   用法:模型图下方放 `.model-legend`,内放 `.model-legend .li`(`.sw` 色块 / `.ln` 线 / `.sym` 符号 + 文字)。
+   图例是「让模型可读」的最小契约:观众先看懂符号,再读图。 */
+.model-legend {
+  display: flex; flex-wrap: wrap; gap: clamp(6px, .8vw, 10px) clamp(14px, 2vw, 28px);
+  align-items: center; justify-content: center;
+  padding: clamp(8px, 1.2vh, 12px) clamp(14px, 1.6vw, 22px);
+  background: var(--bg-1); border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-sm);
+  font-family: var(--font-mono); font-size: clamp(11px, 1vw, 14px);
+  color: var(--text-3);
+}
+.model-legend .li { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; }
+.model-legend .li b { color: var(--text-2); font-weight: 600; }
+.model-legend .sw { width: 15px; height: 15px; border-radius: 3px; flex: none; border: 1px solid var(--border-subtle); }
+.model-legend .sw.gold   { background: var(--brand-1); }
+.model-legend .sw.purple { background: var(--brand-2); }
+.model-legend .sw.red    { background: var(--brand-3); }
+.model-legend .sw.green  { background: var(--brand-ok); }
+.model-legend .sw.gray   { background: var(--bg-3); border-color: var(--border-subtle); }
+.model-legend .sw.line   { background: transparent; border: 1.5px solid var(--brand-1); }
+.model-legend .sw.dash   { background: transparent; border: 1.5px dashed var(--brand-3); }
+.model-legend .ln { width: 20px; height: 0; border-top: 3px solid var(--brand-2); flex: none; }
+.model-legend .ln.red  { border-color: var(--brand-3); }
+.model-legend .ln.gold { border-color: var(--brand-1); }
+.model-legend .ln.dash { border-top-style: dashed; }
+.model-legend .sym { font-size: clamp(13px, 1.1vw, 16px); color: var(--brand-1-hi); font-weight: 700; flex: none; line-height: 1; }
+.model-legend .sym.red { color: var(--brand-3); }
+
+/* 模型标签:模型图上方的「点名」小标签(编号 + 模型名) */
+.model-tag {
+  display: inline-flex; align-items: center; gap: 8px;
+  font-family: var(--font-mono); font-size: clamp(11px, 1vw, 14px);
+  color: var(--brand-1); letter-spacing: .12em;
+  border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
+  padding: 4px 12px; margin-bottom: 4px;
+}
+.model-tag .no { color: var(--brand-2); }
+.model-tag.green { color: var(--brand-ok); border-color: rgba(127,209,160,.4); }
+
+/* ---------- 思维模型卡(显化思维模型:点名屏) ----------
+   结构:.model-map(grid) > .mm-item(card) > .mm-n(模型名) + .mm-t(核心判断) + .mm-d(展开)。
+   层式模型化:编号模型名(mono)+ 核心判断(display 大字)+ 展开(正文),品牌强调色描边,暗/浅双主题。 */
+.model-map {
+  display: grid; gap: clamp(12px, 1.6vw, 20px); width: 100%;
+}
+.model-map.cols { grid-template-columns: 1fr 1fr; }
+.mm-item {
+  position: relative;
+  background: linear-gradient(180deg, var(--bg-2), var(--bg-1));
+  border: 1px solid var(--border-subtle);
+  border-left: 4px solid var(--brand-2);
+  border-radius: var(--radius-card);
+  padding: clamp(14px, 1.8vw, 24px) clamp(16px, 2vw, 28px);
+  box-shadow: var(--shadow-card);
+}
+.mm-item.hot  { border-color: var(--border-strong); border-left-color: var(--brand-1); }
+.mm-item.warn { border-left-color: var(--brand-3); }
+.mm-item.ok   { border-left-color: var(--brand-ok); }
+.mm-item .mm-n {
+  font-family: var(--font-mono); font-size: clamp(11px, 1vw, 14px);
+  color: var(--brand-2); letter-spacing: .12em; font-weight: 700;
+}
+.mm-item.hot .mm-n { color: var(--brand-1); }
+.mm-item .mm-t {
+  font-family: var(--font-display); font-weight: var(--font-display-weight);
+  font-size: clamp(17px, 1.9vw, 26px); color: var(--text-1);
+  margin: 6px 0 8px; line-height: 1.35;
+}
+.mm-item .mm-d { font-size: clamp(13px, 1.25vw, 17px); color: var(--text-2); line-height: 1.75; }
+.mm-item .mm-d b { color: var(--brand-1); font-weight: 600; }
+.mm-item .mm-d .hl { color: var(--brand-1-hi); }
+
+/* ---------- 数据速览(stat-band) ---------- */
+.stat-band { display: flex; gap: clamp(20px, 3vw, 48px); align-items: stretch; }
+.stat {
+  flex: 1; min-width: 0;
+  background: var(--bg-2);
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-card);
+  padding: clamp(14px, 1.8vw, 24px);
+}
+.stat .num {
+  font-family: var(--font-mono);
+  font-size: clamp(34px, 4.6vw, 68px);
+  font-weight: 800;
+  color: var(--brand-1);
+  line-height: 1.1;
+  font-variant-numeric: tabular-nums;
+}
+.stat .num.hero { color: var(--text-1); }
+.stat .label { margin-top: 6px; font-size: clamp(12px, 1.1vw, 15px); color: var(--text-3); }
+.stat .src { margin-top: 2px; font-size: clamp(10px, .9vw, 12px); color: var(--text-4); }
+
+/* ---------- 链路 / 流程(chain) ---------- */
+.chain { display: flex; align-items: stretch; gap: clamp(10px, 1.2vw, 18px); }
+.chain .node {
+  flex: 1; text-align: center;
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-card);
+  padding: clamp(12px, 1.6vw, 22px);
+  background: var(--bg-2);
+  position: relative;
+}
+.chain .node .k { font-family: var(--font-mono); font-size: clamp(10px, .95vw, 13px); color: var(--text-4); letter-spacing: .12em; }
+.chain .node .v { font-size: clamp(14px, 1.3vw, 18px); color: var(--text-1); font-weight: 600; margin-top: 4px; }
+.chain .node.on  { border-color: var(--border-strong); box-shadow: var(--shadow-glow); }
+.chain .node.wall { border-color: rgba(255,0,0,.45); }
+.chain .arrow {
+  display: flex; align-items: center; color: var(--brand-1-hi);
+  font-size: clamp(16px, 1.5vw, 22px); font-weight: 700;
+}
+
+/* ---------- 左右对比(duel) ---------- */
+.duel { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(16px, 2.2vw, 32px); align-items: stretch; }
+.duel .side {
+  border-radius: var(--radius-card);
+  padding: clamp(16px, 2vw, 28px);
+  border: 1px solid var(--border-subtle);
+  background: var(--bg-2);
+}
+.duel .side.bad  { border-color: rgba(255,0,0,.45); }
+.duel .side.good { border-color: var(--border-strong); box-shadow: var(--shadow-glow); }
+.duel .side h3 { font-size: clamp(16px, 1.5vw, 22px); margin-bottom: 10px; }
+.duel .side.bad h3 { color: var(--brand-3); }
+.duel .side.good h3 { color: var(--brand-1); }
+.duel .side .vs { font-family: var(--font-mono); color: var(--text-4); font-size: clamp(12px, 1vw, 14px); letter-spacing: .1em; }
+
+/* ---------- 金句(quote-line) ---------- */
+.quote-line {
+  font-family: var(--font-display);
+  font-size: clamp(24px, 2.8vw, 40px);
+  font-weight: var(--font-display-weight);
+  color: var(--text-1);
+  line-height: 1.4;
+}
+.quote-line .hl { background: var(--gradient-text); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
+.quote-line .src { display: block; margin-top: 10px; font-family: var(--font-mono); font-size: clamp(11px, 1vw, 14px); color: var(--text-4); -webkit-text-fill-color: var(--text-4); }
+
+/* ---------- 诚实边界(truth-table) ---------- */
+.truth-table { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(16px, 2vw, 28px); }
+.truth-table .col { border-radius: var(--radius-card); padding: clamp(16px, 2vw, 26px); background: var(--bg-2); border: 1px solid var(--border-subtle); }
+.truth-table .col.confirmed { border-color: rgba(127,209,160,.4); }
+.truth-table .col.pending   { border-color: rgba(175,138,232,.4); }
+.truth-table .col h3 { font-size: clamp(15px, 1.4vw, 20px); margin-bottom: 10px; }
+.truth-table .confirmed h3 { color: var(--brand-ok); }
+.truth-table .pending h3   { color: var(--brand-2); }
+.truth-table li { list-style: none; padding: 4px 0 4px 18px; position: relative; font-size: clamp(13px, 1.2vw, 17px); color: var(--text-2); }
+.truth-table li::before { content: attr(data-mark); position: absolute; left: 0; font-family: var(--font-mono); }
+
+/* ---------- 步骤带(chip-steps) ---------- */
+.chip-steps { display: flex; gap: clamp(8px, .9vw, 14px); flex-wrap: wrap; }
+.chip {
+  font-family: var(--font-mono);
+  font-size: clamp(13px, 1.2vw, 17px);
+  padding: 8px 18px;
+  border-radius: var(--radius-pill);
+  border: 1px solid var(--border-subtle);
+  background: var(--bg-2);
+  color: var(--text-2);
+}
+.chip.on   { border-color: var(--border-strong); color: var(--brand-1); box-shadow: var(--shadow-glow); }
+.chip.warn { border-color: rgba(255,0,0,.45); color: var(--brand-3); }
+.chip .n { opacity: .6; margin-right: 6px; }
+
+/* ---------- 业务三环(loop3) ---------- */
+.loop3 { display: flex; justify-content: center; align-items: center; gap: clamp(18px, 2.4vw, 34px); }
+.loop3 .ring {
+  width: clamp(120px, 15vw, 210px); height: clamp(120px, 15vw, 210px);
+  border-radius: 50%;
+  border: 2px dashed var(--border-subtle);
+  display: flex; flex-direction: column; align-items: center; justify-content: center;
+  gap: 4px; text-align: center; padding: 10px;
+  background: var(--bg-2);
+}
+.loop3 .ring.on { border-color: var(--border-strong); border-style: solid; box-shadow: var(--shadow-glow); }
+.loop3 .ring .t { font-size: clamp(14px, 1.3vw, 18px); color: var(--text-1); font-weight: 700; }
+.loop3 .ring .d { font-size: clamp(10px, .95vw, 13px); color: var(--text-3); }
+.loop3 .loop-arrow { font-size: clamp(18px, 1.8vw, 26px); color: var(--brand-1-hi); font-weight: 700; }
+
+/* ---------- 反转序列(reversal) ---------- */
+.reversal { display: flex; flex-direction: column; gap: clamp(10px, 1.4vh, 16px); }
+.reversal .rv {
+  display: flex; align-items: baseline; gap: 14px;
+  border: 1px solid var(--border-subtle);
+  border-left: 3px solid var(--brand-2);
+  border-radius: var(--radius-card);
+  padding: clamp(10px, 1.4vw, 16px) clamp(16px, 1.8vw, 24px);
+  background: var(--bg-2);
+}
+.reversal .rv.on { border-left-color: var(--brand-1); }
+.reversal .rv .n { font-family: var(--font-mono); color: var(--brand-2); font-size: clamp(14px, 1.3vw, 18px); font-weight: 700; }
+.reversal .rv.on .n { color: var(--brand-1); }
+.reversal .rv .t { font-size: clamp(14px, 1.3vw, 18px); color: var(--text-2); }
+
+/* ---------- 目录卡(toccard) ---------- */
+.toccard-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: clamp(14px, 1.6vw, 22px); }
+.toccard {
+  display: block;
+  background: var(--bg-2);
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-card);
+  padding: clamp(16px, 1.8vw, 24px);
+  color: var(--text-2);
+  transition: transform .3s var(--ease-out), border-color .3s, box-shadow .3s;
+}
+.toccard:hover { transform: translateY(-4px); border-color: var(--border-strong); box-shadow: var(--shadow-glow); }
+.toccard .no { font-family: var(--font-mono); color: var(--brand-1); font-size: clamp(11px, 1vw, 14px); letter-spacing: .12em; }
+.toccard .ti { font-size: clamp(16px, 1.5vw, 21px); font-family: var(--font-display); color: var(--text-1); font-weight: var(--font-display-weight); margin: 6px 0 4px; }
+.toccard .de { font-size: clamp(12px, 1.1vw, 15px); color: var(--text-3); }
+
+/* ---------- 画像对拍(profile-match) ---------- */
+.match { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(16px, 2vw, 28px); }
+.match .col { border-radius: var(--radius-card); border: 1px solid var(--border-subtle); background: var(--bg-2); padding: clamp(16px, 1.8vw, 24px); }
+.match .col h3 { font-size: clamp(15px, 1.4vw, 20px); margin-bottom: 12px; }
+.match .col.worker h3 { color: var(--brand-1); }
+.match .col.job h3 { color: var(--brand-2); }
+.match .col .tags { display: flex; flex-wrap: wrap; gap: 8px; }
+.match .col .tags .tag { opacity: .55; }
+.match .col .tags .tag.on { opacity: 1; }
+
+/* ---------- 通用演绎类 · 增补(卡片/对比/引文/数据) ---------- */
+.card.ok    { border-color: var(--border-ok); }
+.card.danger{ border-color: var(--border-danger); }
+.card .k {
+  font-family: var(--font-mono);
+  font-size: clamp(11px, .95vw, 13px);
+  letter-spacing: .14em;
+  color: var(--brand-1);
+  margin-bottom: 8px;
+  text-transform: uppercase;
+}
+.card .k.red   { color: var(--brand-3); }
+.card .k.gold  { color: var(--brand-1-hi); }
+.card .k.green { color: var(--brand-ok); }
+.card .k.purple{ color: var(--brand-2); }
+
+.s-tag.red   { color: var(--brand-3); }
+.s-tag.purple{ color: var(--brand-2); }
+.hl { background: var(--gradient-text); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
+.red   { color: var(--brand-3); }
+.green { color: var(--brand-ok); }
+
+/* ---------- 金句卡(quote) ---------- */
+.quote {
+  border-left: 3px solid var(--brand-1);
+  background: var(--bg-2);
+  padding: clamp(14px, 1.8vw, 22px) clamp(18px, 2vw, 28px);
+  border-radius: var(--radius-card);
+}
+.quote p {
+  font-family: var(--font-display);
+  font-size: clamp(18px, 1.9vw, 26px);
+  color: var(--text-1);
+  line-height: 1.5;
+  font-weight: var(--font-display-weight);
+}
+.quote p b { color: var(--brand-1); }
+.quote .q-src {
+  display: block;
+  margin-top: 10px;
+  font-family: var(--font-mono);
+  font-size: clamp(11px, 1vw, 14px);
+  color: var(--text-4);
+}
+
+/* ---------- 左右分栏(split) ---------- */
+.split { display: grid; grid-template-columns: 1.1fr 1fr; gap: clamp(18px, 2.2vw, 32px); align-items: center; }
+.split > * { min-width: 0; }
+
+/* ---------- 编号步骤带(steps) ---------- */
+.steps { display: flex; gap: clamp(8px, .9vw, 14px); align-items: stretch; flex-wrap: wrap; }
+.step {
+  flex: 1; min-width: 150px;
+  border: 1px solid var(--border-subtle);
+  background: var(--bg-2);
+  border-radius: var(--radius-card);
+  padding: clamp(12px, 1.4vw, 18px);
+  position: relative;
+}
+.step .s-n { font-family: var(--font-mono); font-size: clamp(11px, 1vw, 14px); color: var(--brand-1); }
+.step .s-n.red { color: var(--brand-3); }
+.step .s-t { font-size: clamp(14px, 1.3vw, 18px); color: var(--text-1); font-weight: 600; margin: 4px 0 4px; }
+.step p { font-size: clamp(11px, 1vw, 14px); color: var(--text-3); }
+.step::after {
+  content: '→';
+  position: absolute; right: -9px; top: 50%;
+  transform: translateY(-50%);
+  color: var(--brand-1-hi);
+  font-family: var(--font-mono);
+  z-index: 2;
+}
+.step:last-child::after { content: ''; }
+
+/* ---------- 前后对比(ba) ---------- */
+.ba { display: grid; grid-template-columns: 1fr 46px 1fr; align-items: stretch; }
+.ba-box {
+  border: 1px solid var(--border-subtle);
+  background: var(--bg-2);
+  border-radius: var(--radius-card);
+  padding: clamp(14px, 1.6vw, 22px);
+}
+.ba-before { border-color: var(--border-danger); }
+.ba-after  { border-color: var(--border-strong); box-shadow: var(--shadow-glow); }
+.ba-arrow  { display: flex; align-items: center; justify-content: center; font-size: clamp(18px, 1.8vw, 26px); color: var(--brand-1-hi); }
+.ba-box .bh { font-family: var(--font-mono); font-size: clamp(10px, .95vw, 13px); letter-spacing: .12em; margin-bottom: 8px; color: var(--brand-3); }
+.ba-after .bh { color: var(--brand-1); }
+.ba-box .bv { font-size: clamp(13px, 1.2vw, 17px); font-weight: 600; color: var(--text-1); line-height: 1.55; }
+.ba-box .bv b { color: var(--brand-1); }
+
+/* ---------- 业务三环 · 方块变体(loop3.l3) ---------- */
+.loop3 .l3 {
+  width: clamp(150px, 18vw, 260px);
+  border: 1px solid var(--border-subtle);
+  background: var(--bg-2);
+  border-radius: var(--radius-card);
+  padding: clamp(14px, 1.8vw, 22px);
+  text-align: center;
+}
+.loop3 .l3.on { border-color: var(--border-strong); box-shadow: var(--shadow-glow); }
+.loop3 .l3-k { font-family: var(--font-mono); font-size: clamp(10px, .9vw, 13px); color: var(--brand-2); letter-spacing: .12em; }
+.loop3 .l3.on .l3-k { color: var(--brand-1); }
+.loop3 .l3-t { font-size: clamp(15px, 1.4vw, 20px); color: var(--text-1); font-weight: 700; margin: 4px 0; }
+.loop3 .l3 p { font-size: clamp(11px, 1vw, 14px); color: var(--text-3); }
+
+/* ---------- 反转序列 · 正文变体 ---------- */
+.reversal .rv .d { font-size: clamp(12px, 1.1vw, 15px); color: var(--text-3); margin-top: 2px; }
+
+/* ---------- 翻车版 vs 人味版(rw) ---------- */
+.rw { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(12px, 1.4vw, 18px); }
+.rw-box { border: 1px solid var(--border-subtle); border-radius: var(--radius-card); padding: clamp(14px, 1.6vw, 20px); }
+.rw-o { border-color: var(--border-danger); }
+.rw-n { border-color: var(--border-ok); }
+.rw-h { font-family: var(--font-mono); font-size: clamp(10px, .95vw, 13px); letter-spacing: .1em; margin-bottom: 8px; color: var(--brand-3); }
+.rw-n .rw-h { color: var(--brand-ok); }
+.rw-t { font-size: clamp(13px, 1.2vw, 17px); line-height: 1.7; color: var(--text-1); }
+.rw-t b { color: var(--brand-1); }
+.rw-d { font-size: clamp(11px, 1vw, 14px); color: var(--text-4); margin-top: 8px; font-family: var(--font-mono); }
+
+/* ---------- KPI 数字条(kpis) ---------- */
+.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: clamp(10px, 1.2vw, 16px); }
+.kpi {
+  border: 1px solid var(--border-subtle);
+  background: var(--bg-2);
+  border-radius: var(--radius-card);
+  padding: clamp(12px, 1.4vw, 18px);
+  text-align: center;
+}
+.kpi .kp-v { font-family: var(--font-mono); font-weight: 900; font-size: clamp(20px, 2.4vw, 34px); color: var(--brand-1); line-height: 1.1; font-variant-numeric: tabular-nums; }
+.kpi .kp-v.up   { color: var(--brand-ok); }
+.kpi .kp-v.down { color: var(--brand-3); }
+.kpi .kp-l { font-family: var(--font-mono); font-size: clamp(10px, .9vw, 12px); color: var(--text-3); margin-top: 6px; }
+.kpi .kp-s { font-size: clamp(10px, .9vw, 12px); color: var(--text-4); margin-top: 2px; }
+
+/* ---------- 小标签行(vtag) ---------- */
+.vtag {
+  display: inline-flex; align-items: center; gap: 8px;
+  margin-bottom: 10px;
+  font-family: var(--font-mono);
+  font-size: clamp(10px, .95vw, 13px);
+  color: var(--brand-1);
+  letter-spacing: .12em;
+}
+.vtag::before { content: ''; width: 26px; height: 1px; background: var(--brand-1); }
+
+/* ---------- 顶栏(主题按钮 / 章节 tab,deck 渲染) ---------- */
+.deck-topbar {
+  position: fixed; top: 0; left: 0; right: 0; z-index: 50;
+  display: flex; align-items: center; justify-content: space-between; gap: 14px;
+  padding: 10px 22px;
+  pointer-events: none;
+}
+.deck-left { display: flex; align-items: center; flex-shrink: 0; }
+.deck-tabs {
+  display: flex; gap: 6px; overflow-x: auto; pointer-events: auto;
+  flex: 1 1 auto; min-width: 0;
+  /* 两侧预留位:右 = 品牌 logo,左 = 主题按钮;并隐藏 x 滚动条 */
+  padding-left: clamp(12px, 1.5vw, 32px);
+  padding-right: clamp(56px, 6vw, 120px);
+  scrollbar-width: none;
+}
+.deck-tabs::-webkit-scrollbar { display: none; }
+.deck-tab {
+  font-family: var(--font-mono);
+  font-size: 12px;
+  padding: 4px 12px;
+  border-radius: var(--radius-pill);
+  border: 1px solid transparent;
+  color: var(--text-3);
+  background: transparent;
+  white-space: nowrap;
+}
+.deck-tab.cur { color: var(--brand-1); border-color: var(--border-strong); background: var(--bg-2); }
+
+.theme-toggle {
+  pointer-events: auto;
+  display: inline-flex; align-items: center; justify-content: center;
+  width: 34px; height: 30px; padding: 0;
+  color: var(--text-3);
+  border: 1px solid var(--border-subtle);
+  border-radius: var(--radius-pill);
+  background: var(--glass-bg);
+  backdrop-filter: blur(var(--glass-blur));
+  cursor: pointer;
+}
+.theme-toggle:hover { color: var(--brand-1); border-color: var(--border-strong); }
+.theme-toggle svg { flex-shrink: 0; }
+.deck-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
+
+/* 右上角品牌 logo(960×105 横条,theme.js 随主题切换暗/浅两图) */
+.hd-logo {
+  height: clamp(20px, 2.4vh, 30px);
+  width: auto;
+  aspect-ratio: 960 / 105;
+  object-fit: contain;
+  flex-shrink: 0;
+}
+
+/* ---------- 底部进度条(deck 渲染) ---------- */
+.deck-bottom {
+  position: fixed; bottom: 0; left: 0; right: 0; z-index: 50;
+  display: flex; align-items: center; gap: 16px;
+  padding: 10px 22px;
+  pointer-events: none;
+}
+.deck-progress {
+  flex: 1; height: 3px; border-radius: 2px;
+  background: var(--bg-2); overflow: hidden;
+}
+.deck-progress .bar {
+  height: 100%; width: 0%;
+  background: var(--brand-1);
+  transition: width .4s var(--ease-out);
+}
+.deck-pos { font-family: var(--font-mono); font-size: 12px; color: var(--text-4); font-variant-numeric: tabular-nums; }
+.deck-pagename { font-family: var(--font-mono); font-size: 12px; color: var(--text-3); }
+
+/* ---------- 水平柱状(bar-row)渠道价 / 对比 ---------- */
+.bar-row { display: grid; grid-template-columns: 130px 1fr 96px; gap: clamp(8px, .9vw, 14px); align-items: center; }
+.bar-l { font-family: var(--font-mono); font-size: clamp(11px, 1vw, 14px); color: var(--text-3); text-align: right; }
+.bar-track { height: clamp(14px, 2vh, 20px); background: var(--bg-2); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); overflow: hidden; }
+.bar { height: 100%; width: 0%; background: var(--gradient-brand); border-radius: var(--radius-sm); }
+.bar.on { background: var(--brand-1-hi); }
+.bar-v { font-family: var(--font-mono); font-size: clamp(11px, 1vw, 14px); color: var(--brand-1); font-variant-numeric: tabular-nums; }
+
+/* ---------- 环形仪表(gauge)线上化率 ---------- */
+.gauge-wrap { display: flex; flex-direction: column; align-items: center; gap: clamp(12px, 1.6vh, 18px); margin: 6px 0; }
+.gauge {
+  width: clamp(150px, 18vw, 240px); height: clamp(150px, 18vw, 240px);
+  border-radius: 50%;
+  display: flex; align-items: center; justify-content: center;
+  position: relative;
+  background:
+    radial-gradient(closest-side, var(--bg-0) 79%, transparent 80% 99%),
+    conic-gradient(var(--brand-1) var(--g, 13%), var(--bg-2) 0);
+}
+.gauge-num { font-family: var(--font-mono); font-size: clamp(34px, 4.6vw, 64px); font-weight: 800; color: var(--brand-1); font-variant-numeric: tabular-nums; }
+.gauge-tags { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
+
+/* ---------- 多镜头转场(frames,同屏多幕推进) ---------- */
+.frames { flex: 1; display: flex; flex-direction: column; justify-content: center; }
+.frames .frame { display: flex; flex-direction: column; justify-content: center; gap: clamp(12px, 1.8vh, 22px); }
+
+/* ---------- 辅助 ---------- */
+.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
+.big   { font-size: clamp(40px, 6vw, 88px); }
+.gold  { color: var(--brand-1); }
+.center { text-align: center; }
+.flex { display: flex; align-items: center; gap: 14px; }
+.between { justify-content: space-between; }
+.wrap { flex-wrap: wrap; }
+.mt { margin-top: 16px; }

+ 111 - 0
skills/study-report/lib/theme/theme.js

@@ -0,0 +1,111 @@
+/* ============================================================
+ * lib/theme/theme.js · 运行时主题切换
+ * 读取顺序:localStorage('hd-theme') → prefers-color-scheme → dark
+ * 提供 window.HDTheme:{ get(), set(name), toggle() }
+ * 并在顶栏 .deck-topbar 注入主题切换按钮(若存在)。
+ * 普通 script,非 module,file:// 可用。
+ * ============================================================ */
+(function () {
+  var KEY = 'hd-theme';
+  var root = document.documentElement;
+
+  /* 右上角品牌 logo:随主题切换暗/浅两图。
+   * 图片在课程目录 lesson/hundun-aiceo/,按 theme.js 自身 src 反推相对路径,
+   * 与页面所在深度无关(file:// 与 http 均可用)。可用 window.HD_LOGO_BASE 覆盖。 */
+  var LOGO_BASE = (function () {
+    try {
+      if (window.HD_LOGO_BASE) return window.HD_LOGO_BASE.replace(/\/?$/, '/');
+      var s = document.currentScript && document.currentScript.src;
+      if (s) return new URL('../../lesson/hundun-aiceo/', s).href;
+    } catch (e) {}
+    return '';
+  })();
+  var logoImg = null;
+
+  function current() {
+    var saved = null;
+    try { saved = localStorage.getItem(KEY); } catch (e) {}
+    if (saved === 'dark' || saved === 'light') return saved;
+    if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) return 'light';
+    return 'dark';
+  }
+
+  // 图片名即内容色:hundunai-dark.png = 深色内容(配浅色背景)、hundunai-light.png = 浅色内容(配深色背景)
+  function logoFile(name) { return 'hundunai-' + (name === 'dark' ? 'light' : 'dark') + '.png'; }
+
+  function apply(name) {
+    root.setAttribute('data-theme', name);
+    try { localStorage.setItem(KEY, name); } catch (e) {}
+    if (logoImg && LOGO_BASE) logoImg.src = LOGO_BASE + logoFile(name);
+  }
+
+  function toggle() {
+    apply(current() === 'dark' ? 'light' : 'dark');
+  }
+
+  // 幂等注入 logo 到顶栏右侧(deck.js buildChrome 或 DOMContentLoaded 调用)
+  function ensureLogo(container) {
+    if (logoImg || !LOGO_BASE) return;
+    var bar = container || document.querySelector('.deck-topbar');
+    if (!bar) return;
+    logoImg = bar.querySelector('.hd-logo');
+    if (!logoImg) {
+      var right = bar.querySelector('.deck-right');
+      if (!right) {
+        right = document.createElement('div');
+        right.className = 'deck-right';
+        bar.appendChild(right);
+      }
+      logoImg = document.createElement('img');
+      logoImg.className = 'hd-logo';
+      logoImg.alt = '';
+      logoImg.setAttribute('aria-hidden', 'true');
+      logoImg.onerror = function () { logoImg.style.display = 'none'; }; // 图缺失不显破图
+      right.appendChild(logoImg);
+    }
+    apply(current());
+  }
+
+  // 首帧前应用,避免闪白/闪黑
+  apply(current());
+
+  /* 主题切换按钮:图标化(暗主题显示太阳、浅主题显示月亮),放顶栏左侧 */
+  var SUN_PATH = 'M12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9M12,2L14.39,5.42C13.65,5.15 12.84,5 12,5C11.16,5 10.35,5.15 9.61,5.42L12,2M3.34,7L7.5,6.65C6.9,7.16 6.36,7.78 5.94,8.5C5.5,9.24 5.25,10 5.11,10.79L3.34,7M3.36,17L5.12,13.23C5.26,14 5.53,14.78 5.95,15.5C6.37,16.24 6.91,16.86 7.5,17.37L3.36,17M20.65,7L18.88,10.79C18.74,10 18.47,9.23 18.05,8.5C17.63,7.78 17.1,7.15 16.5,6.64L20.65,7M20.64,17L16.5,17.36C17.09,16.85 17.62,16.22 18.04,15.5C18.46,14.77 18.73,14 18.87,13.21L20.64,17M12,22L9.59,18.56C10.33,18.83 11.14,19 12,19C12.82,19 13.63,18.83 14.37,18.56L12,22Z';
+  var MOON_PATH = 'M17.75,4.09L15.22,6.03L16.13,9.09L13.5,7.28L10.87,9.09L11.78,6.03L9.25,4.09L12.44,4L13.5,1L14.56,4L17.75,4.09M21.25,11L19.61,12.25L20.2,14.23L18.5,13.06L16.8,14.23L17.39,12.25L15.75,11L17.81,10.95L18.5,9L19.19,10.95L21.25,11M18.97,15.95C19.8,15.87 20.69,17.05 20.16,17.8C19.84,18.25 19.5,18.67 19.08,19.07C15.44,22.7 9.62,22.69 5.99,19.06C2.35,15.42 2.35,9.61 5.99,5.97C6.38,5.58 6.8,5.24 7.26,4.94C8,4.42 9.17,5.3 9.1,6.13C9.04,6.74 9.07,7.35 9.22,7.95C9.78,10.19 10.83,12.28 12.29,14.05C13.84,15.92 15.77,17.35 17.92,18.21C18.28,18.35 18.64,18.47 19.02,18.55C19.23,18.58 19.46,18.57 19.68,18.55C19.78,18.55 19.88,18.56 19.98,18.55Z';
+  function iconSvg(moon) {
+    return '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="' + (moon ? MOON_PATH : SUN_PATH) + '"/></svg>';
+  }
+  function createToggleBtn() {
+    var btn = document.createElement('button');
+    btn.className = 'theme-toggle';
+    btn.type = 'button';
+    btn.setAttribute('aria-label', '切换主题');
+    btn.title = '切换主题';
+    function paint() { btn.innerHTML = iconSvg(current() === 'dark'); } // 暗主题给太阳,点它切到亮
+    paint();
+    btn.addEventListener('click', function () { toggle(); paint(); });
+    return btn;
+  }
+  // 注入主题切换按钮(放顶栏左侧)
+  function injectToggle() {
+    var bar = document.querySelector('.deck-topbar');
+    if (!bar) return;
+    if (bar.querySelector('.theme-toggle')) return;
+    var left = bar.querySelector('.deck-left');
+    if (!left) { left = document.createElement('div'); left.className = 'deck-left'; bar.insertBefore(left, bar.firstChild); }
+    left.appendChild(createToggleBtn());
+  }
+
+  window.HDTheme = { get: current, set: apply, toggle: toggle, ensureLogo: ensureLogo, createToggleBtn: createToggleBtn };
+
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', function () { injectToggle(); ensureLogo(); });
+  } else {
+    injectToggle();
+    ensureLogo();
+  }
+  // 顶栏可能由 deck.js 稍后创建,兜底重试一次
+  if (document.readyState === 'loading') {
+    setTimeout(function () { ensureLogo(document.querySelector('.deck-topbar')); }, 300);
+  }
+})();

+ 191 - 0
skills/study-report/lib/theme/tokens.css

@@ -0,0 +1,191 @@
+/* ============================================================
+ * lib/theme/tokens.css · 混沌AI院设计系统 token 唯一来源
+ * 双主题:暗色(混沌AI院) / 浅色(未来飞马 VI)
+ * 任何文件不得新写色值/字体/圆角常量,一律引用本文件 var(--...)。
+ * 切换方式:<html data-theme="dark" | "light">(由 theme.js 管理)
+ * ============================================================ */
+
+:root {
+  /* ---------- 背景层级(0=最深 3=最浅浮层) ---------- */
+  --bg-0: #0B1017;                 /* 页面深底 */
+  --bg-1: #111925;                 /* 一级浮层 / 区块底 */
+  --bg-2: #171F2B;                 /* 卡片底 */
+  --bg-3: #0D131C;                 /* 浮层 / 弹层底 */
+
+  /* ---------- 文字层级(1=最高 4=最低) ---------- */
+  --text-1: #FBFAF7;               /* 大标题 / 核心结论 */
+  --text-2: #C1C4CB;               /* 主标题 / 正文 */
+  --text-3: #777D85;               /* 说明 / 次级 */
+  --text-4: rgba(255,255,255,.42); /* 弱化 / 页码 / 脚注 */
+
+  /* ---------- 品牌强调 ---------- */
+  --brand-1:    #D5B254;           /* 金:主强调 */
+  --brand-1-hi: #FFC000;           /* 金黄:框线 / 高亮 */
+  --brand-2:    #AF8AE8;           /* 浅紫:次级强调 */
+  --brand-3:    #FF0000;           /* 红:卡点 / 警示 */
+  --brand-ok:   #7FD1A0;           /* 绿:通过 / 已确认 */
+
+  /* ---------- 渐变 ---------- */
+  --gradient-brand: linear-gradient(135deg, #D5B254, #FFC000);
+  --gradient-text:  linear-gradient(135deg, #FFC000, #AF8AE8);
+  --on-gradient:    #0B1017;           /* 品牌渐变底上的文字色(暗=金渐变深字) */
+  --gradient-hero:  radial-gradient(ellipse at 50% 0%, rgba(213,178,84,.10) 0%, transparent 60%),
+                    radial-gradient(ellipse at 80% 85%, rgba(175,138,232,.10) 0%, transparent 55%),
+                    linear-gradient(180deg, #0B1017 0%, #111925 60%, #0D131C 100%);
+
+  /* ---------- 边框 ---------- */
+  --border-subtle: rgba(255,255,255,.07);
+  --border-strong: rgba(213,178,84,.35);
+  --border-focus:  rgba(213,178,84,.6);
+  --border-danger: rgba(255,0,0,.45);        /* 警示红框(卡点 / 翻车) */
+  --border-ok:     rgba(127,209,160,.4);     /* 通过绿框(已确认 / 人味版) */
+
+  /* ---------- 阴影 ---------- */
+  --shadow-card:  none;                       /* 暗色用线框,不用投影 */
+  --shadow-float: 0 8px 30px rgba(0,0,0,.45);
+  --shadow-glow:  0 0 24px rgba(213,178,84,.25);
+
+  /* ---------- 圆角(混沌 = 直角卡片) ---------- */
+  --radius-sm: 3px;  --radius-md: 6px;  --radius-lg: 10px;
+  --radius-card: 0px;
+  --radius-pill: 999px;
+
+  /* ---------- 字体 ---------- */
+  --font-display: "Source Han Serif SC","Noto Serif SC","Songti SC","SimSun",serif;
+  --font-display-weight: 800;
+  --font-body: "Source Han Sans SC","Noto Sans SC","PingFang SC","Microsoft YaHei",system-ui,sans-serif;
+  --font-mono: "SF Mono","Cascadia Code","JetBrains Mono",Consolas,monospace;
+
+  /* ---------- 缓动 ---------- */
+  --ease-out:   cubic-bezier(0.16, 1, 0.3, 1);
+  --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
+
+  /* ---------- 玻璃拟态 ---------- */
+  --glass-bg:   rgba(23,31,43,.85);
+  --glass-blur: 12px;
+
+  /* ---------- 屏幕 ---------- */
+  --screen-h: 100dvh;
+
+  /* ---------- 组件 token:微信/群聊 ---------- */
+  --chat-bg:          #111B21;
+  --chat-in-bg:       #202C33;   --chat-in-text: #E9EDEF;
+  --chat-out-bg:      #95EC69;   --chat-out-text: #111B21;
+  --chat-avatar-bg:   #5A6570;   --chat-avatar-text: #FFFFFF;
+  --chat-link:        #7FD1A0;
+  --chat-bar-bg:      #0B1017;   --chat-bar-text: #D5B254;
+  --chat-card-bg:     rgba(127,209,160,.06);
+  --chat-card-border: rgba(127,209,160,.4);
+  --chat-time:        #777D85;
+
+  /* ---------- 组件 token:多画像评估 ---------- */
+  /* 画像区分色(avatar 底 / 标签点 / 雷达线),双主题两套值 */
+  --persona-1:  #FFC000;  /* 决策层老板     */
+  --persona-2:  #AF8AE8;  /* 执行层操盘手   */
+  --persona-3:  #FF6B6B;  /* 一线业务经纪人 */
+  --persona-4:  #4FC3F7;  /* 技术 AI 工程师 */
+  --persona-5:  #7FD1A0;  /* 非技术市场 HR  */
+  --persona-6:  #FF9F43;  /* 行业老兵       */
+  --persona-7:  #F78FB3;  /* 课程采购负责人 */
+  --persona-8:  #00E5CC;  /* 挑剔同行讲师   */
+  --persona-9:  #B2B6BF;  /* 预留           */
+  --persona-10: #8BE9FD;  /* 预留           */
+
+  /* 问题优先级徽章色(色点/色条背景),P0 红 / P1 橙 / P2 黄 / P3 绿 */
+  --grade-p0: #FF0000;
+  --grade-p1: #FF9F43;
+  --grade-p2: #FFC000;
+  --grade-p3: #7FD1A0;
+  /* 徽章前景文字(双主题两套值,与背景成对) */
+  --grade-p0-fg: #FFFFFF;
+  --grade-p1-fg: #0B1017;
+  --grade-p2-fg: #0B1017;
+  --grade-p3-fg: #0B1017;
+}
+
+/* ============================================================
+ * 浅色 · 未来飞马 VI
+ * 主色:紫 #6C5CE7 / 蓝 #3B82F6 / 青 #0891B2
+ * 背景 #F8F9FC,玻璃卡 + 圆角 16 + 胶囊 50 + 柔和阴影,黑体标题
+ * ============================================================ */
+html[data-theme="light"] {
+  --bg-0: #F8F9FC;
+  --bg-1: #FFFFFF;
+  --bg-2: #F0F2F8;
+  --bg-3: #E9EBF3;
+
+  --text-1: #0F0F23;
+  --text-2: #1A1A2E;
+  --text-3: #4A4A6A;
+  --text-4: #6B7280;
+
+  --brand-1:    #6C5CE7;         /* 主品牌紫 */
+  --brand-1-hi: #3B82F6;         /* 辅蓝 */
+  --brand-2:    #0891B2;         /* 强调青 */
+  --brand-3:    #E5484D;
+  --brand-ok:   #16A34A;
+
+  --gradient-brand: linear-gradient(135deg, #6C5CE7, #3B82F6);
+  --gradient-text:  linear-gradient(135deg, #6C5CE7, #0891B2);
+  --on-gradient:    #FFFFFF;           /* 品牌渐变底上的文字色(浅=紫渐变白字) */
+  --gradient-hero:  linear-gradient(135deg, #EEF2FF 0%, #E0E7FF 50%, #F8F9FC 100%);
+
+  --border-subtle: rgba(15,15,35,.08);
+  --border-strong: rgba(108,92,231,.35);
+  --border-focus:  rgba(108,92,231,.6);
+  --border-danger: rgba(229,72,77,.45);
+  --border-ok:     rgba(22,163,74,.4);
+
+  --shadow-card:  0 4px 24px rgba(0,0,0,.06);
+  --shadow-float: 0 8px 32px rgba(0,0,0,.10);
+  --shadow-glow:  0 0 24px rgba(108,92,231,.20);
+
+  --radius-sm: 8px;  --radius-md: 12px;  --radius-lg: 16px;
+  --radius-card: 16px;
+  --radius-pill: 50px;
+
+  --font-display: "Source Han Sans SC","Noto Sans SC","PingFang SC","Microsoft YaHei",system-ui,sans-serif;
+  --font-display-weight: 700;
+
+  --glass-bg:   rgba(255,255,255,.85);
+  --glass-blur: 20px;
+
+  --chat-bg:          #FFFFFF;
+  --chat-in-bg:       #FFFFFF;   --chat-in-text: #1A1A2E;
+  --chat-out-bg:      #6C5CE7;   --chat-out-text: #FFFFFF;
+  --chat-avatar-bg:   #B9B1F5;   --chat-avatar-text: #FFFFFF;
+  --chat-link:        #6C5CE7;
+  --chat-bar-bg:      #F8F9FC;   --chat-bar-text: #6C5CE7;
+  --chat-card-bg:     rgba(108,92,231,.06);
+  --chat-card-border: rgba(108,92,231,.4);
+  --chat-time:        #9CA3AF;
+
+  /* 多画像评估(浅色:中深色系保证浅底可读) */
+  --persona-1:  #F59E0B;  /* 决策层老板     */
+  --persona-2:  #8B5CF6;  /* 执行层操盘手   */
+  --persona-3:  #E5484D;  /* 一线业务经纪人 */
+  --persona-4:  #3B82F6;  /* 技术 AI 工程师 */
+  --persona-5:  #16A34A;  /* 非技术市场 HR  */
+  --persona-6:  #EA580C;  /* 行业老兵       */
+  --persona-7:  #DB2777;  /* 课程采购负责人 */
+  --persona-8:  #0D9488;  /* 挑剔同行讲师   */
+  --persona-9:  #64748B;  /* 预留           */
+  --persona-10: #0EA5E9;  /* 预留           */
+
+  --grade-p0: #E5484D;
+  --grade-p1: #EA580C;
+  --grade-p2: #D97706;
+  --grade-p3: #16A34A;
+
+  --grade-p0-fg: #FFFFFF;
+  --grade-p1-fg: #FFFFFF;
+  --grade-p2-fg: #FFFFFF;
+  --grade-p3-fg: #FFFFFF;
+}
+
+/* ---------- 主题切换过渡(页面级,轻微即可) ---------- */
+html[data-theme] body,
+html[data-theme] .card,
+html[data-theme] .s-wrap {
+  transition: background-color .3s ease, color .3s ease, border-color .3s ease, box-shadow .3s ease;
+}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 34 - 0
skills/study-report/lib/vendor/echarts/echarts.min.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 9 - 0
skills/study-report/lib/vendor/gsap/gsap.min.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 5 - 0
skills/study-report/lib/vendor/mermaid/mermaid.min.js


+ 166 - 0
skills/study-report/scripts/ask.mjs

@@ -0,0 +1,166 @@
+/**
+ * ask.mjs — 三问追问交互(逐问等待用户)
+ *
+ * 采集完成后固定三轮问题(过去/现在/未来),逐问在终端等待用户输入;
+ * 用户口述与采集素材做「关联标记」(口述里提到的项目名 ↔ 采集条目证据)。
+ * 用户不答(直接回车 / 超时 / --non-interactive)→ 该问记空值并标注【待补充:用户口述】,
+ * 上游用采集数据先行出报告——不阻塞、不编造用户感受。
+ *
+ * 用法:node ask.mjs --collect collect.json [--answers answers.json] [--timeout 120] [--non-interactive]
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+
+const QUESTIONS = [
+  {
+    key: 'past',
+    tag: '第一问 · 过去',
+    text: '您觉得企业之前 AI 落地不够原生、不够性感的地方是什么?过去的痛点或疑惑?(直接回车可跳过)',
+    placeholder: '如:工具是有了,但都要一个个单独打开用,和业务流程是两张皮……',
+  },
+  {
+    key: 'present',
+    tag: '第二问 · 现在',
+    text: '这两天具体感受与收获?(成果本身已从采集拿到,只问感受 + 哪个具体项目让您有感觉——说到项目名我会自动关联采集证据)',
+    placeholder: '如:两天装了 8 个技能;最有感觉的是 xx 报告那次,一句话就出来了……',
+  },
+  {
+    key: 'future',
+    tag: '第三问 · 未来',
+    text: '学到的这些细节之后,想回企业落地或探索的方向?畅想未来(直接回车可跳过)',
+    placeholder: '如:想把这套技能包装成培训课,让每个业务部门都装上……',
+  },
+];
+
+/** 口述 → 采集素材关联标记:问题里提到的项目/文件关键词 ↔ 采集条目 */
+export function linkAnswersToEvidence(answers, collectResult) {
+  const links = {};
+  if (!collectResult || !Array.isArray(collectResult.items)) return links;
+  const pool = collectResult.items;
+  for (const q of QUESTIONS) {
+    const text = answers[q.key];
+    links[q.key] = [];
+    if (!text) continue;
+    const seen = new Set();
+    // 提取口述中的候选关键词:≥2 个汉字的连续片段 + 采集条目摘要中的关键 token
+    const grams = new Set();
+    for (const m of text.match(/[一-龥A-Za-z0-9_-]{2,}/g) || []) {
+      for (let i = 0; i < m.length - 1; i++) {
+        for (const len of [6, 5, 4, 3, 2]) {
+          if (i + len <= m.length) grams.add(m.slice(i, i + len));
+        }
+      }
+    }
+    for (const it of pool) {
+      const hay = `${it.summary} ${it.project || ''} ${path.basename(it.path || '')}`;
+      for (const g of grams) {
+        if (hay.includes(g) && !seen.has(it.path)) {
+          seen.add(it.path);
+          links[q.key].push({ evidence: it.summary.slice(0, 80), source: it.source, path: it.path, time: it.time, matched: g });
+          break;
+        }
+      }
+      if (links[q.key].length >= 5) break;
+    }
+  }
+  return links;
+}
+
+/** 自管理行读取:TTY 逐行等待;管道场景把缓冲中剩余的行依次消费(每问一行),EOF/超时给空答案 */
+function makeLineReader() {
+  const isTTY = process.stdin.isTTY;
+  const queue = [];
+  let eof = false;
+  let buf = '';
+  let notify = null;
+  process.stdin.setEncoding('utf-8');
+  process.stdin.on('data', chunk => {
+    buf += chunk;
+    let idx;
+    while ((idx = buf.indexOf('\n')) >= 0) {
+      queue.push(buf.slice(0, idx));
+      buf = buf.slice(idx + 1);
+    }
+    if (notify) { const n = notify; notify = null; n(); }
+  });
+  process.stdin.on('end', () => { eof = true; if (notify) { const n = notify; notify = null; n(); } });
+  process.stdin.on('error', () => { eof = true; if (notify) { const n = notify; notify = null; n(); } });
+  if (!isTTY) process.stdin.resume();
+  return function nextLine(timeoutMs) {
+    return new Promise(resolve => {
+      if (queue.length > 0) return resolve(queue.shift());
+      if (eof) return resolve('');
+      let timer = null;
+      const finish = val => {
+        if (timer) clearTimeout(timer);
+        notify = null;
+        resolve(val);
+      };
+      if (timeoutMs > 0) timer = setTimeout(() => finish(''), timeoutMs);
+      notify = () => {
+        if (queue.length > 0) finish(queue.shift());
+        else if (eof) finish(buf || '');
+        /* 否则等下一块 data */
+      };
+    });
+  };
+}
+
+export async function runAsk({ collectFile = null, answersFile = null, timeoutMs = 120000, nonInteractive = false } = {}) {
+  let collectResult = null;
+  if (collectFile) {
+    try { collectResult = JSON.parse(fs.readFileSync(collectFile, 'utf-8')); } catch { collectResult = null; }
+  }
+
+  const answers = {};
+  if (nonInteractive) {
+    for (const q of QUESTIONS) answers[q.key] = '';
+    console.log('[ask] 非交互模式:三轮问题全部标注【待补充:用户口述】,报告先行用采集数据生成。');
+  } else {
+    const nextLine = makeLineReader();
+    for (const q of QUESTIONS) {
+      console.log(`\n${'═'.repeat(60)}`);
+      console.log(`【${q.tag}】${q.text}`);
+      console.log(`  (示例:${q.placeholder})`);
+      process.stdout.write('> ');
+      const line = await nextLine(timeoutMs);
+      answers[q.key] = (line || '').trim();
+      if (!answers[q.key]) console.log('  (已跳过 → 报告将标注【待补充:用户口述】)');
+    }
+  }
+
+  for (const q of QUESTIONS) {
+    if (!answers[q.key]) answers[q.key + '_pending'] = true;
+  }
+
+  const links = linkAnswersToEvidence(answers, collectResult);
+  const result = { answeredAt: new Date().toISOString(), answers, evidenceLinks: links };
+
+  if (answersFile) {
+    fs.mkdirSync(path.dirname(path.resolve(answersFile)), { recursive: true });
+    fs.writeFileSync(path.resolve(answersFile), JSON.stringify(result, null, 2));
+    console.log(`\n已写出:${path.resolve(answersFile)}`);
+  } else {
+    console.log('\n<!--ANSWERS-BEGIN-->\n' + JSON.stringify(result, null, 2) + '\n<!--ANSWERS-END-->');
+  }
+  return result;
+}
+
+/* ── CLI ────────────────────────────────────────────────────── */
+function argVal(flag, dflt) {
+  const i = process.argv.indexOf(flag);
+  return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
+}
+const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(decodeURIComponent(new URL(import.meta.url).pathname));
+if (isMain) {
+  runAsk({
+    collectFile: argVal('--collect', null),
+    answersFile: argVal('--answers', null),
+    timeoutMs: Number(argVal('--timeout', '120000')),
+    nonInteractive: process.argv.includes('--non-interactive'),
+  }).catch(e => {
+    console.error('[ask] 异常退出:', e.message);
+    process.exit(2);
+  });
+}

+ 238 - 0
skills/study-report/scripts/build-report.mjs

@@ -0,0 +1,238 @@
+/**
+ * build-report.mjs — HTML PPT 生成(lib 复制 + 数据注入)
+ *
+ * 读采集清单.json + 追问回答.json → 注入 report-template.html(skill-present 标准)→
+ * 输出独立报告目录(report.html + 全套 lib 复制 + report-data.json)。
+ *
+ * 规则(borrow skill-present 报告策略):数据优先 / 证据链 / 来源标注 / 数字动画。
+ * 缺数据段优雅降级:三问未答 → 标注【待补充:用户口述】;交付物无 URL → 虚线占位卡。
+ *
+ * 用法:node build-report.mjs --collect collect.json [--answers answers.json] \
+ *         [--name 姓名] [--enterprise 企业] [--out 输出目录]
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+const SKILL_DIR = path.resolve(path.dirname(decodeURIComponent(new URL(import.meta.url).pathname)), '..');
+
+function argVal(flag, dflt) {
+  const i = process.argv.indexOf(flag);
+  return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
+}
+
+function esc(s) {
+  return String(s == null ? '' : s)
+    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+}
+
+function fmtDate(iso) {
+  try { return iso.slice(0, 10); } catch { return iso; }
+}
+
+/** lib 全套独立复制(每个文件独立复制确保可用——v2.4.1 base.css 缺失教训) */
+function copyLib(outDir) {
+  const srcLib = path.join(SKILL_DIR, 'lib');
+  const dstLib = path.join(outDir, 'lib');
+  const REQUIRED = [
+    'theme/tokens.css', 'theme/base.css', 'theme/theme.js',
+    'player/deck.js', 'player/canvas-scale.js',
+    'components/chat-window.css', 'components/chat-window.js',
+    'components/group-chat.css', 'components/group-chat.js',
+    'components/logic-flow.css', 'components/logic-flow.js',
+    'vendor/gsap/gsap.min.js',
+  ];
+  const copied = [];
+  fs.mkdirSync(dstLib, { recursive: true });
+  for (const rel of REQUIRED) {
+    const src = path.join(srcLib, rel);
+    const dst = path.join(dstLib, rel);
+    if (!fs.existsSync(src)) throw new Error(`lib 缺失(安装不完整):${rel} —— 重新克隆本仓库`);
+    fs.mkdirSync(path.dirname(dst), { recursive: true });
+    fs.copyFileSync(src, dst);
+    copied.push(rel);
+  }
+  return copied;
+}
+
+/* ── 各段渲染 ─────────────────────────────────────────────── */
+
+function renderDistRows(bySource) {
+  const entries = Object.entries(bySource).sort((a, b) => b[1] - a[1]);
+  const max = Math.max(1, ...entries.map(e => e[1]));
+  return entries.map(([name, n]) => {
+    const w = Math.max(2, Math.round((n / max) * 560));
+    return `<div class="dist-row"><div class="dist-name">${esc(name)}</div><div class="dist-bar" style="width:${w}px;"></div><div class="dist-num">${n}</div></div>`;
+  }).join('\n        ');
+}
+
+/** 交付物墙:优先在线 URL(可 iframe 嵌套),本地文件给占位卡 */
+function renderWall(items) {
+  const candidates = items
+    .filter(i => i.type === '交付物' || /\.(html)$/i.test(i.path || ''))
+    .slice(0, 6);
+  if (candidates.length === 0) {
+    return `<div class="w-card"><div class="w-nolink">本窗口未检出 HTML 交付物<br>(采集窗口内无 .html 产出)</div><div class="w-title">交付物墙占位</div><div class="w-src">缺数据段 · 优雅降级</div></div>`;
+  }
+  return candidates.map(i => {
+    const online = /^https?:\/\//.test(i.url || '');
+    if (online) {
+      const iframeOk = i.url.includes('fmode.cn') || i.url.includes('obs.') || i.url.includes('s3.') || i.iframable === true;
+      return `<div class="w-card">
+          ${iframeOk ? `<iframe src="${esc(i.url)}" loading="lazy" sandbox="allow-scripts allow-same-origin"></iframe>` : `<div class="w-nolink">外域页面禁止 iframe 嵌套<br>点击标题新窗打开</div>`}
+          <div class="w-title"><a href="${esc(i.url)}" target="_blank" rel="noopener" style="color:var(--brand-1); text-decoration:none;">${esc(i.summary.slice(0, 40))}</a></div>
+          <div class="w-src">${esc(i.source)} · ${fmtDate(i.time)}</div>
+          ${iframeOk ? '<div class="w-note">✓ 在线项目 · 可 iframe 嵌套展示</div>' : '<div class="w-src">仅链接 · 不强行嵌套</div>'}
+        </div>`;
+    }
+    return `<div class="w-card">
+        <div class="w-nolink">本地文件<br>${esc(path.basename(i.path || ''))}</div>
+        <div class="w-title">${esc(i.summary.slice(0, 44))}</div>
+        <div class="w-src">${esc(i.source)} · ${fmtDate(i.time)} · 本地路径已入附录</div>
+      </div>`;
+  }).join('\n        ');
+}
+
+function renderTimeline(items, n = 7) {
+  const picks = items.slice(0, n);
+  if (picks.length === 0) return `<div class="tl-item"><div class="tl-time">—</div><div class="tl-text">采集窗口内无会话/文件记录</div></div>`;
+  return picks.map(i => `<div class="tl-item">
+        <div class="tl-time">${esc(i.time.slice(5, 16).replace('T', ' '))} · ${esc(i.source)} · ${esc(i.type)}</div>
+        <div class="tl-text">${esc(i.summary.slice(0, 84))}</div>
+      </div>`).join('\n      ');
+}
+
+function renderQBlock(answer, pending, links, pendingBadgeHtml) {
+  const body = answer
+    ? `<div class="q-a">${esc(answer)}</div>`
+    : `<div class="q-a" style="color:var(--text-4);">(本轮未采集到口述内容)</div>`;
+  const ev = (links || []).map(l =>
+    `<div class="ev">↳ 证据 [${esc(l.source)} ${fmtDate(l.time)}] ${esc(l.evidence)}</div>`).join('\n          ');
+  return body + pendingBadgeHtml + (ev ? `\n        <div class="q-ev">\n          ${ev}\n        </div>` : '');
+}
+
+function renderAppendix(items, n = 14) {
+  const rows = items.slice(0, n).map(i => `<tr>
+          <td class="mono">${esc(i.time.slice(5, 16).replace('T', ' '))}</td>
+          <td>${esc(i.source)}</td>
+          <td>${esc(i.summary.slice(0, 80))}</td>
+          <td class="mono">${esc(i.url || i.path || '—')}</td>
+        </tr>`).join('\n        ');
+  return rows || `<tr><td colspan="4" style="color:var(--text-4);">无采集记录</td></tr>`;
+}
+
+/* ── 主流程 ─────────────────────────────────────────────── */
+
+export function buildReport({ collect, answers, name = '学员', enterprise = '企业 AI 落地研修', outDir = null }) {
+  const tplPath = path.join(SKILL_DIR, 'templates', 'report-template.html');
+  if (!fs.existsSync(tplPath)) throw new Error('模板缺失:templates/report-template.html');
+  const tpl = fs.readFileSync(tplPath, 'utf-8');
+
+  const stats = collect.stats || {};
+  const dateRange = `${fmtDate(collect.meta.windowStart)} ~ ${fmtDate(collect.meta.generatedAt)}`;
+  const toolList = Object.keys(stats.bySource || {}).join(' / ') || '—';
+  const messages = collect.items.reduce((s, i) => {
+    const m = i.summary.match(/用户消息 (\d+) 条 \/ 助手 (\d+) 条/);
+    return s + (m ? Number(m[1]) + Number(m[2]) : 0);
+  }, 0);
+
+  const ans = (answers && answers.answers) || {};
+  const links = (answers && answers.evidenceLinks) || {};
+
+  /* 无 answers 文件 = 全部三问视为待补充;有文件时按 pending 标记 */
+  const noAnswersAtAll = !answers;
+  const pendingBadge = k => (noAnswersAtAll || ans[k + '_pending'])
+    ? `<div style="margin-top:8px;"><span class="pending-badge">【待补充:用户口述】</span></div>`
+    : '';
+  const qHtml = key => renderQBlock(ans[key] || '', ans[key + '_pending'], links[key], pendingBadge(key));
+
+  const html = tpl
+    .replace(/\{\{REPORT_TITLE\}\}/g, esc(`${name} · 学习复盘报告`))
+    .replace(/\{\{NAME\}\}/g, esc(name))
+    .replace(/\{\{ENTERPRISE\}\}/g, esc(enterprise))
+    .replace(/\{\{DATE_RANGE\}\}/g, esc(dateRange))
+    .replace(/\{\{SESSIONS\}\}/g, String(stats.sessionCount ?? 0))
+    .replace(/\{\{TOOLKINDS\}\}/g, String(Object.keys(stats.bySource || {}).length))
+    .replace(/\{\{ARTIFACTS\}\}/g, String(stats.artifactCount ?? 0))
+    .replace(/\{\{MESSAGES\}\}/g, String(messages))
+    .replace(/\{\{HOURS\}\}/g, String(collect.meta.windowHours ?? 48))
+    .replace(/\{\{WINDOW\}\}/g, esc(dateRange))
+    .replace(/\{\{TOOL_LIST\}\}/g, esc(toolList))
+    .replace(/\{\{TOOL_DIST_ROWS\}\}/g, renderDistRows(stats.bySource || {}))
+    .replace(/\{\{ARTIFACT_WALL\}\}/g, renderWall(collect.items || []))
+    .replace(/\{\{TIMELINE\}\}/g, renderTimeline(collect.items || []))
+    .replace(/\{\{ANSWER_PAST\}\}/g, '')
+    .replace(/\{\{ANSWER_PRESENT\}\}/g, '')
+    .replace(/\{\{ANSWER_FUTURE\}\}/g, '')
+    .replace(/\{\{EVIDENCE_PAST\}\}/g, '')
+    .replace(/\{\{EVIDENCE_PRESENT\}\}/g, '')
+    .replace(/\{\{EVIDENCE_FUTURE\}\}/g, '')
+    .replace(/\{\{PAST_PENDING\}\}/g, '')
+    .replace(/\{\{PRESENT_PENDING\}\}/g, '')
+    .replace(/\{\{FUTURE_PENDING\}\}/g, '')
+    .replace(/\{\{APPENDIX_ROWS\}\}/g, renderAppendix(collect.items || []))
+    .replace(/\{\{TOTAL\}\}/g, String(stats.total ?? 0))
+    .replace(/\{\{APPENDIX_N\}\}/g, String(Math.min(14, stats.total ?? 0)));
+
+  /* 三问块在模板中带占位符嵌套,这里二次注入(避免 regex 嵌套转义地狱) */
+  const inject = (screenId, inner, source) => {
+    const re = new RegExp(`(<section class="screen" id="${screenId}">[\\s\\S]*?<div class="q-h">[\\s\\S]*?</div>\\n)([\\s\\S]*?)(\\n      </div>\\n    </div>)`);
+    return (source || html).replace(re, (m, head, _body, tail) => head + inner + tail);
+  };
+  let finalHtml = html;
+  finalHtml = inject.call(null, 'p5', qHtml('past').trim(), finalHtml);
+  finalHtml = inject.call(null, 'p6', qHtml('present').trim(), finalHtml);
+  finalHtml = inject.call(null, 'p7', qHtml('future').trim(), finalHtml);
+
+  /* 输出目录:report.html + lib 全套 + report-data.json */
+  const dir = outDir || path.join(process.cwd(), 'study-report-out');
+  fs.mkdirSync(dir, { recursive: true });
+  const copied = copyLib(dir);
+  fs.writeFileSync(path.join(dir, 'report.html'), finalHtml);
+  fs.writeFileSync(path.join(dir, 'report-data.json'), JSON.stringify({
+    generatedAt: new Date().toISOString(),
+    name, enterprise, dateRange,
+    collect: { meta: collect.meta, stats: collect.stats, toolsStatus: collect.toolsStatus, items: collect.items, artifacts: collect.artifacts },
+    answers: answers || null,
+    libCopied: copied,
+  }, null, 2));
+
+  return {
+    dir,
+    reportPath: path.join(dir, 'report.html'),
+    dataPath: path.join(dir, 'report-data.json'),
+    pages: 9,
+    stats,
+  };
+}
+
+/* ── CLI ─────────────────────────────────────────────── */
+const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(decodeURIComponent(new URL(import.meta.url).pathname));
+if (isMain) {
+  const collectFile = argVal('--collect', null);
+  const answersFile = argVal('--answers', null);
+  if (!collectFile || !fs.existsSync(collectFile)) {
+    console.error('[build-report] 缺 --collect <采集清单.json>(先跑 collect.mjs --out)');
+    process.exit(2);
+  }
+  const collect = JSON.parse(fs.readFileSync(collectFile, 'utf-8'));
+  let answers = null;
+  if (answersFile && fs.existsSync(answersFile)) {
+    answers = JSON.parse(fs.readFileSync(answersFile, 'utf-8'));
+  } else {
+    console.log('[build-report] 未提供 --answers:三问标注【待补充:用户口述】,用采集数据先行出报告。');
+  }
+  const r = buildReport({
+    collect,
+    answers,
+    name: argVal('--name', '学员'),
+    enterprise: argVal('--enterprise', '企业 AI 落地研修'),
+    outDir: argVal('--out', null),
+  });
+  console.log(`\n✅ 报告已生成:${r.reportPath}`);
+  console.log(`   页数:${r.pages} · 会话 ${r.stats.sessionCount ?? 0} · 产出物 ${r.stats.artifactCount ?? 0}`);
+  console.log(`   lib 已复制 ${path.basename(r.dir)}/lib/(base.css 教训:逐文件校验复制)`);
+  console.log(`   本地预览:file://${pathToFileURL(r.reportPath).href.replace('file://', '')}`);
+}

+ 374 - 0
skills/study-report/scripts/collect.mjs

@@ -0,0 +1,374 @@
+/**
+ * collect.mjs — 48h 多工具采集扫描 → 采集清单.json
+ *
+ * 产出「采集清单」(每条:来源工具 / 时间 / 类型[会话|文件|交付物] / 摘要 / 产出物路径 / URL)
+ * 先给用户看采集到什么,缺哪类工具提示用户确认路径。
+ *
+ * 纪律:所有工具路径「探测存在才扫」,未知工具列「待确认」不阻塞;
+ *       不伪造任何采集结果——扫不到就报 0 并注明原因。
+ *
+ * 用法:node collect.mjs [--hours 48] [--out <path>] [--max-per-tool 200]
+ * 输出:采集清单 JSON(stdout 或 --out 文件)
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+
+/* ── 工具路径探测表(存在才扫)──────────────────────────────── */
+const HOME = os.homedir();
+const ALT_HOME = '/opt/data/home'; // 容器常见:getpwuid home ≠ $HOME
+
+function candidatePaths(rel) {
+  const set = new Set();
+  for (const base of [HOME, ALT_HOME, '/root']) {
+    if (base) set.add(path.join(base, rel));
+  }
+  if (process.env.STUDY_REPORT_EXTRA_SCAN_ROOT) set.add(path.join(process.env.STUDY_REPORT_EXTRA_SCAN_ROOT, rel));
+  return [...set];
+}
+
+const TOOL_PROBES = [
+  {
+    tool: 'Claude Code',
+    kind: 'session',
+    paths: candidatePaths('.claude/projects'), // <proj>/*.jsonl
+    scan: scanClaudeCode,
+  },
+  {
+    tool: 'Codex',
+    kind: 'session',
+    paths: [...candidatePaths('.codex/sessions'), ...candidatePaths('.codex/log')],
+    scan: scanGenericJsonlDir,
+  },
+  {
+    tool: 'WorkBuddy',
+    kind: 'session',
+    paths: [...candidatePaths('Library/Application Support/WorkBuddy'), ...candidatePaths('.workbuddy')],
+    scan: scanGenericJsonlDir,
+  },
+  {
+    tool: 'Trae',
+    kind: 'session',
+    paths: [...candidatePaths('.trae/sessions'), ...candidatePaths('.trae')],
+    scan: scanGenericJsonlDir,
+  },
+  {
+    tool: 'OpenClaw',
+    kind: 'session',
+    paths: [...candidatePaths('.openclaw/sessions'), ...candidatePaths('.openclaw')],
+    scan: scanGenericJsonlDir,
+  },
+  {
+    tool: '元宝',
+    kind: 'session',
+    paths: [...candidatePaths('.yuanbao'), ...candidatePaths('Library/Application Support/yuanbao')],
+    scan: scanGenericJsonlDir,
+  },
+  {
+    tool: 'Hermes Agent',
+    kind: 'session',
+    paths: [...candidatePaths('.fmode-harness-agent'), ...candidatePaths('.fmode-harness')],
+    scan: scanHermes,
+  },
+];
+
+/* ── 工作区产出物目录 ───────────────────────────────────────── */
+const WORKSPACE_DIRS = ['projects', 'git-repos', 'Desktop', 'Documents'];
+const WORKSPACE_EXTS = new Set(['.md', '.html', '.pdf', '.docx', '.xlsx', '.pptx']);
+const WORKSPACE_SKIP = new Set(['node_modules', '.git', 'dist', '.next', 'build', '.venv', 'venv', '__pycache__']);
+
+/* ── 各工具扫描实现 ─────────────────────────────────────────── */
+
+function* walk(dir, depth = 0, maxDepth = 4) {
+  if (depth > maxDepth) return;
+  let entries;
+  try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
+  for (const e of entries) {
+    const p = path.join(dir, e.name);
+    if (e.isDirectory()) {
+      if (!WORKSPACE_SKIP.has(e.name)) yield* walk(p, depth + 1, maxDepth);
+    } else if (e.isFile()) {
+      yield p;
+    }
+  }
+}
+
+function withinHours(mtimeMs, hours, now) {
+  const ageH = (now - mtimeMs) / 3600000;
+  return ageH >= 0 && ageH <= hours;
+}
+
+/** Claude Code:读 <projects>/<proj> 目录下的 .jsonl 会话(提取 user 消息 / 工具结果 / 产出文件路径) */
+function scanClaudeCode(dirs, cutoff, now, maxPerTool) {
+  const items = [];
+  for (const root of dirs) {
+    let projDirs;
+    try { projDirs = fs.readdirSync(root, { withFileTypes: true }).filter(e => e.isDirectory()); } catch { continue; }
+    for (const proj of projDirs) {
+      const projPath = path.join(root, proj.name);
+      let files;
+      try { files = fs.readdirSync(projPath).filter(f => f.endsWith('.jsonl')); } catch { continue; }
+      for (const f of files) {
+        const fp = path.join(projPath, f);
+        let st;
+        try { st = fs.statSync(fp); } catch { continue; }
+        if (!withinHours(st.mtimeMs, (now - cutoff) / 3600000, now)) continue;
+        const sess = summarizeClaudeSession(fp, st, proj.name);
+        if (sess) items.push(sess);
+      }
+    }
+  }
+  return items.sort((a, b) => b.time.localeCompare(a.time)).slice(0, maxPerTool);
+}
+
+function summarizeClaudeSession(fp, st, projDir) {
+  let userMsgs = 0;
+  let asstMsgs = 0;
+  let toolUses = 0;
+  let firstUser = null;
+  const producedFiles = new Set();
+  let cwd = null;
+  let startedAt = null;
+  try {
+    const lines = fs.readFileSync(fp, 'utf-8').split('\n');
+    for (const line of lines) {
+      if (!line.trim()) continue;
+      let d;
+      try { d = JSON.parse(line); } catch { continue; }
+      if (d.timestamp && !startedAt) startedAt = d.timestamp;
+      if (d.cwd) cwd = d.cwd;
+      if (d.type === 'user' && d.message) {
+        const c = d.message.content;
+        const text = typeof c === 'string' ? c : Array.isArray(c) ? c.filter(x => x.type === 'text').map(x => x.text).join(' ') : '';
+        if (text && !text.startsWith('<') && !/system-reminder|tool_result/i.test(text.slice(0, 60))) {
+          userMsgs++;
+          if (!firstUser && text.trim().length > 12) firstUser = text.trim().slice(0, 90);
+        }
+      } else if (d.type === 'assistant' && d.message) {
+        asstMsgs++;
+        const c = d.message.content;
+        if (Array.isArray(c)) {
+          for (const x of c) {
+            if (x.type === 'tool_use') {
+              toolUses++;
+              const inp = x.input || {};
+              const fp2 = inp.file_path || inp.notebook_path;
+              if (fp2 && /\.(md|html|pdf|docx|xlsx|pptx|mjs|js|py|json)$/i.test(fp2)) producedFiles.add(fp2);
+              if (x.name === 'Bash' && typeof inp.command === 'string') {
+                const m = inp.command.match(/([\w./-]+\.(?:html|md|pdf))\b/g);
+                if (m) m.forEach(f => { if (!/node_modules/.test(f)) producedFiles.add(f); });
+              }
+            }
+          }
+        }
+      }
+    }
+  } catch { return null; }
+  if (!firstUser) firstUser = '(会话无可读用户消息)';
+  return {
+    source: 'Claude Code',
+    time: (startedAt || st.mtime.toISOString()),
+    type: '会话',
+    summary: `用户消息 ${userMsgs} 条 / 助手 ${asstMsgs} 条 / 工具调用 ${toolUses} 次 — ${firstUser}`,
+    artifacts: [...producedFiles].slice(0, 12),
+    path: fp,
+    project: cwd || projDir.replace(/^-/, '/'),
+    url: null,
+  };
+}
+
+/** 通用 jsonl/json 会话目录扫描(Codex/Trae/WorkBuddy/OpenClaw/元宝) */
+function scanGenericJsonlDir(dirs, cutoff, now, maxPerTool) {
+  const items = [];
+  for (const root of dirs) {
+    for (const fp of walk(root, 0, 3)) {
+      const ext = path.extname(fp);
+      if (!['.jsonl', '.json', '.log'].includes(ext)) continue;
+      let st;
+      try { st = fs.statSync(fp); } catch { continue; }
+      if (!withinHours(st.mtimeMs, (now - cutoff) / 3600000, now)) continue;
+      if (st.size > 5 * 1024 * 1024) continue; // >5MB 只登记不深读
+      let summary = `${path.basename(fp)}(${(st.size / 1024).toFixed(0)}KB)`;
+      try {
+        const head = fs.readFileSync(fp, 'utf-8').slice(0, 4096);
+        const m = head.match(/"(?:text|content|message|prompt|instruction)"\s*:\s*"([^"]{16,90})/);
+        if (m) summary = m[1].replace(/\\n/g, ' ');
+      } catch { /* keep default */ }
+      items.push({
+        source: null, // 由调用方填
+        time: st.mtime.toISOString(),
+        type: '会话',
+        summary,
+        artifacts: [],
+        path: fp,
+        project: null,
+        url: null,
+      });
+    }
+  }
+  return items.slice(0, maxPerTool);
+}
+
+/** Hermes Agent:snapshots + tmp 产物 */
+function scanHermes(dirs, cutoff, now, maxPerTool) {
+  const items = [];
+  const interesting = ['sessions-recent.md', 'agent-log.json', 'skills-index.txt', 'container-topology.md'];
+  for (const root of dirs) {
+    for (const fp of walk(root, 0, 4)) {
+      const st = (() => { try { return fs.statSync(fp); } catch { return null; } })();
+      if (!st || !withinHours(st.mtimeMs, (now - cutoff) / 3600000, now)) continue;
+      const name = path.basename(fp);
+      let type = '文件';
+      let summary = `${name}(${(st.size / 1024).toFixed(0)}KB)`;
+      if (name === 'agent-log.json') {
+        type = '会话';
+        try {
+          const log = JSON.parse(fs.readFileSync(fp, 'utf-8'));
+          const arr = Array.isArray(log) ? log : Object.values(log);
+          const last = arr[arr.length - 1];
+          summary = `agent 心跳日志 ${arr.length} 条,最近:${last && last.agent ? last.agent + ' @ ' + (last.timestamp || '') : '(空)'}`;
+        } catch { /* keep */ }
+      } else if (name === 'sessions-recent.md') {
+        type = '会话';
+        try {
+          const head = fs.readFileSync(fp, 'utf-8').slice(0, 600);
+          const m = head.match(/导出时间:\s*(\S+)/);
+          summary = `最近会话快照${m ? `(导出 ${m[1]})` : ''}`;
+        } catch { /* keep */ }
+      } else if (/\.md$/.test(name)) {
+        try {
+          const head = fs.readFileSync(fp, 'utf-8').slice(0, 400);
+          const t = head.match(/^#\s+(.+)$/m);
+          if (t) summary = t[1].slice(0, 90);
+        } catch { /* keep */ }
+      }
+      items.push({ source: 'Hermes Agent', time: st.mtime.toISOString(), type, summary, artifacts: [], path: fp, project: null, url: null });
+    }
+  }
+  return items.sort((a, b) => b.time.localeCompare(a.time)).slice(0, maxPerTool);
+}
+
+/** 工作区:48h 内 mtime 新文件(.md/.html/.pdf/.docx/.xlsx) */
+function scanWorkspaces(hours, now, maxPerTool) {
+  const items = [];
+  const roots = [...new Set([...WORKSPACE_DIRS.map(d => path.join(HOME, d)), ...WORKSPACE_DIRS.map(d => path.join(ALT_HOME, d)), '/opt/data/git-repos', '/opt/data/projects'])];
+  for (const root of roots) {
+    if (!fs.existsSync(root)) continue;
+    for (const fp of walk(root, 0, 5)) {
+      const ext = path.extname(fp).toLowerCase();
+      if (!WORKSPACE_EXTS.has(ext)) continue;
+      let st;
+      try { st = fs.statSync(fp); } catch { continue; }
+      if (!withinHours(st.mtimeMs, hours, now)) continue;
+      let summary = `${path.basename(fp)}(${(st.size / 1024).toFixed(0)}KB)`;
+      if (ext === '.md') {
+        try {
+          const head = fs.readFileSync(fp, 'utf-8').slice(0, 500);
+          const t = head.match(/^#\s+(.+)$/m);
+          if (t) summary = t[1].slice(0, 90);
+        } catch { /* keep */ }
+      }
+      items.push({
+        source: '工作区',
+        time: st.mtime.toISOString(),
+        type: ext === '.html' ? '交付物' : '文件',
+        summary,
+        artifacts: [fp],
+        path: fp,
+        project: path.basename(path.dirname(fp)),
+        url: null,
+      });
+    }
+  }
+  return items.sort((a, b) => b.time.localeCompare(a.time)).slice(0, maxPerTool);
+}
+
+/* ── 汇总 ───────────────────────────────────────────────────── */
+export function collect({ hours = 48, maxPerTool = 200 } = {}) {
+  const now = Date.now();
+  const cutoff = now - hours * 3600000;
+  const items = [];
+  const toolsStatus = [];
+
+  for (const probe of TOOL_PROBES) {
+    const existing = probe.paths.filter(p => fs.existsSync(p));
+    if (existing.length === 0) {
+      toolsStatus.push({ tool: probe.tool, status: '未检出(本机无该工具痕迹,跳过;若实际有用请人工确认路径)' });
+      continue;
+    }
+    const found = probe.scan(existing, cutoff, now, maxPerTool);
+    for (const it of found) if (!it.source) it.source = probe.tool;
+    items.push(...found);
+    toolsStatus.push({ tool: probe.tool, status: `已扫描 ${existing.length} 个路径,命中 ${found.length} 条` });
+  }
+
+  const ws = scanWorkspaces(hours, now, maxPerTool);
+  items.push(...ws);
+  toolsStatus.push({ tool: '工作区产出物', status: `已扫描工作区目录,命中 ${ws.length} 条` });
+
+  // 统计
+  const bySource = {};
+  const byType = {};
+  for (const it of items) {
+    bySource[it.source] = (bySource[it.source] || 0) + 1;
+    byType[it.type] = (byType[it.type] || 0) + 1;
+  }
+  const artifactPaths = [...new Set(items.flatMap(i => i.artifacts || []))].slice(0, 300);
+
+  return {
+    meta: {
+      generatedAt: new Date().toISOString(),
+      windowHours: hours,
+      windowStart: new Date(cutoff).toISOString(),
+      host: os.hostname(),
+      user: process.env.USER || os.userInfo().username,
+    },
+    stats: {
+      total: items.length,
+      sessionCount: byType['会话'] || 0,
+      fileCount: (byType['文件'] || 0) + (byType['交付物'] || 0),
+      bySource,
+      byType,
+      artifactCount: artifactPaths.length,
+    },
+    toolsStatus,
+    items,
+    artifacts: artifactPaths,
+  };
+}
+
+/* ── CLI ────────────────────────────────────────────────────── */
+function argVal(flag, dflt) {
+  const i = process.argv.indexOf(flag);
+  return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
+}
+const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(decodeURIComponent(new URL(import.meta.url).pathname));
+if (isMain) {
+  const hours = Number(argVal('--hours', '48'));
+  const out = argVal('--out', null);
+  const result = collect({ hours, maxPerTool: Number(argVal('--max-per-tool', '200')) });
+
+  /* 人类可读采集清单 */
+  console.log(`\n📋 采集清单(近 ${hours}h · 生成于 ${result.meta.generatedAt})`);
+  console.log(`   会话 ${result.stats.sessionCount} 条 · 文件/交付物 ${result.stats.fileCount} 条 · 合计 ${result.stats.total} 条\n`);
+  console.log('工具探测:');
+  for (const t of result.toolsStatus) console.log(`  · ${t.tool}: ${t.status}`);
+  console.log('\n来源分布:', JSON.stringify(result.stats.bySource));
+  console.log('\n最近条目(前 20):');
+  for (const it of result.items.slice(0, 20)) {
+    console.log(`  [${it.source}] ${it.time.slice(0, 16).replace('T', ' ')} ${it.type} — ${it.summary.slice(0, 70)}`);
+  }
+  if (result.stats.total === 0) {
+    console.log('\n⚠️  未采集到任何痕迹:确认工具安装路径,或用 --hours 放宽窗口。不伪造采集结果。');
+  }
+  console.log('');
+
+  if (out) {
+    fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
+    fs.writeFileSync(path.resolve(out), JSON.stringify(result, null, 2));
+    console.log(`已写出:${path.resolve(out)}`);
+  } else {
+    process.stdout.write('\n<!--JSON-BEGIN-->\n' + JSON.stringify(result) + '\n<!--JSON-END-->\n');
+  }
+}

+ 283 - 0
skills/study-report/scripts/precheck.mjs

@@ -0,0 +1,283 @@
+/**
+ * precheck.mjs — 安装前置自检(安装体检表)
+ *
+ * 按 07-git.md 已验证链路检查三环节,任一环节不通给出恢复指引,绝不伪造通过:
+ *   1. 平台身份:~/.fmode/config/user.json 的 fmodeApiToken(真实字段名,非 sessionToken)
+ *      → POST server.fmode.cn/api/functions HEAD 探测平台可达
+ *   2. Git 账户:POST /api/functions {token, path:"/gogs/admin/proxy",
+ *      params:{method:"POST", path:"/admin/users", body:{...}}}
+ *      → 201 新建 / 200 幂等返回新 token.sha1(忘密码不找回,重调即得)
+ *      → 401/209 → 提示走「对话式验证码登录」恢复(07-git.md 标准节)
+ *   3. Storage 能力:探测 obsutil config / OBS_AK 环境变量;无 → 不阻塞(报告走 Gogs 降级)
+ *
+ * 纪律:凭据零暴露(输出绝不含 token/AK/SK 本体);结果只报状态不造假。
+ *
+ * 用法:node precheck.mjs [--json]
+ *   --json  供上游脚本消费(含 gogsToken,仅内存传递,不落盘)
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+import { execFileSync } from 'node:child_process';
+
+const FMODE_API_BASE = (process.env.FMODE_FUNCTIONS_BASE_URL || 'https://server.fmode.cn').replace(/\/$/, '');
+const GOGS_BASE = 'https://git.fmode.cn';
+
+function readJsonMaybe(p) {
+  try {
+    if (!p || !fs.existsSync(p)) return null;
+    return JSON.parse(fs.readFileSync(p, 'utf-8').replace(/^/, ''));
+  } catch {
+    return null;
+  }
+}
+
+/* ── 环节 1:平台身份 ─────────────────────────────────────────── */
+function checkPlatformIdentity() {
+  const row = { item: '平台身份', ok: false, detail: '', hint: '' };
+  const candidates = [
+    process.env.FMODE_USER_CONFIG,
+    path.join(os.homedir(), '.fmode', 'config', 'user.json'),
+    path.join('/opt/data/home', '.fmode', 'config', 'user.json'),
+  ].filter(Boolean);
+  let token = null;
+  let cfgPath = null;
+  let tokenKind = null;
+  for (const p of candidates) {
+    const cfg = readJsonMaybe(p);
+    if (cfg && cfg.fmodeApiToken) { token = cfg.fmodeApiToken; cfgPath = p; tokenKind = 'fmodeApiToken'; break; }
+  }
+  // 云函数 /gogs/admin/proxy 的 token 口径是 Parse sessionToken(34 位),非 sk- API token。
+  // 两者都在场时 sessionToken 优先供 Git 环节使用,fmodeApiToken 仅作平台身份在场证明。
+  let sessionToken = process.env.FMODE_SESSION_TOKEN && !/^sk-/.test(process.env.FMODE_SESSION_TOKEN)
+    ? process.env.FMODE_SESSION_TOKEN.trim() : null;
+  for (const p of candidates) {
+    const cfg = readJsonMaybe(p);
+    if (!sessionToken && cfg && cfg.sessionToken) { sessionToken = cfg.sessionToken; cfgPath = p; tokenKind = 'sessionToken'; break; }
+  }
+  if (!token && !sessionToken) {
+    row.detail = '未找到 fmodeApiToken(~/.fmode/config/user.json)';
+    row.hint = '登录 FMODE Studio 保存一次配置,或对话式验证码登录';
+    return { row, token: null };
+  }
+  row.detail = `已读取(${cfgPath},${tokenKind}=${tokenKind === 'fmodeApiToken' && token ? 'sk-***' + token.slice(-4) : '***' + String(sessionToken || '').slice(-4)})`;
+  return { row, token: sessionToken || token, tokenKind };
+}
+
+async function probePlatformReachable() {
+  // HEAD 探测平台可达(任意 4xx/5xx 响应头均证明链路通)
+  try {
+    const res = await fetch(`${FMODE_API_BASE}/api/functions`, { method: 'HEAD', signal: AbortSignal.timeout(8000) });
+    return { reachable: true, status: res.status };
+  } catch {
+    try {
+      const res = await fetch(`${FMODE_API_BASE}/parse/health`, { signal: AbortSignal.timeout(8000) });
+      return { reachable: res.ok, status: res.status };
+    } catch (e) {
+      return { reachable: false, status: 0, error: String(e.message || e) };
+    }
+  }
+}
+
+/* ── 环节 2:Git 账户(07-git.md 已验证链路)──────────────────── */
+async function ensureGitAccount(platformToken) {
+  const row = { item: 'Git 账户', ok: false, detail: '', hint: '' };
+  if (!platformToken) {
+    row.detail = '跳过(无平台 token)';
+    row.hint = '先恢复平台身份';
+    return { row, gogsToken: null, gogsUser: null };
+  }
+  const mobile = process.env.FMODE_MOBILE || 'user'; // 稳定用户标识,来自环境或默认
+  const username = `fmode-${mobile}`;
+  try {
+    const res = await fetch(`${FMODE_API_BASE}/api/functions`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      signal: AbortSignal.timeout(20000),
+      body: JSON.stringify({
+        token: platformToken,
+        path: '/gogs/admin/proxy',
+        params: {
+          method: 'POST',
+          path: '/admin/users',
+          body: {
+            username,
+            // email 用 username 派生:与既有账户 email 冲突会让云函数"重置内部密码失败"(502)。
+            // 422 实测:admin/users 对 email 唯一性敏感;派生值保证幂等重调永远命中同一账户。
+            email: process.env.FMODE_EMAIL || `${username}@fmode.agent`,
+            send_notify: false,
+            // 注:已存在账户 → 云函数幂等补发新 access token(200 + token.sha1),
+            // 原密码不可找回也不需要;新账户(201)由云函数代管初始密码。
+          },
+        },
+      }),
+    });
+    const body = await res.json().catch(() => ({}));
+    if (res.status === 401 || res.status === 209) {
+      row.detail = `平台返回 ${res.status}(token 失效/未登录)`;
+      row.hint = '走「对话式验证码登录」恢复:手机号 → VerifyCode → 换 sessionToken(07-git.md 标准节)';
+      return { row, gogsToken: null, gogsUser: null };
+    }
+    if (res.status === 201 && body.username) {
+      // 新建账户:201 只返回用户对象,token 由云函数侧续发一次幂等调用取得
+      const again = await fetch(`${FMODE_API_BASE}/api/functions`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        signal: AbortSignal.timeout(20000),
+        body: JSON.stringify({
+          token: platformToken,
+          path: '/gogs/admin/proxy',
+          params: { method: 'POST', path: '/admin/users', body: { username, email: process.env.FMODE_EMAIL || `${username}@fmode.agent`, send_notify: false } },
+        }),
+      });
+      const againBody = await again.json().catch(() => ({}));
+      if (again.status === 200 && againBody.token && againBody.token.sha1) {
+        row.ok = true;
+        row.detail = `新建 Gogs 账户 ${againBody.username},access token 已签发(sha1 ***${againBody.token.sha1.slice(-4)})`;
+        return { row, gogsToken: againBody.token.sha1, gogsUser: againBody.username };
+      }
+      row.detail = `账户已创建(201)但 token 续发未返回(${again.status})`;
+      row.hint = '重跑本自检(幂等)';
+      return { row, gogsToken: null, gogsUser: body.username };
+    }
+    if ((res.status === 200 || res.status === 502) && body.token && body.token.sha1) {
+      row.ok = true;
+      row.detail = `Git 账户已存在(${body.username}),幂等补发新 access token(sha1 ***${body.token.sha1.slice(-4)})${res.status === 502 ? '(云函数 502 但返回体有效)' : ''}`;
+      return { row, gogsToken: body.token.sha1, gogsUser: body.username };
+    }
+    /* email 冲突等 4xx → 换稳定派生 email 重试一次(幂等口径不变) */
+    if (res.status >= 400 && res.status < 500 && /email/i.test(JSON.stringify(body))) {
+      const altEmail = `${username}@fmode.agent`;
+      const retry = await fetch(`${FMODE_API_BASE}/api/functions`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        signal: AbortSignal.timeout(20000),
+        body: JSON.stringify({
+          token: platformToken,
+          path: '/gogs/admin/proxy',
+          params: { method: 'POST', path: '/admin/users', body: { username, email: altEmail, send_notify: false } },
+        }),
+      });
+      const retryBody = await retry.json().catch(() => ({}));
+      if ((retry.status === 200 || retry.status === 201 || retry.status === 502) && retryBody.token && retryBody.token.sha1) {
+        row.ok = true;
+        row.detail = `Git 账户就绪(${retryBody.username},email 冲突已换 ${altEmail}),token 补发成功(sha1 ***${retryBody.token.sha1.slice(-4)})`;
+        return { row, gogsToken: retryBody.token.sha1, gogsUser: retryBody.username };
+      }
+    }
+    row.detail = `未预期的响应 ${res.status}:${JSON.stringify(body).slice(0, 120)}`;
+    row.hint = '重跑自检;持续失败报告用户,不伪造通过';
+    return { row, gogsToken: null, gogsUser: null };
+  } catch (e) {
+    row.detail = `网络/服务异常:${String(e.message || e).slice(0, 120)}`;
+    row.hint = '检查 server.fmode.cn 可达性后重跑';
+    return { row, gogsToken: null, gogsUser: null };
+  }
+}
+
+/* ── 环节 3:Storage 能力(不阻塞)──────────────────────────── */
+function checkStorage() {
+  const row = { item: 'Storage 能力', ok: false, degraded: true, detail: '', hint: '' };
+  // 3a. 环境变量
+  if (process.env.OBS_AK && process.env.OBS_SK) {
+    row.ok = true; row.degraded = false;
+    row.detail = 'OBS_AK/OBS_SK 环境变量已配置';
+    return { row };
+  }
+  // 3b. obsutil config 文件(OBSUTIL_CONFIG / ~/.obsutilconfig / getpwuid home 变体)
+  const cfgPaths = [];
+  if (process.env.OBSUTIL_CONFIG) cfgPaths.push(process.env.OBSUTIL_CONFIG);
+  cfgPaths.push(path.join(os.homedir(), '.obsutilconfig'), path.join('/opt/data', '.obsutilconfig'));
+  try {
+    const pwHome = os.userInfo().homedir;
+    if (pwHome) cfgPaths.push(path.join(pwHome, '.obsutilconfig'));
+  } catch { /* ignore */ }
+  for (const p of [...new Set(cfgPaths)]) {
+    try {
+      const text = fs.readFileSync(p, 'utf-8');
+      if (/^ak=/m.test(text) && /^sk=/m.test(text)) {
+        row.ok = true; row.degraded = false;
+        row.detail = `obsutil config 已配置(${p})`;
+        return { row };
+      }
+    } catch { /* next */ }
+  }
+  // 3c. obsutil 可执行文件存在(有 config 但无二进制也算能力缺失)
+  row.detail = '未探测到 OBS 凭据(OBS_AK/SK 环境变量 或 obsutil config)';
+  row.hint = '不阻塞:报告走 Gogs 降级通道;如需 Storage 主通道,按 skill-storage init 向导一次性配置';
+  return { row };
+}
+
+/* ── 主流程 ─────────────────────────────────────────────────── */
+export async function runPrecheck({ json = false } = {}) {
+  const { row: idRow, token } = checkPlatformIdentity();
+  const reach = token ? await probePlatformReachable() : { reachable: false, status: 0 };
+  if (!reach.reachable && token) {
+    idRow.ok = false;
+    idRow.detail += ',但平台探测不可达';
+    idRow.hint = '检查网络 / server.fmode.cn 状态';
+  } else if (reach.reachable) {
+    idRow.ok = true;
+    idRow.detail += `;平台可达(探测 ${reach.status})`;
+  }
+
+  const git = await ensureGitAccount(reach.reachable ? token : null);
+  const storage = checkStorage();
+
+  const rows = [idRow, git.row, storage.row];
+  const passAll = rows.every(r => r.ok);
+
+  if (json) {
+    // gogsToken 仅随进程内存/管道传递,禁止落盘
+    process.stdout.write(JSON.stringify({
+      passAll,
+      rows,
+      gogsToken: git.gogsToken,
+      gogsUser: git.gogsUser,
+    }) + '\n');
+    return { passAll, rows, gogsToken: git.gogsToken, gogsUser: git.gogsUser };
+  }
+
+  /* 安装体检表(人类可读,凭据零暴露) */
+  const line = '─'.repeat(66);
+  console.log('┌' + line + '┐');
+  console.log('│  study-report · 安装体检表' + ' '.repeat(40) + '│');
+  console.log('├' + line + '┤');
+  for (const r of rows) {
+    const mark = r.ok ? '✅' : (r.degraded ? '🟡' : '❌');
+    const name = (r.item + ' ').padEnd(10, '·');
+    console.log(`│ ${mark} ${name} ${r.detail}`.slice(0, 66 + 2).padEnd(67) + '│');
+    if (!r.ok && r.hint) {
+      for (const ln of wrapHint(r.hint, 58)) {
+        console.log(`│      ${ln}`.padEnd(67) + '│');
+      }
+    }
+  }
+  console.log('├' + line + '┤');
+  console.log(`│ 结论:${passAll ? '全部就绪,可直接执行完整流程' : '存在未就绪环节——按上方指引恢复后重跑'}${' '.repeat(8)}│`);
+  console.log('└' + line + '┘');
+  return { passAll, rows, gogsToken: git.gogsToken, gogsUser: git.gogsUser };
+}
+
+function wrapHint(text, width) {
+  const out = [];
+  let cur = '';
+  for (const ch of text) {
+    cur += ch;
+    if (cur.length >= width) { out.push(cur); cur = ''; }
+  }
+  if (cur) out.push(cur);
+  return out;
+}
+
+/* ── CLI ────────────────────────────────────────────────────── */
+const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(decodeURIComponent(new URL(import.meta.url).pathname));
+if (isMain) {
+  runPrecheck({ json: process.argv.includes('--json') }).then(r => {
+    process.exitCode = r.passAll ? 0 : 1;
+  }).catch(e => {
+    console.error('[precheck] 异常退出:', e.message);
+    process.exit(2);
+  });
+}

+ 305 - 0
skills/study-report/scripts/publish.mjs

@@ -0,0 +1,305 @@
+/**
+ * publish.mjs — Storage 主 + Gogs 降级/并行 双通道发布
+ *
+ * 1. Storage(主):复用 skill-storage uploader.mjs(诚实 4 级凭据链)put 报告.html
+ *    → 公开 URL(study-report/<姓名>-<日期>/ 前缀)
+ * 2. Gogs(降级/并行):用自检拿到的 token 建 study-report-<姓名拼音> 仓 → push 报告+素材
+ *    (建仓走 07-git.md 已验证链路:云函数幂等 token → Gogs API/web 建仓 → HTTPS push)
+ * 3. ZIP:打包报告目录 → Storage put 到 downloads/skill-study-report.zip(CDN 通道探测,
+ *    fmode.cn/downloads/ 映射未上线时报告为 Storage 直链,不伪造 CDN URL)
+ *
+ * 07-git.md 纪律:凭据零暴露 / 不伪造成功 / 状态门禁分别报告(Storage/Gogs/ZIP 三个独立状态)。
+ *
+ * 用法:node publish.mjs --dir <报告目录> [--name 姓名] [--gogs-token <自检所得token>]
+ *                  [--gogs-user <gogs用户名>] [--no-gogs] [--no-storage]
+ * 输出:JSON { storage: {...}, gogs: {...}, zip: {...} }(每通道独立 ok 状态)
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+import { execFileSync } from 'node:child_process';
+
+const FMODE_API_BASE = (process.env.FMODE_FUNCTIONS_BASE_URL || 'https://server.fmode.cn').replace(/\/$/, '');
+const GOGS_BASE = 'https://git.fmode.cn';
+const UPLOADER = process.env.SKILL_STORAGE_UPLOADER
+  || '/opt/data/git-repos/fmode/skill-storage/skills/fmode-storage/scripts/uploader.mjs';
+
+function argVal(flag, dflt) {
+  const i = process.argv.indexOf(flag);
+  return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
+}
+function hasFlag(flag) { return process.argv.includes(flag); }
+
+function sh(cmd, args, opts = {}) {
+  return execFileSync(cmd, args, { encoding: 'utf8', timeout: 180000, ...opts });
+}
+
+/** 汉字姓名 → 拼音仓库名(无拼音库,取用户名/uid 兜底,稳定即可) */
+export function repoNameFor(name) {
+  const pinyinMap = { 刘: 'liu', 雨: 'yu', 飏: 'yang' };
+  let py = '';
+  for (const ch of String(name)) py += pinyinMap[ch] || '';
+  if (py) return `study-report-${py}`;
+  const ascii = String(name).toLowerCase().replace(/[^a-z0-9]/g, '');
+  const uid = (process.env.USER || 'user').toLowerCase().replace(/[^a-z0-9]/g, '');
+  return `study-report-${ascii || uid || 'agent'}`;
+}
+
+/* ── 通道 1:Storage(主)────────────────────────────────── */
+export async function publishStorage(reportPath, name, dateTag) {
+  if (!fs.existsSync(UPLOADER)) {
+    return { ok: false, degraded: true, reason: `skill-storage uploader 未找到:${UPLOADER}(设 SKILL_STORAGE_UPLOADER 指向)` };
+  }
+  const key = `study-report/${name}-${dateTag}/report.html`;
+  try {
+    const out = sh(process.execPath, [UPLOADER, 'put', reportPath, '--key', key], { timeout: 300000 });
+    const jsonStart = out.indexOf('{');
+    if (jsonStart < 0) throw new Error('uploader 输出非 JSON:' + out.slice(0, 120));
+    const parsed = JSON.parse(out.slice(jsonStart));
+    if (!parsed.ok || !parsed.url) throw new Error('uploader 未返回 ok/url');
+    /* 公开可达性验证——不伪造成功 */
+    const head = await fetch(parsed.url, { method: 'HEAD', signal: AbortSignal.timeout(15000) });
+    if (!head.ok) throw new Error(`上传后 HEAD 验证 ${head.status}`);
+    return { ok: true, url: parsed.url, key, via: parsed.via || null };
+  } catch (e) {
+    return { ok: false, degraded: true, reason: String(e.message || e).slice(0, 200) };
+  }
+}
+
+/* ── 通道 2:Gogs(降级/并行)────────────────────────────── */
+
+/**
+ * 幂等拿 Gogs token:优先用自检传入;否则重调云函数(07-git.md:忘密码不找回,重调即得)。
+ * token 口径与 precheck 一致:云函数要 Parse sessionToken(非 sk- API token)。
+ * email 用 username 派生(email 冲突会让云函数"重置内部密码失败"→502)。
+ */
+async function ensureGogsToken() {
+  const userCfgPaths = [path.join(os.homedir(), '.fmode', 'config', 'user.json'), '/opt/data/home/.fmode/config/user.json'];
+  let platformToken = process.env.FMODE_SESSION_TOKEN && !/^sk-/.test(process.env.FMODE_SESSION_TOKEN)
+    ? process.env.FMODE_SESSION_TOKEN.trim() : null;
+  for (const p of userCfgPaths) {
+    if (platformToken) break;
+    try {
+      const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'));
+      platformToken = cfg.sessionToken || null;
+    } catch { /* next */ }
+  }
+  if (!platformToken) return { ok: false, reason: '无平台 sessionToken(precheck 环节1 未通过)' };
+
+  const mobile = process.env.FMODE_MOBILE || 'user';
+  const username = `fmode-${mobile}`;
+  try {
+    const res = await fetch(`${FMODE_API_BASE}/api/functions`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      signal: AbortSignal.timeout(20000),
+      body: JSON.stringify({
+        token: platformToken,
+        path: '/gogs/admin/proxy',
+        params: { method: 'POST', path: '/admin/users', body: { username, email: process.env.FMODE_EMAIL || `${username}@fmode.agent`, send_notify: false } },
+      }),
+    });
+    const body = await res.json().catch(() => ({}));
+    if ((res.status === 200 || res.status === 201 || res.status === 502) && body.token && body.token.sha1) {
+      return { ok: true, token: body.token.sha1, user: body.username };
+    }
+    return { ok: false, reason: `云函数 ${res.status}:${JSON.stringify(body).slice(0, 120)}` };
+  } catch (e) {
+    return { ok: false, reason: String(e.message || e).slice(0, 160) };
+  }
+}
+
+/** 幂等建仓:Gogs API(token)→ 已存在(409)复用 → 401 时回落 web 表单(fmode org 场景) */
+async function ensureRepo(gogsToken, gogsUser, repoName, description) {
+  /* 先探测是否已存在 */
+  try {
+    const probe = await fetch(`${GOGS_BASE}/api/v1/repos/${gogsUser}/${repoName}`, {
+      headers: { Authorization: `token ${gogsToken}` }, signal: AbortSignal.timeout(15000),
+    });
+    if (probe.ok) return { ok: true, fullName: `${gogsUser}/${repoName}`, reused: true, ownerType: 'user' };
+  } catch { /* continue to create */ }
+
+  /* 用户名下建仓 */
+  const res = await fetch(`${GOGS_BASE}/api/v1/user/repos`, {
+    method: 'POST',
+    headers: { Authorization: `token ${gogsToken}`, 'Content-Type': 'application/json' },
+    signal: AbortSignal.timeout(20000),
+    body: JSON.stringify({ name: repoName, private: false, description, auto_init: false }),
+  });
+  if (res.ok) {
+    const body = await res.json().catch(() => ({}));
+    return { ok: true, fullName: body.full_name || `${gogsUser}/${repoName}`, reused: false, ownerType: 'user' };
+  }
+  if (res.status === 409) return { ok: true, fullName: `${gogsUser}/${repoName}`, reused: true, ownerType: 'user' };
+  return { ok: false, status: res.status, reason: `建仓失败 ${res.status}` };
+}
+
+/** push 报告目录到 Gogs(凭据不进 remote URL 持久层——临时 URL 仅进程内使用) */
+export function pushToGogs(repoFullName, gogsToken, gogsUser, dir, commitMsg) {
+  const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sr-gogs-'));
+  try {
+    /* 复制报告目录(排除大素材),init + commit + push */
+    sh('cp', ['-r', `${dir}/.`, workDir]);
+    const gitDir = path.join(workDir, '.git');
+    fs.rmSync(gitDir, { recursive: true, force: true }); // 报告目录若自带 .git 则去掉
+    sh('git', ['init', '-q', '-b', 'main'], { cwd: workDir });
+    sh('git', ['config', 'user.email', process.env.FMODE_EMAIL || 'agent@fmode.cn'], { cwd: workDir });
+    sh('git', ['config', 'user.name', 'study-report-agent'], { cwd: workDir });
+    fs.writeFileSync(path.join(workDir, '.gitignore'), 'node_modules/\n.DS_Store\n*.log\n');
+    sh('git', ['add', '-A'], { cwd: workDir });
+    sh('git', ['commit', '-q', '-m', commitMsg], { cwd: workDir });
+    /* 一次性 URL:凭据只存在于本次 push 的进程参数中,不写入任何持久文件 */
+    const url = `https://${gogsUser}:${gogsToken}@${GOGS_BASE.replace('https://', '')}/${repoFullName}.git`;
+    /* 幂等重跑:远端已有历史 → fetch 后以远端为基叠加本次报告(不强推、不删历史,07-git 纪律) */
+    let pushArgs = ['push', '-q', url, 'HEAD:main'];
+    try {
+      sh('git', ['fetch', '-q', url, 'main'], { cwd: workDir });
+      sh('git', ['reset', '-q', '--soft', 'FETCH_HEAD'], { cwd: workDir });
+      /* 远端历史之上重放工作区内容:相同内容 → 无新 commit 可跳过;有变化 → 新 commit */
+      const staged = sh('git', ['status', '--porcelain'], { cwd: workDir }).trim();
+      if (staged) {
+        sh('git', ['add', '-A'], { cwd: workDir });
+        sh('git', ['commit', '-q', '-m', commitMsg], { cwd: workDir });
+      }
+      pushArgs = ['push', '-q', url, 'HEAD:main'];
+    } catch { /* 远端为空仓:直接首推 */ }
+    sh('git', pushArgs, { cwd: workDir });
+    /* ls-remote 验证(07-git.md:验证远端 SHA) */
+    const remote = sh('git', ['ls-remote', url, 'HEAD'], { cwd: workDir }).trim();
+    const sha = remote.split('\t')[0] || null;
+    return { ok: true, repo: `https://${GOGS_BASE.replace('https://', '')}/${repoFullName}`, commit: sha };
+  } catch (e) {
+    return { ok: false, reason: String(e.message || e).slice(0, 300) };
+  } finally {
+    fs.rmSync(workDir, { recursive: true, force: true });
+  }
+}
+
+export async function publishGogs({ dir, name, gogsToken = null, gogsUser = null }) {
+  try {
+    let token = gots => gots;
+    let tokenRes = { ok: !!(gogsToken && gogsUser), token: gogsToken, user: gogsUser };
+    if (!tokenRes.ok) {
+      tokenRes = await ensureGogsToken();
+      if (!tokenRes.ok) return { ok: false, degraded: true, reason: tokenRes.reason };
+    }
+    const repoName = repoNameFor(name);
+    const repo = await ensureRepo(tokenRes.token, tokenRes.user, repoName, `${name} · 48h Agent 学习复盘报告(skill-study-report 自动发布)`);
+    if (!repo.ok) return { ok: false, degraded: true, reason: repo.reason };
+    void token;
+    const pushed = pushToGogs(repo.fullName, tokenRes.token, tokenRes.user, dir,
+      `feat: ${name} 学习复盘报告(自动发布 ${new Date().toISOString().slice(0, 10)})`);
+    if (!pushed.ok) return { ok: false, degraded: true, reason: pushed.reason, repo: repo.fullName };
+    return {
+      ok: true,
+      repo: pushed.repo,
+      commit: pushed.commit,
+      reportUrl: `${pushed.repo}/blob/main/report.html`,
+      reused: repo.reused,
+    };
+  } catch (e) {
+    return { ok: false, degraded: true, reason: String(e.message || e).slice(0, 200) };
+  }
+}
+
+/* ── 通道 3:ZIP → Storage downloads/(CDN 通道)──────────── */
+export async function publishZip(dir, name, dateTag) {
+  const zipBase = `/tmp/study-report-${name}-${dateTag}`;
+  const zipPath = `${zipBase}.zip`;
+  try {
+    fs.rmSync(zipPath, { force: true });
+    const workZipDir = path.dirname(zipPath);
+    sh('cp', ['-r', dir, zipBase]);
+    fs.rmSync(path.join(zipBase, 'report-data.json'), { force: true }); // ZIP 只带可复现壳
+    try {
+      sh('zip', ['-qr', zipPath, path.basename(zipBase)], { cwd: workZipDir });
+    } catch {
+      /* zip 不在 PATH → python3 zipfile 回落(幂等产物) */
+      sh('python3', ['-c',
+        `import shutil; shutil.make_archive(${JSON.stringify(zipPath.replace(/\.zip$/, ''))}, 'zip', ${JSON.stringify(workZipDir)}, ${JSON.stringify(path.basename(zipBase))})`]);
+    }
+    fs.rmSync(zipBase, { recursive: true, force: true });
+
+    if (!fs.existsSync(UPLOADER)) {
+      return { ok: false, degraded: true, localFile: zipPath, reason: 'uploader 未找到——ZIP 留本地' };
+    }
+    const key = `downloads/${path.basename(zipPath)}`;
+    const out = sh(process.execPath, [UPLOADER, 'put', zipPath, '--key', key], { timeout: 300000 });
+    const jsonStart = out.indexOf('{');
+    const parsed = jsonStart >= 0 ? JSON.parse(out.slice(jsonStart)) : null;
+    if (!parsed || !parsed.ok) throw new Error('ZIP 上传失败');
+    /* CDN 探测:fmode.cn/downloads/ 是否映射(官方源站配置后自动生效) */
+    let cdnOk = false;
+    try {
+      const head = await fetch(`https://fmode.cn/${key}`, { method: 'HEAD', signal: AbortSignal.timeout(10000) });
+      cdnOk = head.ok;
+    } catch { cdnOk = false; }
+    /* 直链可达性 */
+    let directOk = false;
+    try {
+      const head = await fetch(parsed.url, { method: 'HEAD', signal: AbortSignal.timeout(15000) });
+      directOk = head.ok;
+    } catch { directOk = false; }
+    return {
+      ok: directOk || cdnOk,
+      cdnUrl: cdnOk ? `https://fmode.cn/${key}` : null,
+      directUrl: directOk ? parsed.url : null,
+      cdnMapped: cdnOk,
+      localFile: zipPath,
+      note: cdnOk ? 'CDN 已映射' : 'CDN 未映射(fmode.cn/downloads/ 官方源站未配置),用 Storage 直链',
+    };
+  } catch (e) {
+    return { ok: false, degraded: true, reason: String(e.message || e).slice(0, 200) };
+  }
+}
+
+/* ── 主流程 ─────────────────────────────────────────────── */
+const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(decodeURIComponent(new URL(import.meta.url).pathname));
+if (isMain) {
+  const dir = argVal('--dir', null);
+  if (!dir || !fs.existsSync(path.join(dir, 'report.html'))) {
+    console.error('[publish] 缺 --dir <报告目录>(须含 report.html,先跑 build-report.mjs)');
+    process.exit(2);
+  }
+  const name = argVal('--name', (process.env.USER || 'agent'));
+  const dateTag = new Date().toISOString().slice(0, 10);
+
+  (async () => {
+    const result = { name, dateTag };
+
+    if (!hasFlag('--no-storage')) {
+      console.log('[publish] 通道1 Storage(主)上传中…');
+      result.storage = await publishStorage(path.join(dir, 'report.html'), name, dateTag);
+      console.log(`  → ${result.storage.ok ? '✅ ' + result.storage.url : '🟡 降级:' + result.storage.reason}`);
+    }
+
+    if (!hasFlag('--no-gogs')) {
+      console.log('[publish] 通道2 Gogs(降级/并行)…');
+      result.gogs = await publishGogs({
+        dir, name,
+        gogsToken: argVal('--gogs-token', null),
+        gogsUser: argVal('--gogs-user', null),
+      });
+      console.log(`  → ${result.gogs.ok ? '✅ ' + result.gogs.repo : '🟡 降级:' + result.gogs.reason}`);
+    }
+
+    console.log('[publish] 通道3 ZIP → Storage downloads/(CDN 探测)…');
+    result.zip = await publishZip(dir, name, dateTag);
+    console.log(`  → ${result.zip.ok
+      ? (result.zip.cdnMapped ? '✅ CDN: ' + result.zip.cdnUrl : '✅ 直链: ' + result.zip.directUrl)
+      : '🟡 ' + (result.zip.reason || '失败')}`);
+
+    /* 状态门禁分别报告 */
+    const anyOk = [result.storage, result.gogs, result.zip].filter(Boolean).some(r => r.ok);
+    fs.writeFileSync(path.join(dir, 'publish-result.json'), JSON.stringify(result, null, 2));
+    console.log('\n发布状态门禁:');
+    console.log(`  Storage: ${result.storage ? (result.storage.ok ? '✅ 已发布' : '🟡 未发布(' + result.storage.reason.slice(0, 40) + ')') : '⏭ 跳过'}`);
+    console.log(`  Gogs:    ${result.gogs ? (result.gogs.ok ? '✅ 已归档 ' + result.gogs.repo : '🟡 未归档') : '⏭ 跳过'}`);
+    console.log(`  ZIP:     ${result.zip.ok ? (result.zip.cdnMapped ? '✅ CDN 分发' : '✅ Storage 直链') : '🟡 未分发'}`);
+    process.exitCode = anyOk ? 0 : 2;
+  })().catch(e => {
+    console.error('[publish] 异常退出:', e.message);
+    process.exit(2);
+  });
+}

+ 376 - 0
skills/study-report/templates/report-template.html

@@ -0,0 +1,376 @@
+<!DOCTYPE html>
+<html lang="zh-CN" data-theme="light">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>{{REPORT_TITLE}}</title>
+<link rel="stylesheet" href="../lib/theme/tokens.css">
+<link rel="stylesheet" href="../lib/theme/base.css">
+<script src="../lib/vendor/gsap/gsap.min.js"></script>
+<script src="../lib/theme/theme.js"></script>
+<script src="../lib/player/canvas-scale.js"></script>
+<style>
+/* 页内补充样式:只用 var(--...),不新增色值(token 唯一来源纪律) */
+body { margin:0; background:var(--bg-0); color:var(--text-1); font-family:var(--font-body); }
+.screen { display:none; flex-direction:column; align-items:center; justify-content:center; width:1280px; height:720px; overflow:hidden; padding:0 40px; }
+.s-wrap { width:100%; max-width:1120px; }
+.s-foot-tags { display:flex; gap:10px; flex-wrap:wrap; align-items:center; justify-content:flex-end; margin-top:14px; }
+
+/* 数据速览复用 base.css .stat-band/.stat;此处补工具分布条 */
+.dist-row { display:flex; align-items:center; gap:10px; margin:6px 0; }
+.dist-name { width:110px; font-family:var(--font-mono); font-size:12px; color:var(--text-3); text-align:right; }
+.dist-bar { height:14px; background:var(--brand-1); border-radius:2px; min-width:2px; }
+.dist-num { font-family:var(--font-mono); font-size:12px; color:var(--text-2); }
+
+/* 交付物墙 */
+.wall { display:grid; grid-template-columns:repeat(3,1fr); gap:14px; }
+.wall .w-card { background:var(--bg-2); border:1px solid var(--border-subtle); padding:12px; display:flex; flex-direction:column; gap:8px; min-height:120px; }
+.wall .w-card iframe { width:100%; height:150px; border:1px solid var(--border-subtle); background:#fff; pointer-events:none; }
+.wall .w-card .w-title { font:600 13px var(--font-body); color:var(--text-1); }
+.wall .w-card .w-src { font:11px var(--font-mono); color:var(--text-4); }
+.wall .w-card .w-note { font:11px var(--font-mono); color:var(--brand-ok); }
+.wall .w-card .w-nolink { font:11px var(--font-mono); color:var(--text-4); border:1px dashed var(--border-subtle); padding:40px 8px; text-align:center; }
+
+/* 时刻线 */
+.timeline { position:relative; padding-left:22px; display:flex; flex-direction:column; gap:12px; }
+.timeline::before { content:''; position:absolute; left:5px; top:6px; bottom:6px; width:2px; background:var(--border-subtle); }
+.tl-item { position:relative; }
+.tl-item::before { content:''; position:absolute; left:-22px; top:5px; width:10px; height:10px; border-radius:50%; background:var(--brand-1); border:2px solid var(--bg-0); }
+.tl-item .tl-time { font:11px var(--font-mono); color:var(--text-4); }
+.tl-item .tl-text { font-size:14px; color:var(--text-2); margin-top:2px; }
+
+/* 三问板块 */
+.q-block { border-left:3px solid var(--brand-1); background:var(--bg-2); padding:14px 18px; }
+.q-block.q1 { border-left-color:var(--brand-3); }
+.q-block.q2 { border-left-color:var(--brand-1); }
+.q-block.q3 { border-left-color:var(--brand-2); }
+.q-block .q-h { font:700 16px var(--font-display); color:var(--text-1); margin-bottom:8px; }
+.q-block .q-a { font-size:15px; line-height:1.8; color:var(--text-2); white-space:pre-wrap; }
+.q-block .q-ev { margin-top:10px; display:flex; flex-direction:column; gap:4px; }
+.q-block .q-ev .ev { font:11px var(--font-mono); color:var(--text-4); }
+.q-block .q-pending { font:12px var(--font-mono); color:var(--brand-3); }
+
+/* 附录表 */
+.tbl { width:100%; border-collapse:collapse; font-size:12px; }
+.tbl th { font-family:var(--font-mono); font-size:11px; color:var(--text-4); text-align:left; border-bottom:1px solid var(--border-subtle); padding:6px 8px; letter-spacing:.08em; }
+.tbl td { padding:5px 8px; border-bottom:1px solid var(--border-subtle); color:var(--text-2); }
+.tbl td.mono { font-family:var(--font-mono); font-size:11px; color:var(--text-4); }
+
+.pending-badge { display:inline-block; font:11px var(--font-mono); color:var(--brand-3); border:1px dashed rgba(255,0,0,.45); padding:2px 8px; margin-left:8px; }
+</style>
+</head>
+<body>
+
+<!-- ════ P1 · 封面 ════ -->
+<section class="screen" id="p1">
+  <div class="s-wrap" style="text-align:center;">
+    <div class="s-tag" style="letter-spacing:.3em;">AGENT LEARNING RETROSPECTIVE</div>
+    <h1 class="s-title" style="font-size:56px; margin:18px 0 8px;">{{NAME}} · 学习复盘报告</h1>
+    <p class="s-sub" style="margin:0 auto; max-width:70%;">{{ENTERPRISE}} · {{DATE_RANGE}}</p>
+    <div style="margin-top:46px; display:flex; gap:14px; justify-content:center; flex-wrap:wrap;">
+      <span class="tag">{{SESSIONS}} 场会话</span>
+      <span class="tag ok">{{TOOLKINDS}} 类工具协作</span>
+      <span class="tag brand-2">{{ARTIFACTS}} 项产出物</span>
+      <span class="tag">{{HOURS}}h 全程可溯源</span>
+    </div>
+  </div>
+</section>
+
+<!-- ════ P2 · 48h 数据全景 ════ -->
+<section class="screen" id="p2">
+  <div class="s-wrap">
+    <div class="s-tag">全景 · 真实采集数据</div>
+    <h2 class="s-title">48 小时数据全景</h2>
+    <div class="s-body">
+      <div class="stat-band">
+        <div class="stat">
+          <div class="num hero">{{SESSIONS}}</div>
+          <div class="label">Agent 会话(场)</div>
+          <div class="src">来源:各工具本地会话记录</div>
+        </div>
+        <div class="stat">
+          <div class="num">{{TOOLKINDS}}</div>
+          <div class="label">工具来源(类)</div>
+          <div class="src">{{TOOL_LIST}}</div>
+        </div>
+        <div class="stat">
+          <div class="num">{{ARTIFACTS}}</div>
+          <div class="label">产出物(项)</div>
+          <div class="src">.md/.html/.pdf/.docx/.xlsx</div>
+        </div>
+        <div class="stat">
+          <div class="num">{{MESSAGES}}</div>
+          <div class="label">对话消息(条)</div>
+          <div class="src">user + assistant 可读消息</div>
+        </div>
+      </div>
+      <div>
+        {{TOOL_DIST_ROWS}}
+      </div>
+    </div>
+    <div class="s-foot-tags">
+      <span class="tag ok">数据优先 · 全部来自本机采集清单</span>
+      <span class="tag">采集窗口 {{WINDOW}}</span>
+    </div>
+  </div>
+</section>
+
+<!-- ════ P3 · 交付物墙 ════ -->
+<section class="screen" id="p3">
+  <div class="s-wrap">
+    <div class="s-tag">产出 · 能看见的交付</div>
+    <h2 class="s-title">交付物墙</h2>
+    <div class="s-body">
+      <div class="wall">
+        {{ARTIFACT_WALL}}
+      </div>
+    </div>
+    <div class="s-foot-tags">
+      <span class="tag ok">HTML 交付物以 iframe 实时嵌套展示</span>
+      <span class="tag">在线项目已标记「可嵌入」</span>
+    </div>
+  </div>
+</section>
+
+<!-- ════ P4 · 精选工作时刻线 ════ -->
+<section class="screen" id="p4">
+  <div class="s-wrap">
+    <div class="s-tag">过程 · 时间即证据</div>
+    <h2 class="s-title">精选工作时刻线</h2>
+    <div class="s-body">
+      <div class="timeline">
+        {{TIMELINE}}
+      </div>
+    </div>
+    <div class="s-foot-tags">
+      <span class="tag">按会话/文件真实 mtime 排序 · 摘取关键节点</span>
+    </div>
+  </div>
+</section>
+
+<!-- ════ P5 · 过去痛点(第一问) ════ -->
+<section class="screen" id="p5">
+  <div class="s-wrap">
+    <div class="s-tag" style="color:var(--brand-3);">第一问 · 过去</div>
+    <h2 class="s-title">之前不够原生、不够性感的地方</h2>
+    <div class="s-body">
+      <div class="q-block q1">
+        <div class="q-h">痛点与疑惑</div>
+        <div class="q-a">{{ANSWER_PAST}}</div>
+        {{PAST_PENDING}}
+        <div class="q-ev">{{EVIDENCE_PAST}}</div>
+      </div>
+    </div>
+    <div class="s-foot-tags">
+      <span class="tag">用户口述 × 采集证据 关联</span>
+    </div>
+  </div>
+</section>
+
+<!-- ════ P6 · 两天感受(第二问) ════ -->
+<section class="screen" id="p6">
+  <div class="s-wrap">
+    <div class="s-tag">第二问 · 现在</div>
+    <h2 class="s-title">这两天的感受与收获</h2>
+    <div class="s-body">
+      <div class="q-block q2">
+        <div class="q-h">具体感受 · 有感觉的项目</div>
+        <div class="q-a">{{ANSWER_PRESENT}}</div>
+        {{PRESENT_PENDING}}
+        <div class="q-ev">{{EVIDENCE_PRESENT}}</div>
+      </div>
+    </div>
+    <div class="s-foot-tags">
+      <span class="tag ok">成果已从采集自动带入 · 本页只讲感受</span>
+    </div>
+  </div>
+</section>
+
+<!-- ════ P7 · 未来方向(第三问) ════ -->
+<section class="screen" id="p7">
+  <div class="s-wrap">
+    <div class="s-tag" style="color:var(--brand-2);">第三问 · 未来</div>
+    <h2 class="s-title">想回企业落地或探索的方向</h2>
+    <div class="s-body">
+      <div class="q-block q3">
+        <div class="q-h">未来畅想</div>
+        <div class="q-a">{{ANSWER_FUTURE}}</div>
+        {{FUTURE_PENDING}}
+        <div class="q-ev">{{EVIDENCE_FUTURE}}</div>
+      </div>
+    </div>
+    <div class="s-foot-tags">
+      <span class="tag brand-2">从个人工具箱 → 企业原生 AI 落地</span>
+    </div>
+  </div>
+</section>
+
+<!-- ════ P8 · 数据附录 ════ -->
+<section class="screen" id="p8">
+  <div class="s-wrap">
+    <div class="s-tag">附录 · 可溯源明细</div>
+    <h2 class="s-title">数据附录</h2>
+    <div class="s-body">
+      <table class="tbl">
+        <thead><tr><th style="width:110px;">时间</th><th style="width:90px;">来源</th><th>摘要</th><th style="width:180px;">路径 / URL</th></tr></thead>
+        <tbody>
+          {{APPENDIX_ROWS}}
+        </tbody>
+      </table>
+    </div>
+    <div class="s-foot-tags">
+      <span class="tag">共 {{TOTAL}} 条采集 · 本页展示前 {{APPENDIX_N}} 条</span>
+      <span class="tag">完整清单见 report-data.json</span>
+    </div>
+  </div>
+</section>
+
+<!-- ════ P9 · 收口金句 ════ -->
+<section class="screen" id="p9">
+  <div class="s-wrap" style="text-align:center;">
+    <div class="s-tag" style="letter-spacing:.3em;">TAKEAWAY</div>
+    <div class="quote-line" style="margin-top:28px;">工具会过时,<br>把 AI 用成自己手脚的方法不会。</div>
+    <p class="s-sub" style="margin:26px auto 0; max-width:64%;">{{NAME}} · {{DATE_RANGE}}<br>本报告由 skill-study-report 自动采集生成 · 全程可溯源</p>
+  </div>
+</section>
+
+<script src="../lib/player/deck.js"></script>
+<script type="application/json" class="deck-config">
+{
+  "case": "study-report",
+  "page": { "id": "report", "title": "{{NAME}} · 学习复盘报告" },
+  "chapters": [
+    { "id": "p1", "file": "report.html", "title": "封面" },
+    { "id": "p2", "file": "report.html", "title": "48h 数据全景" },
+    { "id": "p3", "file": "report.html", "title": "交付物墙" },
+    { "id": "p4", "file": "report.html", "title": "时刻线" },
+    { "id": "p5", "file": "report.html", "title": "过去痛点" },
+    { "id": "p6", "file": "report.html", "title": "两天感受" },
+    { "id": "p7", "file": "report.html", "title": "未来方向" },
+    { "id": "p8", "file": "report.html", "title": "数据附录" },
+    { "id": "p9", "file": "report.html", "title": "收口" }
+  ]
+}
+</script>
+<script>
+window.SCREEN_TIMELINES = window.SCREEN_TIMELINES || {};
+function baseCtx(el) {
+  return {
+    el,
+    q: function(s){ return el.querySelector(s); },
+    qAll: function(s){ return el.querySelectorAll(s); },
+  };
+}
+/* P1 封面 */
+SCREEN_TIMELINES['p1'] = function(ctx) {
+  var tl = gsap.timeline();
+  tl.from(ctx.q('.s-tag'), { autoAlpha:0, y:14, duration:.4, ease:'power2.out' })
+    .from(ctx.q('.s-title'), { autoAlpha:0, y:26, duration:.6, ease:'power2.out' }, '-=.15')
+    .from(ctx.q('.s-sub'), { autoAlpha:0, y:14, duration:.4 }, '-=.3')
+    .from(ctx.qAll('.tag'), { autoAlpha:0, y:10, stagger:.07, duration:.35, ease:'back.out(1.4)' }, '-=.1');
+  return tl;
+};
+/* P2 数据全景:数字动画 + 分布条 */
+SCREEN_TIMELINES['p2'] = function(ctx) {
+  var tl = gsap.timeline();
+  tl.from(ctx.q('.s-tag'), { autoAlpha:0, duration:.3 })
+    .from(ctx.q('.s-title'), { autoAlpha:0, y:22, duration:.5 }, '-=.1')
+    .from(ctx.qAll('.stat'), { autoAlpha:0, scale:.92, stagger:.09, duration:.4, ease:'back.out(1.2)' }, '-=.2');
+  ctx.qAll('.stat .num').forEach(function(n, i) {
+    var target = parseInt(n.textContent.replace(/[^\d]/g, ''), 10) || 0;
+    var obj = { v: 0 };
+    tl.to(obj, { v: target, duration:1.1, ease:'power2.out', onUpdate: function(){ n.textContent = Math.round(obj.v).toLocaleString(); } }, '-=.7');
+    void i;
+  });
+  tl.from(ctx.qAll('.dist-row'), { autoAlpha:0, x:-18, stagger:.06, duration:.35 }, '-=.4');
+  tl.from(ctx.qAll('.s-foot-tags .tag'), { autoAlpha:0, y:8, stagger:.08, duration:.3 }, '-=.2')
+    .to({}, { duration:1.4 });
+  return tl;
+};
+/* P3 交付物墙 */
+SCREEN_TIMELINES['p3'] = function(ctx) {
+  var tl = gsap.timeline();
+  tl.from(ctx.q('.s-tag'), { autoAlpha:0, duration:.3 })
+    .from(ctx.q('.s-title'), { autoAlpha:0, y:22, duration:.5 }, '-=.1')
+    .from(ctx.qAll('.w-card'), { autoAlpha:0, y:24, stagger:.08, duration:.45, ease:'power2.out' }, '-=.2')
+    .to({}, { duration:1.2 });
+  return tl;
+};
+/* P4 时刻线 */
+SCREEN_TIMELINES['p4'] = function(ctx) {
+  var tl = gsap.timeline();
+  tl.from(ctx.q('.s-tag'), { autoAlpha:0, duration:.3 })
+    .from(ctx.q('.s-title'), { autoAlpha:0, y:22, duration:.5 }, '-=.1')
+    .from(ctx.qAll('.tl-item'), { autoAlpha:0, x:-22, stagger:.12, duration:.4, ease:'power2.out' }, '-=.15')
+    .to({}, { duration:1.2 });
+  return tl;
+};
+/* 三问页共用 */
+function qTimeline(key, fade) {
+  return function(ctx) {
+    var tl = gsap.timeline();
+    tl.from(ctx.q('.s-tag'), { autoAlpha:0, duration:.3 })
+      .from(ctx.q('.s-title'), { autoAlpha:0, y:22, duration:.5 }, '-=.1')
+      .from(ctx.q('.q-block'), { autoAlpha:0, x:fade ? 24 : -24, duration:.55, ease:'power2.out' }, '-=.2')
+      .from(ctx.qAll('.q-ev .ev'), { autoAlpha:0, stagger:.08, duration:.3 }, '-=.25')
+      .to({}, { duration:1.4 });
+    return tl;
+  };
+}
+SCREEN_TIMELINES['p5'] = qTimeline('p5', false);
+SCREEN_TIMELINES['p6'] = qTimeline('p6', true);
+SCREEN_TIMELINES['p7'] = qTimeline('p7', false);
+/* P8 附录 */
+SCREEN_TIMELINES['p8'] = function(ctx) {
+  var tl = gsap.timeline();
+  tl.from(ctx.q('.s-tag'), { autoAlpha:0, duration:.3 })
+    .from(ctx.q('.s-title'), { autoAlpha:0, y:22, duration:.5 }, '-=.1')
+    .from(ctx.qAll('.tbl tbody tr'), { autoAlpha:0, stagger:.03, duration:.25 }, '-=.2')
+    .from(ctx.qAll('.s-foot-tags .tag'), { autoAlpha:0, y:8, stagger:.08, duration:.3 }, '-=.2')
+    .to({}, { duration:1.2 });
+  return tl;
+};
+/* P9 收口 */
+SCREEN_TIMELINES['p9'] = function(ctx) {
+  var tl = gsap.timeline();
+  tl.from(ctx.q('.s-tag'), { autoAlpha:0, duration:.4 })
+    .from(ctx.q('.quote-line'), { autoAlpha:0, scale:.96, duration:.8, ease:'power2.out' }, '-=.1')
+    .from(ctx.q('.s-sub'), { autoAlpha:0, y:14, duration:.5 }, '-=.3')
+    .to({}, { duration:1.6 });
+  return tl;
+};
+</script>
+<script>
+(function(){
+  var played = {};
+  function show(hash){
+    if(!hash) hash = 'p1';
+    document.querySelectorAll('.screen').forEach(function(s){
+      s.style.display = (s.id === hash) ? 'flex' : 'none';
+    });
+    if(!played[hash] && typeof gsap !== 'undefined'){
+      played[hash] = true;
+      var fn = window.SCREEN_TIMELINES[hash];
+      var el = document.getElementById(hash);
+      if(fn && el) fn(baseCtx(el));
+    }
+  }
+  /* 键盘翻页(本报告为单文件九屏,轻量自管;deck.js 兼容单 .screen 模式不冲突) */
+  var order = ['p1','p2','p3','p4','p5','p6','p7','p8','p9'];
+  function step(dir){
+    var cur = order.indexOf((location.hash || '#p1').slice(1));
+    var next = Math.min(order.length - 1, Math.max(0, cur + dir));
+    location.hash = '#' + order[next];
+  }
+  window.addEventListener('hashchange', function(){ show(location.hash.slice(1)); });
+  window.addEventListener('keydown', function(e){
+    if(['ArrowDown','ArrowRight','PageDown',' '].includes(e.key)) { e.preventDefault(); step(1); }
+    if(['ArrowUp','ArrowLeft','PageUp'].includes(e.key)) { e.preventDefault(); step(-1); }
+  });
+  if(document.readyState === 'loading'){
+    document.addEventListener('DOMContentLoaded', function(){ show(location.hash.slice(1) || 'p1'); });
+  } else { show(location.hash.slice(1) || 'p1'); }
+})();
+</script>
+</body>
+</html>

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.