Parcourir la source

launch huashu-design v1.0: 9 demos + marketing-first README

- 新增 9 个能力 demo(workflow + capability),每个 MP4 + GIF + 60fps
- README 重写:营销 hook 开头 · 功能优先结构 · 自然嵌入 GIF
- 修复 render-video.js Google Fonts networkidle 超时
- 修复 c1-ios-prototype 刘海/Home Indicator 背景色
- 补充 references/editable-pptx.md 和 scripts/export_deck_stage_pdf.mjs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
alchain il y a 2 mois
Parent
commit
b127f8baa3

+ 17 - 32
.gitignore

@@ -1,37 +1,22 @@
-# OS
+# macOS
 .DS_Store
-Thumbs.db
-
-# Editor
-.vscode/
-.idea/
-*.swp
-*~
-
-# Node
-node_modules/
-npm-debug.log*
-yarn-debug.log*
-yarn-error.log*
-
-# Python
-__pycache__/
-*.pyc
-.venv/
-venv/
+**/.DS_Store
 
-# Backups
-*.bak
-*.bak.*
+# Video render temp (render-video.js 产物)
+.video-tmp-*/
+**/.video-tmp-*/
 
-# Video render temp dirs
-.video-tmp-*
-*.tmp.mp4
-*.tmp.webm
-
-# Private overrides (never commit real personal data)
-personal-asset-index.json
+# Personal asset index(个人真实数据,只保留 .example.json 模板)
 assets/personal-asset-index.json
 
-# Logs
-*.log
+# Node / editor / OS
+node_modules/
+*.swp
+.idea/
+.vscode/
+Thumbs.db
+
+# Verification artifacts(截图、临时测试脚本)
+demos/_frames_*.png
+demos/_verify.js
+demos/_verify.mjs

+ 200 - 99
README.md

@@ -1,153 +1,254 @@
-# 花叔Design · Huashu-Design
+<div align="center">
 
-![花叔Design Brand Film](./demo/huashu-design-brand.gif)
+# Huashu Design
 
-> 20 秒品牌片由本 skill 自己完成:ASK → PROPOSE → PICK → CRITIQUE → EXPORT → BRAND,六幕演一次 Junior Designer 的完整工作流。
-> **[下载 MP4(60fps, 1.0MB)](./demo/huashu-design-brand.mp4)** · [25fps 版](./demo/huashu-design-brand-25fps.mp4)
+> *「你要做的下一个设计,何必打开图形界面」*
 
-把「AI 当设计师」落到实处的 Skill。
+[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
+[![Claude Code](https://img.shields.io/badge/Claude%20Code-Skill-blueviolet)](https://claude.ai/code)
+[![Skills](https://img.shields.io/badge/skills.sh-Compatible-green)](https://skills.sh)
 
-不是让 AI 生成一张图,而是让 AI 进入「资深 Junior Designer」的工作模式:先给假设 + 占位 + 理由,show 给 manager 看,得到方向反馈,再做 variation,再迭代细节。最后还能请一位「严苛评审」打分。
+<br>
 
-适用于任何支持 Skill 规范的 AI agent:Claude Code、Codex、Cursor、Trae、OpenClaw、Hermes Agent 等。
+**Claude Design 把设计师塞进了浏览器。Huashu Design 让图形工具这一层消失。**
+
+<br>
+
+PPT、App 原型、时间轴动画、信息图、设计变体——在 Claude Code 里说一句话,agent 自己搭 design spec、自己抓品牌色、自己生成 HTML、自己跑 Playwright 验证。<br>
+没有画布,没有图层,没有插件。只剩下一个能直接复制到 Keynote、贴进飞书、发进 issue 的交付物。
+
+```
+npx skills add alchaincyf/huashu-design
+```
+
+[看效果](#demo-画廊) · [安装](#装上就能用) · [能做什么](#能做什么) · [核心机制](#核心机制) · [和 Claude Design 的关系](#和-claude-design-的关系)
+
+<br>
+
+[![Star History Chart](https://api.star-history.com/svg?repos=alchaincyf/huashu-design&type=Date)](https://star-history.com/#alchaincyf/huashu-design&Date)
+
+</div>
 
 ---
 
-## 它能做什么
+<p align="center">
+  <img src="demos/w3-fallback-advisor.gif" alt="Fallback 设计顾问 · 从 20 种设计哲学推荐 3 个差异化方向" width="100%">
+</p>
 
-一条 skill 同时覆盖四件事:
+---
 
-1. **高保真 HTML 视觉产出**
-   交互原型、App / iOS mockup、幻灯片、动画 Demo、设计变体探索、信息图 —— 用 HTML 做,而不是用 Figma 截图或 placeholder 拼贴。
+## 装上就能用
 
-2. **设计方向顾问(Fallback)**
-   当你的需求只是「给我做个好看的页面」时,skill 会从 5 个流派、20 种设计哲学里推荐 3 个差异化方向(信息建筑派 / 运动诗学派 / 极简主义派 / 实验先锋派 / 东方哲学派),再并行生成 3 个视觉 Demo 让你挑。
+```bash
+npx skills add alchaincyf/huashu-design
+```
 
-3. **Junior Designer 工作流**
-   不直接闷头做大招。先写 assumptions + reasoning + placeholder,尽早 show;方向错了,晚改比早改贵 100 倍。
+然后在 Claude Code 里直接说话:
+
+```
+「做一份 AI 心理学的演讲 PPT,推荐 3 个风格方向让我选」
+「做个 AI 番茄钟 iOS 原型,4 个核心屏幕要真能点击」
+「把这段逻辑做成 60 秒动画,导出 MP4 和 GIF」
+「帮我对这个设计做一个 5 维度评审」
+```
 
-4. **专家级评审**
-   交付后可选一轮 5 维度评分(哲学一致性 / 视觉层级 / 细节执行 / 功能性 / 创新性),外加具体的 Quick Fixes 清单。
+没有按钮、没有面板、没有 Figma 插件。跨 agent 通用 —— Claude Code / Cursor / Trae / Hermes / OpenClaw 任一都能装。
 
 ---
 
-## 附带的工程工具链
+## 能做什么
 
-- **Starter Components**:iOS / Android / macOS / Browser 的设备边框,design_canvas(变体画布),animations.jsx(Stage + Sprite 时间轴引擎),deck_stage.js(幻灯片外壳)。
-- **视频导出**:HTML 动画一键导出 25fps MP4 → 60fps 插帧 MP4 → palette 优化 GIF,配 6 首场景化 BGM(科技 / 广告 / 教育 / 教程)自动 fade。
-- **幻灯片导出**:HTML → PDF(矢量)/ PPTX(图片铺底)。
-- **Playwright 验证**:截图 + 控制台错误检查,交付前跑一遍。
+| 能力 | 交付物 | 典型耗时 |
+|------|--------|----------|
+| 交互原型(App / Web) | 单文件 HTML · 真 iPhone bezel · 可点击 · Playwright 验证 | 10–15 min |
+| 演讲幻灯片 | HTML deck(浏览器演讲)+ 可编辑 PPTX(文本框保留) | 15–25 min |
+| 时间轴动画 | MP4(25fps / 60fps 插帧)+ GIF(palette 优化)+ BGM | 8–12 min |
+| 设计变体 | 3+ 并排对比 · Tweaks 实时调参 · 跨维度探索 | 10 min |
+| 信息图 / 可视化 | 印刷级排版 · 可导 PDF/PNG/SVG | 10 min |
+| 设计方向顾问 | 5 流派 × 20 种设计哲学 · 推荐 3 方向 · 并行生成 Demo | 5 min |
+| 5 维度专家评审 | 雷达图 + Keep/Fix/Quick Wins · 可操作修复清单 | 3 min |
+
+**跨 agent 通用**:Claude Code、Cursor、Trae、Hermes、OpenClaw 任一都能装。
 
 ---
 
-## 安装
+## Demo 画廊
 
-把整个目录放到你 agent 读取 skills 的位置即可。
+### 设计方向顾问
 
-**Claude Code**:
+模糊需求时的 fallback:从 5 流派 × 20 种设计哲学里挑 3 个差异化方向,并行生成 3 个 Demo 让你选。
 
-```bash
-git clone https://github.com/alchaincyf/huashu-design-skill.git ~/.claude/skills/huashu-design
-```
+<p align="center"><img src="demos/w3-fallback-advisor.gif" width="100%"></p>
+
+### iOS App 原型
+
+iPhone 15 Pro 精确机身(灵动岛 / 状态栏 / Home Indicator)· 状态驱动多屏切换 · 真图从 Wikimedia/Met/Unsplash 取 · Playwright 自动点击测试。
+
+<p align="center"><img src="demos/c1-ios-prototype.gif" width="100%"></p>
+
+### Motion Design 引擎
+
+Stage + Sprite 时间片段模型 · `useTime` / `useSprite` / `interpolate` / `Easing` 四 API 覆盖所有动画需求 · 一条命令导出 MP4 / GIF / 60fps 插帧 / 带 BGM 的成片。
+
+<p align="center"><img src="demos/c3-motion-design.gif" width="100%"></p>
+
+### HTML Slides → 可编辑 PPTX
+
+HTML deck 浏览器演讲 · `html2pptx.js` 读 DOM 的 computedStyle 逐元素翻译成 PowerPoint 对象 · 导出的是**真文本框**,不是图片铺底。
+
+<p align="center"><img src="demos/c2-slides-pptx.gif" width="100%"></p>
+
+### Tweaks · 实时变体切换
+
+配色 / 字型 / 信息密度等参数化 · 侧边面板切换 · 纯前端 + `localStorage` 持久化 · 刷新不丢。
 
-**其他 agent**(Codex / Cursor / Trae / OpenClaw / Hermes Agent 等):按各自的 skills 约定放置,SKILL.md 的 frontmatter + markdown 结构是通用的。
+<p align="center"><img src="demos/c4-tweaks.gif" width="100%"></p>
 
-安装完成后,用任意包含触发词的话开启:
+### 信息图 / 数据可视化
 
-> 「做个 iOS App 原型」「帮我做 pitch deck」「导出 MP4」「评审这个设计」「做个好看的页面(我没想法)」
+杂志级排版 · CSS Grid 精准分栏 · `text-wrap: pretty` 排印细节 · 真数据驱动 · 可导 PDF 矢量 / PNG 300dpi / SVG。
 
-触发词完整清单见 `SKILL.md` frontmatter 的 description 字段。
+<p align="center"><img src="demos/c5-infographic.gif" width="100%"></p>
+
+### 5 维度专家评审
+
+哲学一致性 · 视觉层级 · 细节执行 · 功能性 · 创新性 各 0–10 分 · 雷达图可视化 · 输出 Keep / Fix / Quick Wins 清单。
+
+<p align="center"><img src="demos/c6-expert-review.gif" width="100%"></p>
+
+### Junior Designer 工作流
+
+不闷头做大招:先写 assumptions + placeholders + reasoning,尽早 show 给你,再迭代。理解错了早改比晚改便宜 100 倍。
+
+<p align="center"><img src="demos/w2-junior-designer.gif" width="100%"></p>
+
+### 品牌资产协议 5 步硬流程
+
+涉及具体品牌时强制执行:问 → 搜 → 下载(三条兜底)→ grep 色值 → 写 `brand-spec.md`。
+
+<p align="center"><img src="demos/w1-brand-protocol.gif" width="100%"></p>
 
 ---
 
-## 使用前需要自己配置的东西
+## 核心机制
+
+### 品牌资产协议
+
+skill 里最硬的一段规则。涉及具体品牌(Stripe、Linear、Anthropic、自家公司等)时强制执行 5 步:
+
+| 步骤 | 动作 | 目的 |
+|------|------|------|
+| 1 · 问 | 用户有 brand guidelines 吗? | 尊重已有资源 |
+| 2 · 搜官方品牌页 | `<brand>.com/brand` · `brand.<brand>.com` · `<brand>.com/press` | 抓权威色值 |
+| 3 · 下载资产 | SVG 文件 → 官网 HTML 全文 → 产品截图取色 | 三条兜底,前一条失败立刻走下一条 |
+| 4 · grep 提取色值 | 从资产里抓所有 `#xxxxxx`,按频率排序,过滤黑白灰 | **绝不从记忆猜品牌色** |
+| 5 · 固化 spec | 写 `brand-spec.md` + CSS 变量,所有 HTML 引用 `var(--brand-*)` | 不固化就会忘 |
+
+A/B 测试(v1 vs v2,各跑 6 agent):**v2 的稳定性方差比 v1 低 5 倍**。稳定性的稳定性,这是 skill 真正的护城河。
+
+### 设计方向顾问(Fallback)
+
+当用户需求模糊到无法着手时触发:
 
-大部分能力开箱即用,下面几项在需要时再弄:
+- 不凭通用直觉硬做,进入 Fallback 模式
+- 从 5 流派 × 20 种设计哲学里推荐 3 个**必须来自不同流派**的差异化方向
+- 每个方向配代表作、气质关键词、代表设计师
+- 并行生成 3 个视觉 Demo 让用户选
+- 选定后进入主干 Junior Designer 流程
 
-| 能力 | 需要 |
-|------|------|
-| 视频导出 | `npm i -g playwright` + `brew install ffmpeg`(或等价方案) |
-| 幻灯片导出 PDF | `npm i -g playwright pdf-lib` |
-| 幻灯片导出 PPTX | `npm i -g playwright pptxgenjs` |
-| Playwright 截图验证 | `npm i -g playwright` |
-| 个人品牌锚定(可选) | 复制 `assets/personal-asset-index.example.json` 到你的 agent 私有 memory 目录并填真实信息 |
+### Junior Designer 工作流
+
+默认工作模式,贯穿所有任务:
+
+- 开工前 show 问题清单一次性发给用户,等批量答完再动手
+- HTML 里先写 assumptions + placeholders + reasoning comments
+- 尽早 show 给用户(哪怕只是灰色方块)
+- 填充实际内容 → variations → Tweaks 这三步分别再 show 一次
+- 交付前用 Playwright 肉眼过一遍浏览器
+
+### 反 AI slop 规则
+
+避免一眼 AI 的视觉最大公约数(紫渐变 / emoji 图标 / 圆角+左 border accent / SVG 画人脸 / Inter 做 display)。用 `text-wrap: pretty` + CSS Grid + 精心选择的 serif display 和 oklch 色彩。
 
 ---
 
-## 目录结构
+## 和 Claude Design 的关系
 
-```
-huashu-design-skill/
-├── SKILL.md                       # 主干文件:哲学 + 工作流 + 路由表
-├── references/                    # 深度参考:按需加载
-│   ├── workflow.md                # 开工问什么问题
-│   ├── content-guidelines.md      # 反 AI slop 完整清单
-│   ├── react-setup.md             # React + Babel 最佳实践
-│   ├── slide-decks.md             # 幻灯片架构
-│   ├── animations.md              # 动画引擎用法
-│   ├── animation-pitfalls.md      # 动画踩坑 + T0 黑屏修复
-│   ├── tweaks-system.md           # 实时调参 UI
-│   ├── design-context.md          # 没有 design system 怎么办
-│   ├── design-styles.md           # 20 种设计哲学
-│   ├── scene-templates.md         # 按输出类型的场景模板
-│   ├── critique-guide.md          # 评审打分标准
-│   ├── verification.md            # 产出验证流程
-│   └── video-export.md            # 视频导出命令链
-├── scripts/                       # 可执行脚本
-│   ├── render-video.js            # HTML → 25fps MP4
-│   ├── convert-formats.sh         # 25fps MP4 → 60fps MP4 + GIF
-│   ├── add-music.sh               # BGM 叠加 + 自动 fade
-│   ├── export_deck_pdf.mjs        # HTML → PDF
-│   ├── export_deck_pptx.mjs       # HTML → PPTX
-│   └── verify.py                  # Playwright 截图 + 控制台检查
-└── assets/                        # 起手组件 + BGM + 示例
-    ├── deck_stage.js              # 幻灯片外壳(单文件架构)
-    ├── deck_index.html            # 幻灯片拼接器(多文件架构)
-    ├── animations.jsx             # Stage + Sprite 时间轴引擎
-    ├── design_canvas.jsx          # 变体画布
-    ├── ios_frame.jsx              # iPhone 设备边框
-    ├── android_frame.jsx          # Android 设备边框
-    ├── macos_window.jsx           # macOS 窗口 chrome
-    ├── browser_window.jsx         # 浏览器 chrome
-    ├── bgm-{tech,ad,educational,tutorial,*-alt}.mp3
-    ├── personal-asset-index.example.json
-    └── showcases/                 # 24 个预制 showcase(8 场景 × 3 风格)
-```
+我大方承认:品牌资产协议的哲学是从 Claude Design 流传出来的提示词里偷师的。那份提示词反复强调**好的高保真设计不是从白纸开始,而是从已有的设计上下文长出来**。这个原则是 65 分作品和 90 分作品的分水岭。
+
+定位差异:
+
+| | Claude Design | huashu-design |
+|---|---|---|
+| 形态 | 网页产品(浏览器里用) | skill(Claude Code 里用) |
+| 配额 | 订阅 quota | API 消耗 · 并行跑 agent 不受 quota 限 |
+| 交付物 | 画布内 + 可导 Figma | HTML / MP4 / GIF / 可编辑 PPTX / PDF |
+| 操作方式 | GUI(点、拖、改) | 对话(说话、等 agent 做完) |
+| 复杂动画 | 有限 | Stage + Sprite 时间轴 · 60fps 导出 |
+| 跨 agent | 专属 Claude.ai | Claude Code / Cursor / Trae / Hermes / OpenClaw 任一 |
+
+Claude Design 是**更好的图形工具**,huashu-design 是**让图形工具这层消失**。两条路,不同受众。
 
 ---
 
-## 设计原则(写进 DNA 的五条)
+## Limitations
 
-1. **从 existing context 出发**,不凭空画 hi-fi。没有 design system / brand / Figma 就先找,真没有就明确告知「我会基于通用直觉做」。
-2. **Junior Designer 模式**:先 show 假设,再执行。不要一头扎进去闷头做大招。
-3. **给 variations,不给最终答案**。3+ 个变体,跨视觉 / 交互 / 色彩 / 布局 / 动画维度递进,让用户 mix and match。
-4. **Placeholder > 烂实现**。没图标就留灰色方块 + 文字标签,不画烂 SVG;没数据不编造假数据。
-5. **反 AI slop**:激进渐变、满屏 emoji、圆角卡片 + 左 accent border、SVG 画人画物、Inter / Roboto 填所有文字 —— 每一条都是默认避开项(用户可按品牌 override)。
+- **不支持图层级可编辑的 PPTX 到 Figma**。产出 HTML,可截图、录屏、导图,但不能拖进 Keynote 改文字位置。
+- **Framer Motion 级别的复杂动画不行**。3D、物理模拟、粒子系统超出 skill 边界。
+- **完全空白的品牌从零设计质量会掉到 60–65 分**。凭空画 hi-fi 本来就是 last resort。
 
-完整哲学和工作流见 `SKILL.md`
+这是一个 80 分的 skill,不是 100 分的产品。对不愿意打开图形界面的人,80 分的 skill 比 100 分的产品好用。
 
 ---
 
-## BGM 版权说明
+## 仓库结构
 
-`assets/bgm-*.mp3` 为原创 / 授权音乐,仅限配合本 skill 生成的演示 / 宣传视频使用。商业投放前请确认你的素材来源许可;若替换成自己的 BGM,只需保留相同文件名或改脚本参数。
+```
+huashu-design/
+├── SKILL.md                 # 主文档(给 agent 读)
+├── README.md                # 本文件(给用户读)
+├── assets/                  # Starter Components
+│   ├── animations.jsx       # Stage + Sprite + Easing + interpolate
+│   ├── ios_frame.jsx        # iPhone 15 Pro bezel
+│   ├── android_frame.jsx
+│   ├── macos_window.jsx
+│   ├── browser_window.jsx
+│   ├── deck_stage.js        # HTML 幻灯片引擎
+│   ├── deck_index.html      # 多文件 deck 拼接器
+│   ├── design_canvas.jsx    # 并排变体展示
+│   ├── showcases/           # 24 个预制样例(8 场景 × 3 风格)
+│   └── bgm-*.mp3            # 6 首场景化背景音乐
+├── references/              # 按任务深入读的子文档
+│   ├── animation-pitfalls.md
+│   ├── design-styles.md     # 20 种设计哲学详细库
+│   ├── slide-decks.md
+│   ├── editable-pptx.md
+│   ├── critique-guide.md
+│   ├── video-export.md
+│   └── ...
+├── scripts/                 # 导出工具链
+│   ├── render-video.js      # HTML → MP4
+│   ├── convert-formats.sh   # MP4 → 60fps + GIF
+│   ├── add-music.sh         # MP4 + BGM
+│   ├── export_deck_pdf.mjs
+│   ├── export_deck_pptx.mjs
+│   ├── html2pptx.js
+│   └── verify.py
+└── demos/                   # 9 个能力演示(本 README 引用的 GIF 都来自这里)
+```
 
 ---
 
-## License
+## 起源
+
+Anthropic 发布 Claude Design 那天我玩到凌晨四点。几天之后发现自己再也没点开过它,不是它不好——它是这个赛道目前最成熟的产品——是我宁愿让 agent 在终端里帮我干活,也不愿意打开任何图形界面。
 
-MIT。自由 fork、魔改、开分支。PR 欢迎,尤其欢迎:
+于是让 agent 拆解 Claude Design 本身(包括社区流传的系统提示词、品牌资产协议、组件机制),蒸馏成结构化 spec,再写成 skill 装进自己的 Claude Code。
 
-- 新的设计哲学流派(在 `references/design-styles.md` 里)
-- 新的 scene template(在 `references/scene-templates.md` 里)
-- 新的 showcase(在 `assets/showcases/` 里)
-- 其他 agent 环境的适配笔记(尤其欢迎 Codex / Cursor / Trae 的使用报告)
+感谢 Anthropic 把 Claude Design 的提示词写得清晰。这种基于其他产品灵感的二次创作,是开源文化在 AI 时代的新形态。
 
 ---
 
-## 致谢
+## License
 
-- Skill 规范:Anthropic Claude Code
-- 设计哲学蒸馏:Pentagram、Field.io、Kenya Hara、Stefan Sagmeister、Dieter Rams、Massimo Vignelli 等 20 位设计师 / 机构
-- 工程实践:Remotion / After Effects(Stage + Sprite 思路)、Playwright(验证 + 录制)、ffmpeg(视频后处理)
-- 灵感来源:AI Artifacts 原生设计能力 → 搬到 Skill 架构 → 跨 agent 可用
+MIT. Skill 和 demo 都开源,随便用。issue 里欢迎延伸讨论。

+ 164 - 17
SKILL.md

@@ -29,6 +29,87 @@ description: 花叔Design(Huashu-Design)——用HTML做高保真原型、
 
 **如果还是没有,或者用户需求表达很模糊**(如"做个好看的页面"、"帮我设计"、"不知道要什么风格"、"做个XX"没有具体参考),**不要凭通用直觉硬做**——进入 **设计方向顾问模式**,从 20 种设计哲学里给 3 个差异化方向让用户选。完整流程见下方「设计方向顾问(Fallback 模式)」大节。
 
+#### 1.a 品牌资产协议(涉及具体品牌时强制执行)
+
+> **这是 v1 最核心的约束,也是稳定性的生命线。** Agent 是否走通这个协议,直接决定输出质量是 65 分还是 90 分。不要跳过任何一步。
+
+**触发条件**:任务涉及具体品牌——用户提了产品名/公司名/明确客户(Stripe、Linear、Anthropic、Notion、Lovart、自家公司等),不论用户是否主动提供了品牌资料。
+
+**5 步硬流程**(每一步都有 fallback,但绝不可以静默跳过):
+
+##### Step 1 · 问
+一句话问清楚:「这个品牌有 brand guidelines / 设计规范 / logo 文件吗?品牌色和字体清单?有的话直接给我;没有的话我去搜。」
+
+##### Step 2 · 搜官方品牌页
+- 典型路径:`<brand>.com/brand` / `<brand>.com/press` / `brand.<brand>.com` / `<brand>.github.io/brand`
+- `WebFetch` 搜不到时用 `WebSearch`:「`<brand>` brand guidelines logo download」
+- App 类产品另查 App Store 页面(app icon + 截图可提色)
+
+##### Step 3 · 下载资产 · 三条兜底路径
+
+> **真实世界里 SVG logo 经常拿不到**。2026 年官网大多 Next.js SSR + inline SVG,或 Cloudflare 反爬返回 403。下面三条路径按成功率递减排列——**前一条失败立刻走下一条,不要停**:
+
+1. **独立 SVG 文件**(最理想)
+   ```bash
+   curl -o assets/<brand>-brand/logo.svg https://<brand>.com/logo.svg
+   ```
+2. **官网 HTML 全文**(80% 场景必用):WebFetch 返回 403 时用 curl 带 UA 兜底,logo 的 inline SVG 和色值 token 直接藏在 HTML 里:
+   ```bash
+   curl -A "Mozilla/5.0" -L https://<brand>.com -o assets/<brand>-brand/homepage.html
+   ```
+3. **产品截图取色**(App/未开源产品必走):从用户提供的产品截图或官方 Dribbble / Twitter media 里提取主色。macOS 用 Preview 的吸管、Linux 用 `convert image.png -unique-colors txt:-` 给出主要色值。
+
+##### Step 4 · Grep 提取真实色值
+- **绝对不要从记忆里猜品牌色**。从下载的资产里 grep 所有 `fill`/`stroke`/`color`/`background`/`#xxxxxx` 的 6 位 hex,按频率排序,过滤黑白灰:
+  ```bash
+  grep -hoE '#[0-9A-Fa-f]{6}' assets/<brand>-brand/*.{svg,html,css} | sort | uniq -c | sort -rn | head -20
+  ```
+- **警惕示范品牌污染**:产品截图里常有用户 demo 的品牌色(如某工具截图里演示喜茶红),那不是该工具的色。**同时出现两种强色时必须区分**。
+- **品牌多切面的客观事实**:同一品牌的官网营销色(`<brand>.com` 暖底 accent 色)和产品 UI 色(产品内深底 accent 色)经常不同,比如 Lovart 官网是暖米+橙+蓝,产品 UI 是 Charcoal + Lime 青柠。**两套都是真的**——根据交付场景选合适的切面(做营销物料用官网色,做产品内/App 延伸用 UI 色)。
+
+##### Step 5 · 固化为 `brand-spec.md` 文件(不可省略)
+
+> **这一步是全协议的制胜点**。前 4 步抽完色,如果只留在内存里下一页就忘。必须写一份 `brand-spec.md` 到项目根目录,让所有 CSS 变量引用这份文件。
+
+```markdown
+# <Brand> · Brand Spec
+> 从以下资产抽取:<列出资产来源和提取日期>
+> 资产完整度:<完整 / 部分 / 推断>
+
+## 色板
+- Primary: #XXXXXX  <来源标注>
+- Background: #XXXXXX
+- Ink: #XXXXXX
+- 禁用色: <品牌明确不用的色系>
+
+## 字型
+- Display: <font stack>
+- Body: <font stack>
+
+## 签名细节
+- <哪些细节是「120% 做到」的,如 2px 细线、italic em 强调>
+
+## 禁区
+- <明确不能做的:比如 Lovart 不用蓝色、Stripe 不用低饱和暖色>
+
+## 气质关键词
+- <3-5 个形容词>
+```
+
+**写完 spec 后的执行纪律**(硬要求):
+- 写 CSS 前把 spec 色值做成 `:root { --brand-primary: ...; }` CSS 变量
+- 所有 HTML 文件只能用 `var(--brand-*)`,**不允许临场写死色号**
+- 这一条让品牌一致性从「靠自觉」变成「靠结构」——agent 想临时加色要先改 spec,改 spec 就会自觉
+
+**全流程失败的兜底**:三条路径都失败(极罕见)才明确告诉用户「找不到 official brand guidelines,我按通用风格做并在 spec 里标注 assumption」——**不要静默用通用色硬做**。Fallback 时进入「设计方向顾问模式」(见下节)。
+
+**反例**(真实踩过的坑):
+- 为 Kimi 做动画时凭记忆猜「应该是橙色」,实际 Kimi 是 `#1783FF` 蓝色——返工一遍
+- 把 Lovart 产品截图里演示品牌的喜茶红当成 Lovart 自己的色——差点毁整个设计
+- 抽完色没写进 brand-spec.md,第三页就忘了主色数值,临场加了个「接近但不是」的 hex——品牌一致性崩溃
+
+**协议代价 vs 不做代价**:5-10 分钟 grep + 5 分钟写 spec = 15 分钟。不做的代价是整个产出方向错,返工 1-2 小时。**这是稳定性最便宜的投资**。
+
 ### 2. Junior Designer模式:先展示假设,再执行
 
 你是manager的junior designer。**不要一头扎进去闷头做大招**。HTML文件的开头先写下你的assumptions + reasoning + placeholders,**尽早show给用户**。然后:
@@ -59,18 +140,47 @@ description: 花叔Design(Huashu-Design)——用HTML做高保真原型、
 
 ### 6. 反AI slop(重要,必读)
 
-AI设计最容易掉进去的陷阱。完整清单见 `references/content-guidelines.md`,速查:
+#### 6.1 什么是 AI slop?为什么要反?
+
+**AI slop = AI 训练语料里最常见的"视觉最大公约数"**。
+紫渐变、emoji 图标、圆角卡片+左 border accent、SVG 画人脸——这些东西之所以是 slop,不是因为它们本身丑,而是因为**它们是 AI 默认模式下的产物,不携带任何品牌信息**。
+
+**规避 slop 的逻辑链**:
+1. 用户请你做设计,是要**他的品牌被认出来**
+2. AI 默认产出 = 训练语料的平均 = 所有品牌混合 = **没有任何品牌被认出来**
+3. 所以 AI 默认产出 = 帮用户把品牌稀释成"又一个 AI 做的页面"
+4. 反 slop 不是审美洁癖,是**替用户保护品牌识别度**
+
+这也是为什么 §1.a 品牌资产协议是 v1 最硬的约束——**服从规范是反 slop 的正向方式**(对的事),清单只是反 slop 的反向方式(不做错的事)。
+
+#### 6.2 核心要规避的(带"为什么")
+
+| 元素 | 为什么是 slop | 什么情况可以用 |
+|------|-------------|---------------|
+| 激进紫色渐变 | AI 训练语料里"科技感"的万能公式,出现在 SaaS/AI/web3 每一个落地页 | 品牌本身用紫渐变(如 Linear 某些场景)、或任务就是讽刺/展示这类 slop |
+| Emoji 作图标 | 训练语料里每个 bullet 都配 emoji,是"不够专业就用 emoji 凑"的病 | 品牌本身用(如 Notion),或产品受众是儿童/轻松场景 |
+| 圆角卡片 + 左彩色 border accent | 2020-2024 Material/Tailwind 时期的烂大街组合,已成视觉噪音 | 用户明确要求、或这个组合在品牌 spec 里被保留 |
+| SVG 画 imagery(人脸/场景/物品)| AI 画的 SVG 人物永远五官错位,比例诡异 | **几乎没有**——有图就用真图(Wikimedia/Unsplash/AI 生成),没图就留诚实 placeholder |
+| Inter/Roboto/Arial/system fonts 作 display | 太常见,读者看不出这是"有设计的产品"还是"demo 页" | 品牌 spec 明确用这些字体(Stripe 用 Sohne/Inter 变体,但是经过微调的) |
+| 赛博霓虹 / 深蓝底 `#0D1117` | GitHub dark mode 美学的烂大街复制 | 开发者工具产品且品牌本身走这方向 |
+
+**判断边界**:「品牌本身用」是唯一能合法破例的理由。品牌 spec 里明写了用紫渐变,那就用——此时它不再是 slop,是品牌签名。
 
-- ❌ 激进渐变背景(尤其紫色渐变在白底上)
-- ❌ Emoji 作为图标(除非品牌本身用)
-- ❌ 圆角卡片+左边彩色border accent
-- ❌ SVG画imagery(画人/物/场景)——留placeholder让用户提供真材料
-- ❌ Inter/Roboto/Arial/Fraunces/system fonts
-- ❌ 赛博霓虹 / 深蓝色底(#0D1117)= 审美禁区
-- ✅ `text-wrap: pretty` + CSS Grid + 高级CSS是好朋友
-- ✅ oklch定义色彩(而不是从头发明新颜色)
-- ✅ 配图优先 AI 生成(Gemini / Flash / Lovart),HTML截图仅在精确数据表格时用
-- ✅ 文案中使用「」引号而非""引号
+#### 6.3 正向做什么(带"为什么")
+
+- ✅ `text-wrap: pretty` + CSS Grid + 高级 CSS:排版细节是 AI 分不清的"品味税",会用这些的 agent 看起来像真设计师
+- ✅ 用 `oklch()` 或 spec 里已有的色,**不凭空发明新颜色**:所有临场发明的色都会让品牌识别度下降
+- ✅ 配图优先 AI 生成(Gemini / Flash / Lovart),HTML 截图仅在精确数据表格时用:AI 生成的图比 SVG 手画准确,比 HTML 截图有质感
+- ✅ 文案用「」引号不用 "":中文排印规范,也是"有审校过"的细节信号
+- ✅ 一个细节做到 120%,其他做到 80%:品味 = 在合适的地方足够精致,不是均匀用力
+
+#### 6.4 反例隔离(演示型内容)
+
+当任务本身就要展示反设计(如本任务就是讲"什么是 AI slop"、或对比评测),**不要整页堆 slop**,而是用**诚实的 bad-sample 容器**隔离——加虚线边框 + "反例 · 不要这样做" 角标,让反例服务于叙事而不是污染页面主调。
+
+这不是硬规则(不做成模板),是原则:**反例要看得出是反例,不是让页面真的变成 slop**。
+
+完整清单见 `references/content-guidelines.md`。
 
 ## 设计方向顾问(Fallback 模式)
 
@@ -308,10 +418,20 @@ Screen 组件接 callback props(`onEnter`、`onClose`、`onTabChange`、`onOpe
 
 1. **理解需求**:新任务或模糊任务必须问clarifying questions,详见 `references/workflow.md`。一次focused一轮问题通常够,小修小补跳过。
    🛑 **检查点1:问题清单一次性发给用户,等用户批量答完再往下走**。不要边问边做。
+   🛑 **幻灯片/PPT 任务额外必问「最终交付格式」**(浏览器演讲 / PDF / 可编辑 PPTX)——**要可编辑 PPTX 就必须从第一行 HTML 开始按 `references/editable-pptx.md` 的 4 条硬约束写**,事后补救会导致 2-3 小时返工。详见 `references/slide-decks.md` 开头「开工前先确认交付格式」一节。
    ⚡ **如果用户需求严重模糊(没参考、没明确风格、"做个好看的"类)→ 走「设计方向顾问(Fallback 模式)」大节,完成 Phase 1-4 选定方向后,再回到这里 Step 2**。
-2. **探索资源**:读design system的完整定义、linked files、上传的截图/代码。如果用户没给context,先走设计方向顾问 Fallback,再按 `references/design-context.md` 的品位锚点兜底。
-3. **规划系统**:vocalize你要用的设计系统(色彩/字型/layout节奏/component pattern)。
-   🛑 **检查点2:口头说出来等用户点头,再动手写代码**。方向错了晚改比早改贵100倍。
+2. **探索资源 + 抽 spec**:读 design system、linked files、上传的截图/代码。**涉及具体品牌时必走 §1.a 五步协议**(问→搜→下载→grep→写 `brand-spec.md`)。如果用户没给 context 且挖不出资产,先走设计方向顾问 Fallback,再按 `references/design-context.md` 的品位锚点兜底。
+3. **先答四问,再规划系统**:**这一步的前半段比所有 CSS 规则更决定输出**。
+
+   📐 **位置四问**(每个页面/屏幕/镜头开工前必答):
+   - **叙事角色**:hero / 过渡 / 数据 / 引语 / 结尾?(一页 deck 里每页都不一样)
+   - **观众距离**:10cm 手机 / 1m 笔记本 / 10m 投屏?(决定字号和信息密度)
+   - **视觉温度**:安静 / 兴奋 / 冷静 / 权威 / 温柔 / 悲伤?(决定配色和节奏)
+   - **容量估算**:用纸笔画 3 个 5 秒 thumbnail 算一下内容塞得下吗?(防溢出 / 防挤压)
+
+   四问答完再 vocalize 设计系统(色彩/字型/layout 节奏/component pattern)——**系统要服务于答案,不是先选系统再塞内容**。
+
+   🛑 **检查点2:四问答案 + 系统口头说出来等用户点头,再动手写代码**。方向错了晚改比早改贵 100 倍。
 4. **构建文件夹结构**:`项目名/` 下放主HTML、需要的assets拷贝(不要bulk copy >20个文件)。
 5. **Junior pass**:HTML里写assumptions+placeholders+reasoning comments。
    🛑 **检查点3:尽早show给用户(哪怕只是灰色方块+标签),等反馈再写组件**。
@@ -384,9 +504,11 @@ Screen 组件接 callback props(`onEnter`、`onClose`、`onTabChange`、`onOpe
 | 文件 | 何时用 | 提供 |
 |------|--------|------|
 | `deck_index.html` | **做幻灯片(默认,多文件架构)** | iframe拼接 + 键盘导航 + scale + 计数器 + 打印合并,每页独立HTML免CSS串扰 |
-| `deck_stage.js` | 做幻灯片(单文件架构,≤10页) | web component:auto-scale + 键盘导航 + slide counter + localStorage + speaker notes |
-| `scripts/export_deck_pdf.mjs` | **HTML→PDF 导出**(矢量、高保真、文字可搜) | Playwright `page.pdf()` + pdf-lib 合并。依赖 `playwright pdf-lib` |
-| `scripts/export_deck_pptx.mjs` | **HTML→PPTX 导出**(图片铺底,不可编辑) | Playwright 截图 + pptxgenjs addImage。视觉 100% 保真。**要可编辑 PPTX → 切到支持该格式的专用 skill/工具按其严格格式重构 HTML**。依赖 `playwright pptxgenjs` |
+| `deck_stage.js` | 做幻灯片(单文件架构,≤10页) | web component:auto-scale + 键盘导航 + slide counter + localStorage + speaker notes ⚠️ **script 必须放在 `</deck-stage>` 之后,section 的 `display: flex` 必须写到 `.active` 上**,详见 `references/slide-decks.md` 的两个硬约束 |
+| `scripts/export_deck_pdf.mjs` | **HTML→PDF 导出(多文件架构)** · 每页独立 HTML 文件,playwright 逐个 `page.pdf()` → pdf-lib 合并。文字保留矢量可搜。依赖 `playwright pdf-lib` |
+| `scripts/export_deck_stage_pdf.mjs` | **HTML→PDF 导出(单文件 deck-stage 架构专用)** · 2026-04-20 新增。处理 shadow DOM slot 导致的「只出 1 页」、absolute 子元素溢出等坑。详见 `references/slide-decks.md` 末节。依赖 `playwright` |
+| `scripts/export_deck_pptx.mjs` | **HTML→PPTX 导出(双模式)** · `--mode image` 图片铺底视觉 100% 保真但文字不可编辑;`--mode editable` 调 `html2pptx.js` 导出原生可编辑文本框,但 HTML 必须符合 4 条硬约束(见 `references/editable-pptx.md`)。依赖 `playwright pptxgenjs`(editable 模式还需 `sharp`) |
+| `scripts/html2pptx.js` | **HTML→PPTX 元素级翻译器** · 读 computedStyle 把 DOM 逐元素翻译成 PowerPoint 对象(text frame / shape / picture)。`export_deck_pptx.mjs --mode editable` 内部调用。要求 HTML 严格满足 4 条硬约束 |
 | `design_canvas.jsx` | 并排展示≥2个静态variations | 带label的网格布局 |
 | `animations.jsx` | 任何动画HTML | Stage + Sprite + useTime + Easing + interpolate |
 | `ios_frame.jsx` | iOS App mockup | iPhone bezel + 状态栏 + 圆角 |
@@ -406,6 +528,7 @@ Screen 组件接 callback props(`onEnter`、`onClose`、`onTabChange`、`onOpe
 | 反AI slop、内容规范、scale | `references/content-guidelines.md` |
 | React+Babel项目setup | `references/react-setup.md` |
 | 做幻灯片 | `references/slide-decks.md` + `assets/deck_stage.js` |
+| 导出可编辑 PPTX(html2pptx 4 条硬约束) | `references/editable-pptx.md` + `scripts/html2pptx.js` |
 | 做动画/motion(**先读 pitfalls**)| `references/animation-pitfalls.md` + `references/animations.md` + `assets/animations.jsx` |
 | 做Tweaks实时调参 | `references/tweaks-system.md` |
 | 没有design context怎么办 | `references/design-context.md`(薄 fallback) 或 `references/design-styles.md`(厚 fallback:20 种设计哲学详细库) |
@@ -436,6 +559,27 @@ Skill 路径引用均采用**相对本 skill 根目录**的形式(`references/
 - HTML放项目目录,不要散落到`~/Downloads`
 - 最终产出用浏览器打开检查或用Playwright截图
 
+## Skill 推广水印(仅动画产出)
+
+**只在动画产出**(HTML 动画 → MP4 / GIF)默认带「**Created by Huashu-Design**」水印,辅助 skill 传播。**幻灯片 / 信息图 / 原型 / 网页等其他场景不加**——加了反而干扰用户实际使用。
+
+- **必带场景**:HTML 动画 → 导出 MP4 / GIF(用户会拿去公众号、X、B站传播,水印能跟着流通)
+- **不带场景**:幻灯片(用户自己讲)、信息图(嵌文章)、App / 网页原型(设计 review)、配图
+- **第三方品牌的非官方致敬动画**:水印前加「非官方出品 · 」前缀,避免被误认为官方物料引发 IP 争议
+- **用户明确说"不要水印"**:尊重,移除
+- **水印模板**:
+  ```jsx
+  <div style={{
+    position: 'absolute', bottom: 24, right: 32,
+    fontSize: 11, color: 'rgba(0,0,0,0.4)' /* 深底用 rgba(255,255,255,0.35) */,
+    letterSpacing: '0.15em', fontFamily: 'monospace',
+    pointerEvents: 'none', zIndex: 100,
+  }}>
+    Created by Huashu-Design
+    {/* 第三方品牌动画前缀「非官方出品 · 」*/}
+  </div>
+  ```
+
 ## 核心提醒
 
 - **Embody专家**:做幻灯片时是幻灯片设计师,做动画时是动画师。不是写Web UI。
@@ -443,3 +587,6 @@ Skill 路径引用均采用**相对本 skill 根目录**的形式(`references/
 - **Variations不给答案**:3+个变体,让用户选。
 - **Placeholder优于烂实现**:诚实留白,不编造。
 - **反AI slop时时警醒**:每个渐变/emoji/圆角border accent之前先问——这真的必要吗?
+- **涉及具体品牌**:先走「品牌资产协议」(核心哲学 §1.a),不要凭记忆猜配色。
+- **做动画之前**:必读 `references/animation-pitfalls.md`——里面 14 条规则每条都来自真实踩过的坑,跳过会让你重做 1-3 轮。
+- **手写 Stage / Sprite**(不用 `assets/animations.jsx`):必须实现两件事——(a) tick 第一帧同步设 `window.__ready = true` (b) 检测 `window.__recording === true` 时强制 loop=false。否则录视频必出问题。

+ 11 - 2
assets/animations.jsx

@@ -162,6 +162,12 @@
     const startTimeRef = useRef(performance.now());
     const canvasRef = useRef(null);
 
+    // Recording mode: render-video.js injects window.__recording = true before goto.
+    // When set, force loop=false so the export ends on the final frame instead of
+    // wrapping back to t=0 and capturing the start of the next cycle.
+    // (Browsers viewing manually still loop because __recording is undefined there.)
+    const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
+
     useEffect(() => {
       function updateScale() {
         const vw = window.innerWidth;
@@ -195,7 +201,10 @@
         setTime(prev => {
           const next = prev + delta;
           if (next >= duration) {
-            return loop ? 0 : duration;
+            // effectiveLoop honors window.__recording (forced non-loop during export).
+            // Stop just shy of duration so the final-frame state stays rendered
+            // (avoids exiting all Sprites that end exactly at `duration`).
+            return effectiveLoop ? 0 : duration - 0.001;
           }
           return next;
         });
@@ -218,7 +227,7 @@
         cancelled = true;
         cancelAnimationFrame(rafRef.current);
       };
-    }, [playing, duration, loop]);
+    }, [playing, duration, effectiveLoop]);
 
     const handleScrub = useCallback((e) => {
       const rect = e.currentTarget.getBoundingClientRect();

+ 20 - 5
assets/deck_stage.js

@@ -43,12 +43,27 @@
       this._width = parseInt(this.getAttribute('width')) || 1920;
       this._height = parseInt(this.getAttribute('height')) || 1080;
 
+      // Shadow DOM 先渲染(独立于子节点,不受 parser 时机影响)
       this._render();
-      this._collectSlides();
-      this._setupEventListeners();
-      this._restoreSlide();
-      this._updateDisplay();
-      this._setupPrintStyles();
+
+      // 防御:若 script 放在 <head> 里(而非 </deck-stage> 之后),
+      // parser 此刻可能还没处理完子 <section>,querySelectorAll 会返回空。
+      // 延迟到下一个事件循环,确保子节点都已 parse 完毕。
+      const init = () => {
+        this._collectSlides();
+        this._setupEventListeners();
+        this._restoreSlide();
+        this._updateDisplay();
+        this._setupPrintStyles();
+      };
+
+      if (this.ownerDocument.readyState === 'loading') {
+        // 文档还在 parse,等 DOMContentLoaded 一次搞定所有 section
+        this.ownerDocument.addEventListener('DOMContentLoaded', init, { once: true });
+      } else {
+        // 文档已 parse 完(script 在 body 底部或 defer),下一帧收集即可
+        requestAnimationFrame(init);
+      }
     }
 
     _render() {

+ 1 - 1
assets/personal-asset-index.example.json

@@ -1,7 +1,7 @@
 {
   "_meta": {
     "description": "个人素材索引模板 — 复制此文件并填入你的真实数据",
-    "how_to_use": "1. 复制此文件到你的 agent 私有 memory 目录(Claude Code: ~/.claude/memory/personal-asset-index.json;其他 agent 按其约定)  2. 填入你的真实信息  3. huashu-design skill 在需要个人素材/锚定当前品牌时会自动读取",
+    "how_to_use": "1. 复制此文件到 ~/.claude/memory/personal-asset-index.json  2. 填入你的真实信息  3. design-philosophy skill 会自动读取",
     "note": "真实数据文件不要放在 skill 目录内,避免随 skill 分发泄露隐私"
   },
 

+ 721 - 0
demos/c1-ios-prototype.html

@@ -0,0 +1,721 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<title>Huashu-Design · iOS App Prototype</title>
+<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
+<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
+<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400&family=Noto+Serif+SC:wght@400;500;600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
+<style>
+  * { box-sizing: border-box; margin: 0; padding: 0; }
+  html, body { width: 100%; height: 100%; overflow: hidden; }
+  body { background: #0c0c0c; font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif; color: #1a1a1a; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
+</style>
+</head>
+<body>
+<div id="root"></div>
+
+<!-- animations.jsx inlined -->
+<script type="text/babel">
+(function() {
+  const { createContext, useContext, useState, useEffect, useRef } = React;
+  const TimeContext = createContext({ time: 0, duration: 10, playing: false });
+  const SpriteContext = createContext(null);
+  const Easing = {
+    linear: t => t,
+    easeIn: t => t * t,
+    easeOut: t => 1 - (1 - t) * (1 - t),
+    easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
+    spring: t => {
+      const c = (2 * Math.PI) / 3;
+      return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
+    },
+  };
+  function interpolate(t, input, output, easing) {
+    const [a, b] = input, [x, y] = output;
+    if (t <= a) return x; if (t >= b) return y;
+    let p = (t - a) / (b - a); if (easing) p = easing(p);
+    return x + (y - x) * p;
+  }
+  function useTime() { return useContext(TimeContext).time; }
+  function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
+  function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
+    const [time, setTime] = useState(0);
+    const [playing, setPlaying] = useState(true);
+    const [scale, setScale] = useState(1);
+    const rafRef = useRef(null);
+    const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
+    useEffect(() => {
+      const update = () => {
+        const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
+        setScale(s);
+      };
+      update(); window.addEventListener('resize', update);
+      return () => window.removeEventListener('resize', update);
+    }, [width, height]);
+    useEffect(() => {
+      if (!playing) return;
+      let cancelled = false, last = null;
+      function tick(now) {
+        if (cancelled) return;
+        if (last === null) { last = now; if (typeof window !== 'undefined') window.__ready = true; }
+        const delta = (now - last) / 1000; last = now;
+        setTime(prev => {
+          const next = prev + delta;
+          if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
+          return next;
+        });
+        rafRef.current = requestAnimationFrame(tick);
+      }
+      const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
+      if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
+      return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
+    }, [playing, duration, effectiveLoop]);
+    const progress = time / duration;
+    const ctx = { time, duration, playing, setPlaying, setTime };
+    return (
+      <TimeContext.Provider value={ctx}>
+        <div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
+          <div style={{flex:1, position:'relative', overflow:'hidden'}}>
+            <div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
+              {children}
+            </div>
+          </div>
+          <div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
+            <button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
+            <button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
+            <div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
+            <div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
+              <div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
+            </div>
+          </div>
+        </div>
+      </TimeContext.Provider>
+    );
+  }
+  function Sprite({ start = 0, end, children, style }) {
+    const { time } = useContext(TimeContext);
+    const actualEnd = end == null ? Infinity : end;
+    if (time < start || time >= actualEnd) return null;
+    const duration = actualEnd - start;
+    const elapsed = time - start;
+    const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
+    return (
+      <SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
+        <div style={{position:'absolute', inset:0, ...style}}>{children}</div>
+      </SpriteContext.Provider>
+    );
+  }
+  window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
+})();
+</script>
+
+<!-- Demo scene -->
+<script type="text/babel">
+const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
+
+// ── Design tokens ─────────────────────────────────────────
+const CREAM = '#FAF6EF';
+const PAPER = '#FDFBF5';
+const INK = '#1a1a1a';
+const TERRA = '#C04A1A';
+const OLIVE = '#6a6b4e';
+const ASH = '#6b6b6b';
+const LINE = '#e5ddcd';
+const LINE2 = '#d9d2c5';
+
+const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
+const sans = "'Inter', -apple-system, sans-serif";
+const mono = "'JetBrains Mono', ui-monospace, monospace";
+
+// ── Art image: CSS-rendered "oil painting" hero ──────────
+function ArtBlock({ mood = 'warm', height = 200 }) {
+  // Three curated palettes for variety (no Unsplash dep, stable offline)
+  const palettes = {
+    warm: ['#8b4a2b', '#c67b4a', '#e3a876', '#f2d4a7'], // Turner sunset
+    quiet: ['#3d4a3a', '#6a8066', '#a8b89c', '#e0d8b8'], // Corot pastoral
+    study: ['#2a3552', '#5e6b8a', '#8b98b5', '#d4c9a5'], // Vermeer indoor
+  };
+  const p = palettes[mood];
+  return (
+    <div style={{
+      width: '100%', height, position: 'relative', overflow: 'hidden',
+      background: `linear-gradient(135deg, ${p[0]} 0%, ${p[1]} 35%, ${p[2]} 70%, ${p[3]} 100%)`,
+    }}>
+      {/* Impressionist brush texture */}
+      <div style={{
+        position: 'absolute', inset: 0,
+        background: `
+          radial-gradient(ellipse 80px 30px at 30% 40%, ${p[3]}44, transparent 70%),
+          radial-gradient(ellipse 60px 20px at 70% 60%, ${p[0]}33, transparent 70%),
+          radial-gradient(ellipse 100px 40px at 50% 80%, ${p[2]}44, transparent 70%),
+          radial-gradient(ellipse 50px 25px at 20% 70%, ${p[1]}55, transparent 70%)
+        `,
+        filter: 'blur(1px)',
+      }} />
+      {/* Subtle scratch noise */}
+      <svg width="100%" height="100%" style={{position:'absolute', inset:0, opacity: 0.18}}>
+        <filter id="paint-noise">
+          <feTurbulence baseFrequency="0.9" numOctaves="2" />
+          <feColorMatrix values="0 0 0 0 0.3   0 0 0 0 0.2   0 0 0 0 0.1   0 0 0 1 0" />
+        </filter>
+        <rect width="100%" height="100%" filter="url(#paint-noise)" />
+      </svg>
+    </div>
+  );
+}
+
+// ── iOS Frame (simplified from ios_frame.jsx, positioned for demo) ──
+function IosFrame({ children, time = '9:41', scale = 1, style = {} }) {
+  const W = 420, H = 900;
+  return (
+    <div style={{
+      display: 'inline-block',
+      padding: 13,
+      background: '#0a0a0a',
+      borderRadius: 62,
+      boxShadow: '0 0 0 2px #2a2a2a, 0 30px 80px rgba(0,0,0,0.35), 0 10px 30px rgba(0,0,0,0.2)',
+      position: 'relative',
+      transform: `scale(${scale})`,
+      transformOrigin: 'center center',
+      ...style,
+    }}>
+      <div style={{
+        position: 'relative', width: W, height: H,
+        borderRadius: 50, overflow: 'hidden', background: PAPER,
+      }}>
+        {/* Status bar */}
+        <div style={{
+          position: 'absolute', top: 0, left: 0, right: 0, height: 54,
+          display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+          padding: '0 34px', fontSize: 17, fontWeight: 600,
+          fontFamily: '-apple-system, "SF Pro Text", sans-serif',
+          color: '#000', zIndex: 20, pointerEvents: 'none',
+        }}>
+          <span>{time}</span>
+          <div style={{display:'flex', alignItems:'center', gap: 6}}>
+            <div style={{display:'flex', alignItems:'flex-end', gap: 2, height: 12}}>
+              {[4, 6, 9, 11].map((h, i) => <div key={i} style={{width:3, height:h, background:'#000', borderRadius:1}} />)}
+            </div>
+            <svg width="16" height="12" viewBox="0 0 16 12">
+              <path d="M8 11.5a1 1 0 100-2 1 1 0 000 2z" fill="#000" />
+              <path d="M3 7.5a7 7 0 0110 0" stroke="#000" strokeWidth="1.3" fill="none" strokeLinecap="round" />
+              <path d="M1 4.5a11 11 0 0114 0" stroke="#000" strokeWidth="1.3" fill="none" strokeLinecap="round" opacity="0.7" />
+            </svg>
+            <div style={{width:26, height:12, border:'1.5px solid #000', borderRadius:3, padding:1, position:'relative'}}>
+              <div style={{width:'85%', height:'100%', background:'#000', borderRadius:1}} />
+              <div style={{position:'absolute', top:3, right:-3, width:2, height:6, background:'#000', borderRadius:'0 1px 1px 0'}} />
+            </div>
+          </div>
+        </div>
+        {/* Dynamic island */}
+        <div style={{
+          position: 'absolute', top: 12, left: '50%',
+          transform: 'translateX(-50%)', width: 124, height: 36,
+          background: '#000', borderRadius: 999, zIndex: 30,
+        }} />
+        {/* Content */}
+        <div style={{position:'absolute', top: 54, left: 0, right: 0, bottom: 34, overflow: 'hidden'}}>
+          {children}
+        </div>
+        {/* Home indicator */}
+        <div style={{
+          position: 'absolute', bottom: 10, left: '50%',
+          transform: 'translateX(-50%)', width: 140, height: 5,
+          background: 'rgba(0,0,0,0.28)', borderRadius: 999, zIndex: 10,
+        }} />
+      </div>
+    </div>
+  );
+}
+
+// ── Screen: Today ────────────────────────────────────────
+function TodayScreen({ animateT = 1 }) {
+  const headerOp = interpolate(animateT, [0, 0.25], [0, 1]);
+  const headerY = interpolate(animateT, [0, 0.35], [20, 0], Easing.easeOut);
+  const heroOp = interpolate(animateT, [0.15, 0.5], [0, 1]);
+  const heroY = interpolate(animateT, [0.15, 0.5], [30, 0], Easing.easeOut);
+  const memoriesOp = interpolate(animateT, [0.4, 0.8], [0, 1]);
+
+  return (
+    <div style={{padding: '24px 22px 0', height: '100%', display:'flex', flexDirection:'column', background: PAPER}}>
+      {/* Header */}
+      <div style={{opacity: headerOp, transform: `translateY(${headerY}px)`, marginBottom: 18}}>
+        <div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.25em', marginBottom: 4}}>
+          周二 · 4月20日
+        </div>
+        <div style={{fontFamily: serif, fontSize: 34, fontWeight: 500, color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
+          今日
+        </div>
+      </div>
+
+      {/* Hero card */}
+      <div style={{opacity: heroOp, transform: `translateY(${heroY}px)`, border: `1px solid ${LINE}`, background:'#fff', marginBottom: 14}}>
+        <ArtBlock mood="warm" height={180} />
+        <div style={{padding: '14px 16px 16px'}}>
+          <div style={{fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em', marginBottom: 6}}>
+            继续阅读 · 剩 12 分钟
+          </div>
+          <div style={{fontFamily: serif, fontSize: 20, fontWeight: 500, color: INK, lineHeight: 1.2, marginBottom: 4, letterSpacing:'-0.005em'}}>
+            《<span style={{fontStyle:'italic'}}>沉思录</span>》
+          </div>
+          <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13, color: ASH}}>
+            马可·奥勒留 · 第四卷
+          </div>
+          {/* AI insight */}
+          <div style={{marginTop: 14, paddingTop: 12, borderTop: `1px solid ${LINE}`,
+            display:'flex', gap: 10, alignItems: 'flex-start'}}>
+            <div style={{width: 6, height: 6, borderRadius:'50%', background: TERRA, marginTop: 6, flexShrink: 0}} />
+            <div>
+              <div style={{fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em', marginBottom: 2}}>
+                流明 · 已关联
+              </div>
+              <div style={{fontFamily: serif, fontSize: 13, color: INK, lineHeight: 1.4}}>
+                呼应你 3 周前读的《<span style={{fontStyle:'italic'}}>塞涅卡书简·28</span>》——同在谈<span style={{fontStyle:'italic'}}>内心堡垒</span>。
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      {/* Memory bubbles list */}
+      <div style={{opacity: memoriesOp, flex: 1}}>
+        <div style={{fontFamily: mono, fontSize: 9, color: ASH, letterSpacing:'0.25em', marginBottom: 10}}>
+          来自你的记忆
+        </div>
+        {[
+          { title: '"Amor fati"——一个说法', sub: '尼采 · 2 个月前', dot: OLIVE },
+          { title: '论「注意力即爱」', sub: '薇依 · 5 个月前', dot: TERRA },
+        ].map((m, i) => (
+          <div key={i} style={{padding: '11px 0', borderTop: i === 0 ? `1px solid ${LINE}` : 'none', borderBottom: `1px solid ${LINE}`, display:'flex', alignItems:'center', gap: 12}}>
+            <div style={{width: 8, height: 8, borderRadius: '50%', background: m.dot}} />
+            <div style={{flex: 1}}>
+              <div style={{fontFamily: serif, fontSize: 14, color: INK, lineHeight: 1.3}}>{m.title}</div>
+              <div style={{fontFamily: mono, fontSize: 9, color: ASH, marginTop: 2, letterSpacing:'0.1em'}}>{m.sub}</div>
+            </div>
+            <div style={{fontFamily: serif, fontSize: 18, color: ASH, fontStyle:'italic'}}>→</div>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}
+
+// ── Screen: Memory (graph view) ───────────────────────────
+function MemoryScreen({ animateT = 1 }) {
+  const headerOp = interpolate(animateT, [0, 0.3], [0, 1]);
+  const graphOp = interpolate(animateT, [0.15, 0.6], [0, 1]);
+  const listOp = interpolate(animateT, [0.5, 0.9], [0, 1]);
+
+  // Nodes for graph
+  const nodes = [
+    { x: 210, y: 100, r: 22, label: '斯多葛', emph: true },
+    { x: 110, y: 180, r: 14, label: '伦理' },
+    { x: 310, y: 170, r: 16, label: '美德', emph: true },
+    { x: 90, y: 260, r: 10, label: '' },
+    { x: 200, y: 240, r: 12, label: '' },
+    { x: 320, y: 270, r: 18, label: '自我' },
+    { x: 150, y: 330, r: 11, label: '' },
+    { x: 280, y: 340, r: 13, label: '心流' },
+  ];
+  const edges = [
+    [0, 1], [0, 2], [0, 4], [1, 3], [2, 5], [4, 5], [4, 6], [5, 7], [6, 7], [1, 4],
+  ];
+
+  return (
+    <div style={{padding: '24px 22px 0', height: '100%', background: PAPER, display:'flex', flexDirection:'column'}}>
+      <div style={{opacity: headerOp, marginBottom: 14}}>
+        <div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.25em', marginBottom: 4}}>
+          287 条 · 4 个聚类
+        </div>
+        <div style={{fontFamily: serif, fontSize: 34, fontWeight: 500, color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
+          记忆
+        </div>
+      </div>
+
+      {/* Graph visualization */}
+      <div style={{
+        opacity: graphOp, border:`1px solid ${LINE}`, background:'#fff',
+        height: 400, position:'relative', overflow:'hidden', marginBottom: 14,
+      }}>
+        <svg viewBox="0 0 420 400" width="100%" height="100%" style={{display:'block'}}>
+          {/* edges */}
+          {edges.map(([a, b], i) => {
+            const na = nodes[a], nb = nodes[b];
+            return <line key={i} x1={na.x} y1={na.y} x2={nb.x} y2={nb.y}
+              stroke="#c8beb0" strokeWidth={0.8} opacity={0.7} />;
+          })}
+          {/* nodes */}
+          {nodes.map((n, i) => {
+            const appear = interpolate(animateT, [0.2 + i * 0.04, 0.4 + i * 0.04], [0, 1], Easing.easeOut);
+            return (
+              <g key={i} opacity={appear}>
+                <circle cx={n.x} cy={n.y} r={n.r}
+                  fill={n.emph ? TERRA : '#ede5d3'}
+                  stroke={n.emph ? TERRA : '#b8ac94'}
+                  strokeWidth={1} />
+                {n.label && (
+                  <text x={n.x} y={n.y + n.r + 14} textAnchor="middle"
+                    fontFamily={serif} fontStyle="italic" fontSize={11}
+                    fill={n.emph ? TERRA : '#666'}>
+                    {n.label}
+                  </text>
+                )}
+              </g>
+            );
+          })}
+        </svg>
+        {/* corner label */}
+        <div style={{position:'absolute', top: 12, left: 14,
+          fontFamily: mono, fontSize: 9, color: ASH, letterSpacing:'0.2em'}}>
+          · 图谱视图
+        </div>
+        <div style={{position:'absolute', bottom: 12, right: 14,
+          fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em'}}>
+          斯多葛派 · 47 条
+        </div>
+      </div>
+
+      {/* Top clusters */}
+      <div style={{opacity: listOp}}>
+        <div style={{fontFamily: mono, fontSize: 9, color: ASH, letterSpacing:'0.25em', marginBottom: 8}}>
+          主要聚类
+        </div>
+        {[
+          { name: '斯多葛', count: 47, swatch: TERRA },
+          { name: '注意力', count: 32, swatch: OLIVE },
+        ].map((c, i) => (
+          <div key={i} style={{padding: '9px 0', borderTop: i === 0 ? `1px solid ${LINE}` : 'none', borderBottom: `1px solid ${LINE}`, display:'flex', alignItems:'center', gap: 10}}>
+            <div style={{width: 14, height: 14, background: c.swatch, borderRadius: 2}} />
+            <div style={{flex: 1, fontFamily: serif, fontSize: 14, color: INK}}>{c.name}</div>
+            <div style={{fontFamily: mono, fontSize: 10, color: ASH}}>{c.count}</div>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}
+
+// ── Screen: Chat ─────────────────────────────────────────
+function ChatScreen({ animateT = 1 }) {
+  const headerOp = interpolate(animateT, [0, 0.2], [0, 1]);
+  const msg1Op = interpolate(animateT, [0.15, 0.35], [0, 1]);
+  const msg2Op = interpolate(animateT, [0.4, 0.65], [0, 1]);
+  const ctxCardOp = interpolate(animateT, [0.55, 0.75], [0, 1]);
+  const msg3Op = interpolate(animateT, [0.7, 0.92], [0, 1]);
+  const inputOp = interpolate(animateT, [0.5, 0.8], [0, 1]);
+
+  // Typewriter for AI reply (msg2)
+  const aiText = '两处呼应——马可谈的「内心堡垒」和塞涅卡第 28 封信中的独处。';
+  const charCount = Math.floor(interpolate(animateT, [0.4, 0.7], [0, aiText.length]));
+  const typed = aiText.slice(0, charCount);
+
+  return (
+    <div style={{padding: '24px 22px 0', height: '100%', background: PAPER, display:'flex', flexDirection:'column'}}>
+      <div style={{opacity: headerOp, marginBottom: 16}}>
+        <div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.25em', marginBottom: 4}}>
+          流明 · 已关联
+        </div>
+        <div style={{fontFamily: serif, fontSize: 30, fontWeight: 500, color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
+          问你的记忆
+        </div>
+      </div>
+
+      <div style={{flex: 1, display:'flex', flexDirection:'column', gap: 12}}>
+        {/* User msg */}
+        <div style={{opacity: msg1Op, alignSelf:'flex-end', maxWidth: '85%',
+          background:'#f0e8d5', padding: '10px 14px', borderRadius: '18px 18px 4px 18px'}}>
+          <div style={{fontFamily: serif, fontSize: 14, color: INK, lineHeight: 1.4}}>
+            我最近关于<span style={{fontStyle:'italic'}}>「独处」</span>在想什么?
+          </div>
+        </div>
+
+        {/* AI reply (typewriter) */}
+        <div style={{opacity: msg2Op, alignSelf:'flex-start', maxWidth: '90%',
+          paddingLeft: 14, borderLeft: `2px solid ${TERRA}`}}>
+          <div style={{fontFamily: mono, fontSize: 9, color: TERRA, letterSpacing:'0.2em', marginBottom: 4}}>
+            流明
+          </div>
+          <div style={{fontFamily: serif, fontSize: 14, color: INK, lineHeight: 1.45, minHeight: 60}}>
+            {typed}
+            {charCount < aiText.length && charCount > 0 && (
+              <span style={{color: TERRA, marginLeft: 2}}>|</span>
+            )}
+          </div>
+        </div>
+
+        {/* Context card */}
+        <div style={{opacity: ctxCardOp, alignSelf:'flex-start', maxWidth: '88%',
+          background:'#fff', border: `1px solid ${LINE}`, padding: '10px 12px',
+          marginLeft: 14, display:'flex', gap: 10, alignItems:'center'}}>
+          <div style={{width: 40, height: 40, flexShrink: 0, overflow:'hidden'}}>
+            <ArtBlock mood="study" height={40} />
+          </div>
+          <div style={{flex: 1}}>
+            <div style={{fontFamily: serif, fontSize: 12, fontWeight: 500, color: INK, lineHeight: 1.2}}>
+              塞涅卡 · 第 28 封信
+            </div>
+            <div style={{fontFamily: mono, fontSize: 8, color: ASH, marginTop: 2, letterSpacing:'0.15em'}}>
+              3 周前阅读 · 4 分钟
+            </div>
+          </div>
+          <div style={{fontFamily: serif, fontSize: 16, color: ASH, fontStyle:'italic'}}>↗</div>
+        </div>
+
+        {/* User follow-up */}
+        <div style={{opacity: msg3Op, alignSelf:'flex-end', maxWidth: '70%',
+          background:'#f0e8d5', padding: '10px 14px', borderRadius: '18px 18px 4px 18px'}}>
+          <div style={{fontFamily: serif, fontSize: 14, color: INK}}>
+            给我看原文段落。
+          </div>
+        </div>
+      </div>
+
+      {/* Input bar */}
+      <div style={{opacity: inputOp, padding: '10px 0 16px',
+        borderTop: `1px solid ${LINE}`, marginTop: 12,
+        display:'flex', alignItems:'center', gap: 10}}>
+        <div style={{flex: 1, fontFamily: serif, fontStyle:'italic',
+          fontSize: 13, color: ASH}}>
+          从你的阅读里问我任何事…
+        </div>
+        <div style={{width: 28, height: 28, background: TERRA, borderRadius: '50%',
+          display:'flex', alignItems:'center', justifyContent:'center',
+          color:'#fff', fontFamily: sans, fontSize: 16}}>↑</div>
+      </div>
+    </div>
+  );
+}
+
+// ── Tab bar ───────────────────────────────────────────────
+function TabBar({ active = 'today', tapping = null }) {
+  const tabs = [
+    { id: 'today', label: '今日' },
+    { id: 'memory', label: '记忆' },
+    { id: 'chat', label: '对话' },
+  ];
+  return (
+    <div style={{
+      position: 'absolute', bottom: 0, left: 0, right: 0,
+      height: 72, background: 'rgba(253,251,245,0.95)',
+      backdropFilter: 'blur(12px)',
+      borderTop: `1px solid ${LINE}`,
+      display: 'flex', alignItems: 'center',
+      fontFamily: serif,
+    }}>
+      {tabs.map((t) => {
+        const isActive = active === t.id;
+        const isTapping = tapping === t.id;
+        return (
+          <div key={t.id} style={{
+            flex: 1, textAlign:'center', position:'relative',
+            padding: '12px 0 18px',
+          }}>
+            {/* Ripple */}
+            {isTapping !== null && isTapping > 0 && isTapping < 1 && (
+              <div style={{
+                position:'absolute', top:'50%', left:'50%',
+                transform: `translate(-50%, -50%) scale(${1 + isTapping * 2})`,
+                width: 44, height: 44, borderRadius:'50%',
+                background: TERRA, opacity: 0.25 * (1 - isTapping),
+                pointerEvents:'none',
+              }} />
+            )}
+            <div style={{
+              fontSize: 15, fontWeight: isActive ? 600 : 400,
+              fontStyle: isActive ? 'normal' : 'italic',
+              color: isActive ? TERRA : ASH,
+              letterSpacing: '0.02em',
+            }}>
+              {t.label}
+            </div>
+            {isActive && (
+              <div style={{
+                position:'absolute', bottom: 8, left:'50%',
+                transform: 'translateX(-50%)',
+                width: 18, height: 2, background: TERRA,
+              }} />
+            )}
+          </div>
+        );
+      })}
+    </div>
+  );
+}
+
+// ── Scene composition ─────────────────────────────────────
+// Timeline:
+//   0.0 – 1.8  iPhone fade+bounce in
+//   1.8 – 7.5  Today screen (fills in)
+//   7.5 – 8.5  Tap on Memory tab (ripple)
+//   8.5 – 13.5 Memory screen
+//   13.5 – 14.5 Tap on Chat tab
+//   14.5 – 19.5 Chat screen
+//   19.5 – 21.5 Pan: phone shrinks + capability labels appear
+//   21.5 – 24.0 Hold final frame with labels
+function App() {
+  return (
+    <Stage duration={24} width={1920} height={1080} bgColor={CREAM}>
+      <MainComposition />
+    </Stage>
+  );
+}
+
+function MainComposition() {
+  const time = useTime();
+
+  // Phone entrance
+  const entranceT = Math.min(1, Math.max(0, time / 1.8));
+  const phoneOp = interpolate(entranceT, [0, 0.5], [0, 1]);
+  const phoneScale = interpolate(entranceT, [0, 1], [0.82, 0.88], Easing.spring);
+
+  // Pan-out in final scene (19.5 – 21.5)
+  const panT = Math.min(1, Math.max(0, (time - 19.5) / 2));
+  const finalScale = interpolate(panT, [0, 1], [0.88, 0.68], Easing.easeInOut);
+  const finalX = interpolate(panT, [0, 1], [0, -200], Easing.easeInOut);
+
+  const currentScale = panT > 0 ? finalScale : phoneScale;
+  const currentX = panT > 0 ? finalX : 0;
+
+  // Screen determination
+  let activeScreen = 'today';
+  let tapping = null; // { id: 'memory', t: 0..1 }
+  let screenAnimateT = 1;
+  let transitionProgress = 0;
+
+  if (time < 7.5) {
+    activeScreen = 'today';
+    screenAnimateT = Math.min(1, Math.max(0, (time - 1.8) / 2.5));
+  } else if (time < 8.5) {
+    activeScreen = 'today';
+    tapping = { id: 'memory', t: (time - 7.5) / 1.0 };
+    transitionProgress = (time - 8.0) / 0.5; // slide starts at 8.0
+  } else if (time < 13.5) {
+    activeScreen = 'memory';
+    screenAnimateT = Math.min(1, Math.max(0, (time - 8.5) / 2.5));
+  } else if (time < 14.5) {
+    activeScreen = 'memory';
+    tapping = { id: 'chat', t: (time - 13.5) / 1.0 };
+    transitionProgress = (time - 14.0) / 0.5;
+  } else if (time < 19.5) {
+    activeScreen = 'chat';
+    screenAnimateT = Math.min(1, Math.max(0, (time - 14.5) / 2.5));
+  } else {
+    activeScreen = 'chat';
+    screenAnimateT = 1;
+  }
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM}}>
+      {/* Phone */}
+      <div style={{
+        position: 'absolute', top: '50%', left: '50%',
+        transform: `translate(calc(-50% + ${currentX}px), -50%) scale(${currentScale})`,
+        opacity: phoneOp, transformOrigin: 'center center',
+      }}>
+        <IosFrame>
+          <div style={{position:'relative', width:'100%', height:'100%'}}>
+            {activeScreen === 'today' && <TodayScreen animateT={screenAnimateT} />}
+            {activeScreen === 'memory' && <MemoryScreen animateT={screenAnimateT} />}
+            {activeScreen === 'chat' && <ChatScreen animateT={screenAnimateT} />}
+            <TabBar active={activeScreen} tapping={tapping ? tapping.t : null} />
+          </div>
+        </IosFrame>
+      </div>
+
+      {/* Capability labels (appear during pan-out 19.5+) */}
+      {panT > 0.05 && <CapabilityLabels t={panT} />}
+
+      {/* Masthead (tucked corner, always visible from ~2s) */}
+      {time > 2 && time < 19.5 && (
+        <div style={{
+          position: 'absolute', top: 60, left: 80,
+          opacity: Math.min(1, (time - 2) / 0.6),
+          maxWidth: 420,
+        }}>
+          <div style={{fontFamily: mono, fontSize: 12, color: TERRA, letterSpacing:'0.3em', marginBottom: 10}}>
+            iOS APP 原型
+          </div>
+          <div style={{fontFamily: serif, fontSize: 70, fontWeight: 500, color: INK, lineHeight: 1.05, letterSpacing:'-0.015em'}}>
+            真机。<br/>
+            <span style={{fontStyle:'italic', color: TERRA}}>真</span>交互。
+          </div>
+          <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 20, color: ASH, marginTop: 22, lineHeight: 1.55}}>
+            iPhone 15 Pro 机身 · 灵动岛 · 状态驱动多屏<br/>
+            AI 密度信息 · CSS 艺术 · Playwright 点击测试
+          </div>
+        </div>
+      )}
+
+      {/* Screen label (bottom) */}
+      {time > 2 && time < 19.5 && (
+        <ScreenLabel active={activeScreen} time={time} />
+      )}
+
+      {/* Watermark */}
+      <div style={{position:'absolute', bottom: 24, right: 32,
+        fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
+        fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
+        Created by Huashu-Design
+      </div>
+    </div>
+  );
+}
+
+function ScreenLabel({ active, time }) {
+  const label = { today: '屏幕 1 · 今日', memory: '屏幕 2 · 记忆', chat: '屏幕 3 · 对话' }[active];
+  const idx = { today: 1, memory: 2, chat: 3 }[active];
+  return (
+    <div style={{
+      position: 'absolute', bottom: 80, left: 80,
+      fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.25em',
+      opacity: 0.9,
+    }}>
+      <span style={{color: TERRA, marginRight: 12}}>0{idx}</span>
+      <span>{label.toUpperCase()}</span>
+    </div>
+  );
+}
+
+function CapabilityLabels({ t }) {
+  const labels = [
+    { text: '真图 · Wikimedia / Met / Unsplash', y: 220, delay: 0.0 },
+    { text: 'Inline React · 双击就开', y: 380, delay: 0.15 },
+    { text: 'AppPhone · 状态驱动多屏切换', y: 540, delay: 0.30 },
+    { text: '信息密度型 · 每屏 ≥ 3 处差异化', y: 700, delay: 0.45 },
+    { text: 'Playwright · 交付前点击测试', y: 860, delay: 0.60 },
+  ];
+  return (
+    <>
+      {labels.map((l, i) => {
+        const localT = Math.max(0, Math.min(1, (t - l.delay) / 0.35));
+        const op = localT;
+        const x = interpolate(localT, [0, 1], [1400, 1280], Easing.easeOut);
+        return (
+          <div key={i} style={{
+            position: 'absolute', left: x, top: l.y,
+            opacity: op, display:'flex', alignItems:'center', gap: 14,
+          }}>
+            <div style={{width: 60, height: 1, background: TERRA}} />
+            <div>
+              <div style={{fontFamily: mono, fontSize: 10, color: TERRA, letterSpacing:'0.25em', marginBottom: 3}}>
+                0{i + 1}
+              </div>
+              <div style={{fontFamily: serif, fontSize: 20, color: INK, lineHeight: 1.25, letterSpacing:'-0.005em'}}>
+                {l.text}
+              </div>
+            </div>
+          </div>
+        );
+      })}
+    </>
+  );
+}
+
+ReactDOM.createRoot(document.getElementById('root')).render(<App />);
+</script>
+</body>
+</html>

+ 978 - 0
demos/c2-slides-pptx.html

@@ -0,0 +1,978 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<title>Huashu-Design · Slides → PPTX</title>
+<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
+<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
+<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
+<style>
+  * { box-sizing: border-box; margin: 0; padding: 0; }
+  html, body { width: 100%; height: 100%; overflow: hidden; }
+  body {
+    background: #0c0c0c;
+    font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
+    color: #1a1a1a;
+    -webkit-font-smoothing: antialiased;
+    text-rendering: optimizeLegibility;
+  }
+</style>
+</head>
+<body>
+<div id="root"></div>
+
+<!-- animations.jsx inlined -->
+<script type="text/babel">
+(function() {
+  const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
+  const TimeContext = createContext({ time: 0, duration: 10, playing: false });
+  const SpriteContext = createContext(null);
+
+  const Easing = {
+    linear: t => t,
+    easeIn: t => t * t,
+    easeOut: t => 1 - (1 - t) * (1 - t),
+    easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
+    spring: t => {
+      const c = (2 * Math.PI) / 3;
+      return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
+    },
+  };
+
+  function interpolate(t, input, output, easing) {
+    const [inStart, inEnd] = input;
+    const [outStart, outEnd] = output;
+    if (t <= inStart) return outStart;
+    if (t >= inEnd) return outEnd;
+    let progress = (t - inStart) / (inEnd - inStart);
+    if (easing) progress = easing(progress);
+    return outStart + (outEnd - outStart) * progress;
+  }
+
+  function useTime() { return useContext(TimeContext).time; }
+  function useSprite() {
+    const sprite = useContext(SpriteContext);
+    return sprite || { t: 0, elapsed: 0, duration: 0 };
+  }
+
+  function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
+    const [time, setTime] = useState(0);
+    const [playing, setPlaying] = useState(true);
+    const [scale, setScale] = useState(1);
+    const rafRef = useRef(null);
+    const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
+
+    useEffect(() => {
+      function updateScale() {
+        const vw = window.innerWidth;
+        const vh = window.innerHeight - 56;
+        const s = Math.min(vw / width, vh / height);
+        setScale(s);
+      }
+      updateScale();
+      window.addEventListener('resize', updateScale);
+      return () => window.removeEventListener('resize', updateScale);
+    }, [width, height]);
+
+    useEffect(() => {
+      if (!playing) return;
+      let cancelled = false;
+      let last = null;
+      function tick(now) {
+        if (cancelled) return;
+        if (last === null) {
+          last = now;
+          if (typeof window !== 'undefined') window.__ready = true;
+        }
+        const delta = (now - last) / 1000;
+        last = now;
+        setTime(prev => {
+          const next = prev + delta;
+          if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
+          return next;
+        });
+        rafRef.current = requestAnimationFrame(tick);
+      }
+      const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
+      if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
+      return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
+    }, [playing, duration, effectiveLoop]);
+
+    const progress = time / duration;
+    const ctx = { time, duration, playing, setPlaying, setTime };
+
+    const canvasStyle = {
+      position: 'absolute',
+      top: '50%',
+      left: '50%',
+      transformOrigin: 'center center',
+      width,
+      height,
+      background: bgColor,
+      overflow: 'hidden',
+      transform: `translate(-50%, -50%) scale(${scale})`,
+    };
+
+    return (
+      <TimeContext.Provider value={ctx}>
+        <div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
+          <div style={{flex:1, position:'relative', overflow:'hidden'}}>
+            <div style={canvasStyle}>{children}</div>
+          </div>
+          <div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', backdropFilter:'blur(10px)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
+            <button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
+            <button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
+            <div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
+            <div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
+              <div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
+            </div>
+          </div>
+        </div>
+      </TimeContext.Provider>
+    );
+  }
+
+  function Sprite({ start = 0, end, children, style }) {
+    const { time } = useContext(TimeContext);
+    const actualEnd = end == null ? Infinity : end;
+    if (time < start || time >= actualEnd) return null;
+    const duration = actualEnd - start;
+    const elapsed = time - start;
+    const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
+    const spriteValue = { t, elapsed, duration, start, end: actualEnd };
+    return (
+      <SpriteContext.Provider value={spriteValue}>
+        <div style={{position:'absolute', inset:0, ...style}}>{children}</div>
+      </SpriteContext.Provider>
+    );
+  }
+
+  window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
+})();
+</script>
+
+<!-- Demo scene -->
+<script type="text/babel">
+const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
+
+// ── Design tokens ─────────────────────────────────────────
+const CREAM = '#FAF6EF';
+const INK = '#1a1a1a';
+const TERRA = '#C04A1A';
+const ASH = '#6b6b6b';
+const LINE = '#d9d2c5';
+const OLIVE = '#6a6b4e';
+const DEEP_BLUE = '#2a3552';
+
+const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
+const sans = "'Inter', -apple-system, sans-serif";
+const mono = "'JetBrains Mono', ui-monospace, monospace";
+
+// ══════════════════════════════════════════════════════════
+// Scene 1 (0 – 3s) · 开题
+// ══════════════════════════════════════════════════════════
+function Scene1_Title() {
+  const { elapsed } = useSprite();
+  const tagOp = interpolate(elapsed, [0, 0.6], [0, 1]);
+  const mainOp = interpolate(elapsed, [0.4, 1.2], [0, 1]);
+  const mainY = interpolate(elapsed, [0.4, 1.2], [40, 0], Easing.easeOut);
+  const terraOp = interpolate(elapsed, [1.1, 1.8], [0, 1]);
+  const lineW = interpolate(elapsed, [1.6, 2.2], [0, 640]);
+  const subOp = interpolate(elapsed, [1.9, 2.5], [0, 1]);
+  const fadeOut = interpolate(elapsed, [2.7, 3.0], [1, 0]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
+      display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
+      <div style={{position:'absolute', top: 72, left: 88,
+        fontFamily: mono, fontSize: 12, letterSpacing:'0.3em',
+        color: ASH, opacity: tagOp}}>
+        <span style={{color: TERRA}}>●</span>  幻灯片能力 · HTML + PPTX
+      </div>
+      <div style={{fontFamily: serif, fontSize: 130, fontWeight: 500,
+        color: INK, lineHeight: 1.0, letterSpacing:'-0.015em',
+        opacity: mainOp, transform: `translateY(${mainY}px)`,
+        textAlign: 'center'}}>
+        <span style={{fontStyle:'italic'}}>播放</span>用 HTML,<br/>
+        <span style={{fontStyle:'italic', color: TERRA, opacity: terraOp}}>编辑</span>用 PPTX
+      </div>
+      <div style={{height: 1, background: INK, width: lineW, marginTop: 40}} />
+      <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24,
+        color: ASH, marginTop: 24, opacity: subOp, letterSpacing:'0.02em'}}>
+        一个源文件,两种交付形态
+      </div>
+    </div>
+  );
+}
+
+// ══════════════════════════════════════════════════════════
+// Scene 2 (3 – 9s) · HTML Deck 翻页
+// ══════════════════════════════════════════════════════════
+function Scene2_DeckFlip() {
+  const { elapsed } = useSprite();
+  const frameOp = interpolate(elapsed, [0, 0.6], [0, 1]);
+  const frameScale = interpolate(elapsed, [0, 0.8], [0.94, 1], Easing.easeOut);
+
+  // Three pages, each ~1.5s. Stagger timings inside deck.
+  // Page 1: 0.6 – 2.2 | Page 2: 2.2 – 3.8 | Page 3: 3.8 – 5.6
+  const pageIndex = elapsed < 2.2 ? 0 : elapsed < 3.8 ? 1 : 2;
+  const pageNum = pageIndex + 1;
+
+  const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
+      display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
+      <div style={{position:'absolute', top: 48, left: 88,
+        fontFamily: mono, fontSize: 11, letterSpacing:'0.3em', color: ASH}}>
+        <span style={{color: TERRA}}>●</span>  SCENE 02 · HTML DECK
+      </div>
+      <div style={{position:'absolute', top: 48, right: 88,
+        fontFamily: serif, fontStyle:'italic', fontSize: 20, color: ASH}}>
+        浏览器里直接演讲
+      </div>
+
+      <div style={{opacity: frameOp, transform: `scale(${frameScale})`,
+        transformOrigin:'center center'}}>
+        <BrowserFrame url="file:///Users/huashu/decks/annual-2026/deck.html">
+          <DeckSlide pageIndex={pageIndex} localElapsed={elapsed} />
+          {/* Footer inside deck */}
+          <div style={{position:'absolute', bottom: 18, left: 28, right: 28,
+            display:'flex', justifyContent:'space-between', alignItems:'center',
+            zIndex: 5}}>
+            <div style={{fontFamily: mono, fontSize: 11, color: ASH,
+              letterSpacing:'0.15em'}}>
+              {String(pageNum).padStart(2,'0')} / 12
+            </div>
+            <div style={{fontFamily: mono, fontSize: 10, color: ASH,
+              letterSpacing:'0.2em'}}>
+              HUASHU · DESIGN
+            </div>
+          </div>
+          {/* TERRA progress bar */}
+          <div style={{position:'absolute', bottom: 0, left: 0, right: 0,
+            height: 3, background: '#eee', zIndex: 5}}>
+            <div style={{height:'100%', width: `${(pageNum/12)*100}%`,
+              background: TERRA}} />
+          </div>
+        </BrowserFrame>
+      </div>
+
+      <div style={{marginTop: 28, fontFamily: mono, fontSize: 11, color: ASH,
+        letterSpacing:'0.25em'}}>
+        <span style={{color: pageIndex === 0 ? TERRA : LINE}}>●</span>
+        <span style={{margin:'0 10px', color: pageIndex === 1 ? TERRA : LINE}}>●</span>
+        <span style={{color: pageIndex === 2 ? TERRA : LINE}}>●</span>
+      </div>
+    </div>
+  );
+}
+
+// Browser chrome container (chrome style, 1600×900 deck 16:9)
+function BrowserFrame({ url, children }) {
+  const W = 1400, H = 788;  // 16:9 ratio
+  return (
+    <div style={{
+      display:'inline-block',
+      background:'#e8e4dc',
+      borderRadius: 12,
+      boxShadow:'0 30px 70px rgba(0,0,0,0.18), 0 10px 24px rgba(0,0,0,0.12)',
+      padding: 0,
+      overflow:'hidden',
+      border:`1px solid ${LINE}`,
+    }}>
+      {/* Title bar */}
+      <div style={{height: 42, display:'flex', alignItems:'center',
+        background:'#e8e4dc', padding:'0 16px', gap: 8,
+        borderBottom:`1px solid ${LINE}`}}>
+        <div style={{width:12, height:12, borderRadius:'50%', background:'#ff5f57'}} />
+        <div style={{width:12, height:12, borderRadius:'50%', background:'#febc2e'}} />
+        <div style={{width:12, height:12, borderRadius:'50%', background:'#28c840'}} />
+        <div style={{flex: 1, height: 26, background:'#faf6ef', border:`1px solid ${LINE}`,
+          borderRadius: 6, marginLeft: 16, padding:'0 14px',
+          display:'flex', alignItems:'center', gap: 8,
+          fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.02em',
+          overflow:'hidden', whiteSpace:'nowrap'}}>
+          <svg width="10" height="12" viewBox="0 0 10 12" style={{flexShrink: 0}}>
+            <path d="M2 5 V3.5 a3 3 0 016 0 V5" stroke={OLIVE} strokeWidth="1.2" fill="none"/>
+            <rect x="1" y="5" width="8" height="6" fill={OLIVE} opacity="0.85"/>
+          </svg>
+          <span style={{color: INK, opacity: 0.7}}>{url}</span>
+        </div>
+        <div style={{fontFamily: mono, fontSize: 10, color: ASH,
+          letterSpacing:'0.15em'}}>DECK MODE</div>
+      </div>
+      {/* Deck area */}
+      <div style={{width: W, height: H, background:'#fff', position:'relative',
+        overflow:'hidden'}}>
+        {children}
+      </div>
+    </div>
+  );
+}
+
+// Three deck pages
+function DeckSlide({ pageIndex, localElapsed }) {
+  // Slide-in entrance each time pageIndex changes
+  const pageStart = pageIndex === 0 ? 0.6 : pageIndex === 1 ? 2.2 : 3.8;
+  const sinceStart = localElapsed - pageStart;
+  const slideX = interpolate(sinceStart, [0, 0.5], [140, 0], Easing.easeOut);
+  const fadeIn = interpolate(sinceStart, [0, 0.4], [0, 1]);
+
+  return (
+    <div key={pageIndex} style={{position:'absolute', inset:0,
+      opacity: fadeIn, transform: `translateX(${slideX}px)`}}>
+      {pageIndex === 0 && <CoverPage />}
+      {pageIndex === 1 && <DataPage />}
+      {pageIndex === 2 && <QuotePage />}
+    </div>
+  );
+}
+
+function CoverPage() {
+  return (
+    <div style={{padding: '80px 80px 60px', height:'100%', background:'#fff',
+      display:'flex', flexDirection:'column'}}>
+      <div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.3em',
+        color: TERRA, marginBottom: 14}}>
+        VOL.01 · ANNUAL REPORT
+      </div>
+      <div style={{flex: 1, display:'flex', flexDirection:'column',
+        justifyContent:'center'}}>
+        <div style={{fontFamily: serif, fontSize: 132, fontWeight: 500,
+          color: INK, lineHeight: 1.02, letterSpacing:'-0.02em'}}>
+          2026<br/>
+          <span style={{fontStyle:'italic'}}>设计年度</span>报告
+        </div>
+        <div style={{height: 1, background: INK, width: 380, marginTop: 36,
+          marginBottom: 28}} />
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
+          color: ASH, letterSpacing:'0.02em'}}>
+          The shape of digital craft, from typography to motion.
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function DataPage() {
+  const numbers = [
+    { big: '428', label: '项目交付', unit: 'projects' },
+    { big: '92%', label: '客户续约', unit: 'retention' },
+    { big: '3.1x', label: '交付提速', unit: 'vs 2025' },
+  ];
+  const bars = [
+    { h: 0.45, label: 'Q1' },
+    { h: 0.62, label: 'Q2' },
+    { h: 0.78, label: 'Q3' },
+    { h: 1.00, label: 'Q4', hi: true },
+  ];
+  return (
+    <div style={{padding: '60px 80px 56px', height:'100%', background:'#fff',
+      display:'flex', flexDirection:'column'}}>
+      <div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
+        color: TERRA, marginBottom: 10}}>SECTION 02 · NUMBERS</div>
+      <div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
+        letterSpacing:'-0.015em', marginBottom: 36}}>
+        今年的三个关键数字
+      </div>
+      <div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap: 48,
+        marginBottom: 40}}>
+        {numbers.map((n, i) => (
+          <div key={i}>
+            <div style={{fontFamily: serif, fontSize: 112, fontWeight: 400,
+              color: i === 2 ? TERRA : INK, lineHeight: 1, letterSpacing:'-0.02em'}}>
+              {n.big}
+            </div>
+            <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
+              color: INK, marginTop: 10}}>
+              {n.label}
+            </div>
+            <div style={{fontFamily: mono, fontSize: 11, color: ASH,
+              letterSpacing:'0.2em', marginTop: 4}}>
+              {n.unit}
+            </div>
+          </div>
+        ))}
+      </div>
+      <div style={{flex: 1, display:'flex', alignItems:'flex-end', gap: 20,
+        paddingLeft: 4, borderTop:`1px solid ${LINE}`, paddingTop: 24}}>
+        {bars.map((b, i) => (
+          <div key={i} style={{flex: 1, display:'flex', flexDirection:'column',
+            alignItems:'center'}}>
+            <div style={{width:'78%', height: `${b.h * 180}px`,
+              background: b.hi ? TERRA : INK, marginBottom: 10}} />
+            <div style={{fontFamily: mono, fontSize: 11, color: ASH,
+              letterSpacing:'0.2em'}}>{b.label}</div>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}
+
+function QuotePage() {
+  return (
+    <div style={{padding: '80px', height:'100%', background:'#faf6ef',
+      display:'flex', flexDirection:'column', justifyContent:'center',
+      alignItems:'center', position:'relative'}}>
+      <div style={{position:'absolute', top: 64, left: 80,
+        fontFamily: mono, fontSize: 11, letterSpacing:'0.3em', color: TERRA}}>
+        EPIGRAPH · III
+      </div>
+      <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 104,
+        fontWeight: 400, color: INK, lineHeight: 1.15, letterSpacing:'-0.015em',
+        textAlign:'center', maxWidth: 1100}}>
+        "Less,<br/>but <span style={{color: TERRA}}>better</span>."
+      </div>
+      <div style={{height: 1, background: INK, width: 140, marginTop: 44,
+        marginBottom: 20}} />
+      <div style={{fontFamily: serif, fontSize: 22, color: ASH,
+        letterSpacing:'0.08em'}}>
+        — Dieter Rams
+      </div>
+    </div>
+  );
+}
+
+// ══════════════════════════════════════════════════════════
+// Scene 3 (9 – 15s) · 导出流水线
+// ══════════════════════════════════════════════════════════
+function Scene3_Pipeline() {
+  const { elapsed } = useSprite();
+  const titleOp = interpolate(elapsed, [0, 0.6], [0, 1]);
+  const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
+
+  const nodes = [
+    { title: 'HTML Deck', sub: 'source of truth', icon: 'code', delay: 0.4 },
+    { title: 'html2pptx.js', sub: 'read computedStyle', icon: 'scan', delay: 1.1, hi: true },
+    { title: 'pptxgenjs', sub: 'assemble objects', icon: 'compose', delay: 1.8 },
+    { title: 'deck.pptx', sub: 'editable output', icon: 'doc', delay: 2.5 },
+  ];
+
+  const cmdOp = interpolate(elapsed, [3.8, 4.4], [0, 1]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
+      padding: '72px 96px 56px', display:'flex', flexDirection:'column'}}>
+      <div style={{display:'flex', justifyContent:'space-between',
+        alignItems:'baseline', opacity: titleOp, marginBottom: 12}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
+            color: TERRA, marginBottom: 10}}>
+            <span>●</span>  SCENE 03 · EXPORT PIPELINE
+          </div>
+          <div style={{fontFamily: mono, fontSize: 64, fontWeight: 500,
+            color: INK, letterSpacing:'0.04em'}}>
+            导出流水线
+          </div>
+        </div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 20,
+          color: ASH, textAlign:'right', maxWidth: 380, lineHeight: 1.5}}>
+          把 DOM 翻译成<br/>
+          PowerPoint 对象图
+        </div>
+      </div>
+
+      <div style={{height: 1, background: INK, width: '100%', opacity: titleOp,
+        marginTop: 28, marginBottom: 48}} />
+
+      {/* Pipeline nodes */}
+      <div style={{display:'flex', alignItems:'stretch', gap: 0, flex: 1,
+        position:'relative'}}>
+        {nodes.map((n, i) => {
+          const op = interpolate(elapsed, [n.delay, n.delay + 0.5], [0, 1]);
+          const ty = interpolate(elapsed, [n.delay, n.delay + 0.5], [28, 0], Easing.easeOut);
+          return (
+            <React.Fragment key={i}>
+              <div style={{flex: 1, opacity: op, transform: `translateY(${ty}px)`,
+                background: n.hi ? TERRA : '#fff',
+                border: `1px solid ${n.hi ? TERRA : LINE}`,
+                padding:'28px 24px', display:'flex', flexDirection:'column',
+                color: n.hi ? '#fff' : INK}}>
+                <div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.25em',
+                  opacity: n.hi ? 0.85 : 0.5, marginBottom: 18}}>
+                  STEP {String(i+1).padStart(2, '0')}
+                </div>
+                <NodeIcon kind={n.icon} hi={n.hi} />
+                <div style={{fontFamily: mono, fontSize: 20, fontWeight: 500,
+                  marginTop: 20, letterSpacing:'0.01em'}}>
+                  {n.title}
+                </div>
+                <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
+                  opacity: n.hi ? 0.85 : 0.6, marginTop: 6}}>
+                  {n.sub}
+                </div>
+              </div>
+              {i < nodes.length - 1 && (
+                <ArrowBetween elapsed={elapsed} startTime={n.delay + 0.4} />
+              )}
+            </React.Fragment>
+          );
+        })}
+      </div>
+
+      {/* Data flow caption */}
+      <div style={{marginTop: 36, display:'flex', alignItems:'center', gap: 24,
+        opacity: interpolate(elapsed, [3.2, 3.8], [0, 1])}}>
+        <div style={{fontFamily: mono, fontSize: 13, color: ASH,
+          letterSpacing:'0.05em', flex: 1}}>
+          <span style={{color: OLIVE}}>DOM node</span> <span style={{color: TERRA}}>→</span>{' '}
+          <span style={{color: INK}}>{'{ type, text, font, color, x, y }'}</span>
+          <span style={{color: ASH, margin:'0 14px'}}>·</span>
+          <span style={{color: TERRA}}>→</span> <span style={{color: INK}}>slide.addText(...) / slide.addShape(...)</span>
+        </div>
+      </div>
+
+      {/* Command subtitle */}
+      <div style={{marginTop: 22, opacity: cmdOp,
+        background:'#1a1a1a', padding:'16px 24px',
+        borderLeft: `3px solid ${TERRA}`,
+        display:'flex', alignItems:'center', gap: 16}}>
+        <div style={{fontFamily: mono, fontSize: 12, color: TERRA,
+          letterSpacing:'0.2em'}}>$</div>
+        <div style={{fontFamily: mono, fontSize: 15, color: '#f5f0e6',
+          letterSpacing:'0.02em'}}>
+          node export_deck_pptx.mjs deck.html <span style={{color: '#8ca577'}}>--mode editable</span>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function NodeIcon({ kind, hi }) {
+  const fg = hi ? '#fff' : INK;
+  const bg = hi ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.04)';
+  if (kind === 'code') {
+    return (
+      <div style={{width: 72, height: 72, background: bg,
+        display:'flex', alignItems:'center', justifyContent:'center'}}>
+        <svg width="34" height="34" viewBox="0 0 34 34" fill="none">
+          <path d="M12 10 L5 17 L12 24" stroke={fg} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
+          <path d="M22 10 L29 17 L22 24" stroke={fg} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
+          <path d="M19 7 L15 27" stroke={fg} strokeWidth="2" strokeLinecap="round"/>
+        </svg>
+      </div>
+    );
+  }
+  if (kind === 'scan') {
+    return (
+      <div style={{width: 72, height: 72, background: bg,
+        display:'flex', alignItems:'center', justifyContent:'center'}}>
+        <svg width="38" height="38" viewBox="0 0 38 38" fill="none">
+          <rect x="6" y="6" width="26" height="26" stroke={fg} strokeWidth="2"/>
+          <line x1="6" y1="15" x2="32" y2="15" stroke={fg} strokeWidth="1.5"/>
+          <line x1="6" y1="23" x2="32" y2="23" stroke={fg} strokeWidth="1.5"/>
+          <line x1="15" y1="6" x2="15" y2="32" stroke={fg} strokeWidth="1.5"/>
+          <line x1="23" y1="6" x2="23" y2="32" stroke={fg} strokeWidth="1.5"/>
+          <circle cx="19" cy="19" r="3" fill={fg}/>
+        </svg>
+      </div>
+    );
+  }
+  if (kind === 'compose') {
+    return (
+      <div style={{width: 72, height: 72, background: bg,
+        display:'flex', alignItems:'center', justifyContent:'center'}}>
+        <svg width="38" height="38" viewBox="0 0 38 38" fill="none">
+          <rect x="4" y="4" width="16" height="12" stroke={fg} strokeWidth="2"/>
+          <rect x="22" y="4" width="12" height="12" stroke={fg} strokeWidth="2"/>
+          <rect x="4" y="20" width="12" height="14" stroke={fg} strokeWidth="2"/>
+          <rect x="18" y="20" width="16" height="14" stroke={fg} strokeWidth="2"/>
+        </svg>
+      </div>
+    );
+  }
+  // doc
+  return (
+    <div style={{width: 72, height: 72, background: bg,
+      display:'flex', alignItems:'center', justifyContent:'center'}}>
+      <svg width="34" height="38" viewBox="0 0 34 38" fill="none">
+        <path d="M6 4 H22 L28 10 V34 H6 Z" stroke={fg} strokeWidth="2" strokeLinejoin="round"/>
+        <path d="M22 4 V10 H28" stroke={fg} strokeWidth="2" strokeLinejoin="round"/>
+        <line x1="11" y1="17" x2="23" y2="17" stroke={fg} strokeWidth="1.5"/>
+        <line x1="11" y1="22" x2="23" y2="22" stroke={fg} strokeWidth="1.5"/>
+        <line x1="11" y1="27" x2="19" y2="27" stroke={fg} strokeWidth="1.5"/>
+      </svg>
+    </div>
+  );
+}
+
+function ArrowBetween({ elapsed, startTime }) {
+  const reveal = interpolate(elapsed, [startTime, startTime + 0.3], [0, 1]);
+  return (
+    <div style={{width: 48, display:'flex', alignItems:'center',
+      justifyContent:'center', position:'relative'}}>
+      <svg width="48" height="24" viewBox="0 0 48 24" style={{opacity: reveal}}>
+        <line x1="0" y1="12" x2={34 * reveal + 8} y2="12" stroke={TERRA} strokeWidth="1.5"/>
+        {reveal > 0.6 && (
+          <path d="M38 6 L44 12 L38 18" stroke={TERRA} strokeWidth="1.5" fill="none"
+            strokeLinecap="round" strokeLinejoin="round"/>
+        )}
+      </svg>
+    </div>
+  );
+}
+
+// ══════════════════════════════════════════════════════════
+// Scene 4 (15 – 20s) · 产物:可编辑文本框
+// ══════════════════════════════════════════════════════════
+function Scene4_PPTEdit() {
+  const { elapsed } = useSprite();
+  const fadeIn = interpolate(elapsed, [0, 0.5], [0, 1]);
+  const pptScale = interpolate(elapsed, [0, 0.8], [0.94, 1], Easing.easeOut);
+
+  // Selection bounding box appears at 0.8s, handles animate in staggered
+  const selectOp = interpolate(elapsed, [0.9, 1.3], [0, 1]);
+
+  // Format panel slides in from right at 1.5s
+  const panelX = interpolate(elapsed, [1.6, 2.4], [80, 0], Easing.easeOut);
+  const panelOp = interpolate(elapsed, [1.6, 2.4], [0, 1]);
+
+  // Caption fades in 2.4s
+  const captionOp = interpolate(elapsed, [2.4, 3.0], [0, 1]);
+
+  // Checkboxes tick in sequentially
+  const chk1 = elapsed > 3.2 ? 1 : 0;
+  const chk2 = elapsed > 3.7 ? 1 : 0;
+  const chk3 = elapsed > 4.2 ? 1 : 0;
+
+  const fadeOut = interpolate(elapsed, [4.6, 5.0], [1, 0]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM,
+      opacity: fadeIn * fadeOut,
+      display:'flex', flexDirection:'column', alignItems:'center',
+      padding:'60px 60px 40px'}}>
+      <div style={{width:'100%', display:'flex', justifyContent:'space-between',
+        alignItems:'baseline', marginBottom: 20}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
+            color: TERRA, marginBottom: 8}}>
+            <span>●</span>  SCENE 04 · THE ARTIFACT
+          </div>
+          <div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK,
+            letterSpacing:'-0.01em'}}>
+            产物:可编辑文本框
+          </div>
+        </div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
+          textAlign:'right', maxWidth: 340, lineHeight: 1.5}}>
+          在 PowerPoint 里<br/>
+          像素级复现,字还是字
+        </div>
+      </div>
+
+      <div style={{position:'relative', transform: `scale(${pptScale})`,
+        transformOrigin:'center center'}}>
+        <PPTMockup selectOp={selectOp} />
+
+        {/* Format panel */}
+        <div style={{position:'absolute', top: 94, right: -296,
+          width: 272, background:'#f5f2ed', border:`1px solid ${LINE}`,
+          boxShadow:'0 12px 30px rgba(0,0,0,0.08)',
+          transform: `translateX(${panelX}px)`, opacity: panelOp,
+          padding: 0}}>
+          <FormatPanel />
+        </div>
+      </div>
+
+      <div style={{marginTop: 28, display:'flex', alignItems:'center', gap: 48,
+        opacity: captionOp}}>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24,
+          color: TERRA, letterSpacing:'0.01em'}}>
+          原生 PowerPoint 文本框 · 不是图片
+        </div>
+        <div style={{display:'flex', gap: 28, fontFamily: mono, fontSize: 13}}>
+          <CheckRow label="文字可编辑" on={chk1} />
+          <CheckRow label="字体保留" on={chk2} />
+          <CheckRow label="位置/颜色精确" on={chk3} />
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function CheckRow({ label, on }) {
+  return (
+    <div style={{display:'flex', alignItems:'center', gap: 8}}>
+      <div style={{width: 18, height: 18, border:`1.5px solid ${on ? TERRA : LINE}`,
+        background: on ? TERRA : 'transparent',
+        display:'flex', alignItems:'center', justifyContent:'center',
+        transition:'none'}}>
+        {on ? (
+          <svg width="12" height="12" viewBox="0 0 12 12">
+            <path d="M2 6 L5 9 L10 3" stroke="#fff" strokeWidth="2" fill="none"
+              strokeLinecap="round" strokeLinejoin="round"/>
+          </svg>
+        ) : null}
+      </div>
+      <span style={{color: on ? INK : ASH}}>{label}</span>
+    </div>
+  );
+}
+
+function PPTMockup({ selectOp }) {
+  const W = 1100, H = 620;
+  return (
+    <div style={{width: W, height: H, background:'#f4f1ec',
+      border:`1px solid ${LINE}`, boxShadow:'0 22px 50px rgba(0,0,0,0.14)',
+      display:'flex', flexDirection:'column'}}>
+      {/* PPT ribbon (title bar + tabs) */}
+      <div style={{height: 32, background:'#dcd7cd', display:'flex',
+        alignItems:'center', padding:'0 14px', gap: 8,
+        borderBottom:`1px solid ${LINE}`}}>
+        <div style={{width:10, height:10, borderRadius:'50%', background:'#ff5f57'}} />
+        <div style={{width:10, height:10, borderRadius:'50%', background:'#febc2e'}} />
+        <div style={{width:10, height:10, borderRadius:'50%', background:'#28c840'}} />
+        <div style={{flex: 1, textAlign:'center', fontFamily: sans, fontSize: 11,
+          color: ASH, letterSpacing:'0.02em'}}>
+          deck.pptx — PowerPoint
+        </div>
+      </div>
+      <div style={{height: 34, background:'#ebe7de', display:'flex',
+        alignItems:'center', padding:'0 18px', gap: 22,
+        fontFamily: sans, fontSize: 11, color: INK,
+        borderBottom:`1px solid ${LINE}`}}>
+        <span style={{color: TERRA, fontWeight: 600,
+          borderBottom: `2px solid ${TERRA}`, paddingBottom: 6,
+          marginBottom: -7}}>Home</span>
+        <span style={{opacity: 0.55}}>Insert</span>
+        <span style={{opacity: 0.55}}>Design</span>
+        <span style={{opacity: 0.55}}>Transitions</span>
+        <span style={{opacity: 0.55}}>Animations</span>
+        <span style={{opacity: 0.55}}>Slide Show</span>
+        <span style={{opacity: 0.55}}>Review</span>
+        <span style={{opacity: 0.55}}>View</span>
+      </div>
+
+      {/* Body: slide panel (left) + slide canvas (main) */}
+      <div style={{flex: 1, display:'flex'}}>
+        {/* Thumbnails */}
+        <div style={{width: 160, background:'#eae5db',
+          borderRight:`1px solid ${LINE}`, padding:'12px 12px',
+          display:'flex', flexDirection:'column', gap: 8}}>
+          {[0,1,2,3].map(i => (
+            <div key={i} style={{
+              background:'#fff',
+              border: i === 2 ? `2px solid ${TERRA}` : `1px solid ${LINE}`,
+              aspectRatio:'16/9', position:'relative',
+              padding: 8, display:'flex', alignItems:'center',
+              justifyContent:'center'}}>
+              <div style={{position:'absolute', top: 4, left: 4,
+                fontFamily: mono, fontSize: 8, color: ASH}}>{i+1}</div>
+              {i === 2 && (
+                <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 12,
+                  color: INK}}>"Less..."</div>
+              )}
+              {i !== 2 && (
+                <div style={{width:'70%', height: 3, background: LINE}} />
+              )}
+            </div>
+          ))}
+        </div>
+
+        {/* Slide canvas */}
+        <div style={{flex: 1, background:'#e8e4dc', display:'flex',
+          alignItems:'center', justifyContent:'center', padding: 32,
+          position:'relative'}}>
+          <div style={{width: 720, height: 405, background:'#faf6ef',
+            boxShadow:'0 8px 24px rgba(0,0,0,0.1)',
+            border:`1px solid ${LINE}`, position:'relative'}}>
+            {/* The editable text box */}
+            <div style={{position:'absolute', top:'50%', left:'50%',
+              transform:'translate(-50%, -50%)', textAlign:'center',
+              padding:'18px 40px'}}>
+              <div style={{fontFamily: serif, fontStyle:'italic',
+                fontSize: 72, color: INK, lineHeight: 1.1,
+                letterSpacing:'-0.01em'}}>
+                "Less, but <span style={{color: TERRA}}>better</span>."
+              </div>
+              <div style={{fontFamily: serif, fontSize: 14, color: ASH,
+                marginTop: 14, letterSpacing:'0.1em'}}>
+                — Dieter Rams
+              </div>
+            </div>
+            {/* Selection bounding box + 8 handles */}
+            {selectOp > 0 && <SelectionBox opacity={selectOp} />}
+
+            {/* slide number */}
+            <div style={{position:'absolute', bottom: 10, right: 14,
+              fontFamily: sans, fontSize: 9, color: ASH}}>3</div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function SelectionBox({ opacity }) {
+  // Box centered around the textbox (~ 520×160)
+  const BW = 560, BH = 170;
+  const color = '#4a9eff';
+  const handles = [
+    { x: 0, y: 0 }, { x: 0.5, y: 0 }, { x: 1, y: 0 },
+    { x: 0, y: 0.5 }, { x: 1, y: 0.5 },
+    { x: 0, y: 1 }, { x: 0.5, y: 1 }, { x: 1, y: 1 },
+  ];
+  return (
+    <div style={{position:'absolute', top:'50%', left:'50%',
+      width: BW, height: BH, transform:'translate(-50%, -50%)',
+      border: `1.5px solid ${color}`, opacity,
+      boxShadow:`0 0 0 1px rgba(255,255,255,0.6)`, pointerEvents:'none'}}>
+      {handles.map((h, i) => (
+        <div key={i} style={{position:'absolute',
+          left: `${h.x * 100}%`, top: `${h.y * 100}%`,
+          transform:'translate(-50%, -50%)',
+          width: 10, height: 10, background:'#fff',
+          border: `1.5px solid ${color}`, borderRadius: 2}} />
+      ))}
+      {/* Rotate handle */}
+      <div style={{position:'absolute', left:'50%', top: -26,
+        transform:'translateX(-50%)',
+        width: 10, height: 10, background:'#fff',
+        border: `1.5px solid ${color}`, borderRadius: '50%'}} />
+      <div style={{position:'absolute', left:'50%', top: -17,
+        transform:'translateX(-50%)', width: 1, height: 9, background: color}} />
+      {/* Label: Text Box */}
+      <div style={{position:'absolute', top: -26, left: 0,
+        fontFamily: mono, fontSize: 10, color: color,
+        background:'rgba(255,255,255,0.9)', padding:'2px 6px',
+        letterSpacing:'0.1em'}}>
+        TEXT BOX · shape #1
+      </div>
+    </div>
+  );
+}
+
+function FormatPanel() {
+  return (
+    <div>
+      <div style={{padding:'12px 16px', borderBottom:`1px solid ${LINE}`,
+        display:'flex', justifyContent:'space-between', alignItems:'center',
+        background:'#ebe7de'}}>
+        <div style={{fontFamily: sans, fontSize: 12, color: INK,
+          fontWeight: 600}}>Format Text</div>
+        <div style={{fontFamily: mono, fontSize: 10, color: ASH}}>✕</div>
+      </div>
+      <div style={{padding:'16px'}}>
+        <div style={{fontFamily: sans, fontSize: 10, color: ASH,
+          letterSpacing:'0.15em', marginBottom: 8}}>FONT</div>
+        <div style={{background:'#fff', border:`1px solid ${LINE}`,
+          padding:'8px 10px', marginBottom: 14,
+          display:'flex', justifyContent:'space-between', alignItems:'center',
+          fontFamily: sans, fontSize: 12, color: INK}}>
+          <span style={{fontFamily: serif, fontStyle:'italic'}}>Newsreader</span>
+          <span style={{color: ASH, fontSize: 10}}>▾</span>
+        </div>
+        <div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap: 10,
+          marginBottom: 14}}>
+          <div>
+            <div style={{fontFamily: sans, fontSize: 10, color: ASH,
+              letterSpacing:'0.15em', marginBottom: 6}}>SIZE</div>
+            <div style={{background:'#fff', border:`1px solid ${LINE}`,
+              padding:'6px 10px', fontFamily: sans, fontSize: 12}}>72 pt</div>
+          </div>
+          <div>
+            <div style={{fontFamily: sans, fontSize: 10, color: ASH,
+              letterSpacing:'0.15em', marginBottom: 6}}>WEIGHT</div>
+            <div style={{background:'#fff', border:`1px solid ${LINE}`,
+              padding:'6px 10px', fontFamily: sans, fontSize: 12}}>400 · italic</div>
+          </div>
+        </div>
+        <div style={{fontFamily: sans, fontSize: 10, color: ASH,
+          letterSpacing:'0.15em', marginBottom: 6}}>COLOR</div>
+        <div style={{display:'flex', gap: 8, marginBottom: 14}}>
+          <div style={{width: 28, height: 28, background: INK, border:`2px solid ${INK}`}} />
+          <div style={{width: 28, height: 28, background: TERRA,
+            outline:`2px solid ${TERRA}`, outlineOffset: 1}} />
+          <div style={{width: 28, height: 28, background: OLIVE}} />
+          <div style={{width: 28, height: 28, background: DEEP_BLUE}} />
+          <div style={{width: 28, height: 28, background:'#fff', border:`1px solid ${LINE}`}} />
+        </div>
+        <div style={{fontFamily: mono, fontSize: 10, color: ASH,
+          letterSpacing:'0.1em', lineHeight: 1.6,
+          paddingTop: 10, borderTop:`1px solid ${LINE}`}}>
+          x: 2.4in · y: 2.1in<br/>
+          w: 5.8in · h: 1.7in
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ══════════════════════════════════════════════════════════
+// Scene 5 (20 – 24s) · 收尾
+// ══════════════════════════════════════════════════════════
+function Scene5_Final() {
+  const { elapsed } = useSprite();
+  const tagOp = interpolate(elapsed, [0, 0.5], [0, 1]);
+  const mainY = interpolate(elapsed, [0.2, 1.2], [50, 0], Easing.easeOut);
+  const mainOp = interpolate(elapsed, [0.2, 1.0], [0, 1]);
+  const lineW = interpolate(elapsed, [1.1, 1.8], [0, 540]);
+  const subOp = interpolate(elapsed, [1.5, 2.2], [0, 1]);
+  const monoOp = interpolate(elapsed, [2.2, 2.8], [0, 1]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM,
+      display:'flex', alignItems:'center', justifyContent:'center',
+      flexDirection:'column'}}>
+      <div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
+        color: TERRA, marginBottom: 32, opacity: tagOp}}>
+        ONE SOURCE · TWO STATES
+      </div>
+      <div style={{fontFamily: serif, fontSize: 220, fontWeight: 500,
+        color: INK, lineHeight: 0.98, letterSpacing:'-0.03em',
+        opacity: mainOp, transform: `translateY(${mainY}px)`}}>
+        一<span style={{color: ASH, fontStyle:'italic'}}>源</span>
+        <span style={{color: TERRA, margin:'0 28px'}}>·</span>
+        双<span style={{color: ASH, fontStyle:'italic'}}>态</span>
+      </div>
+      <div style={{height: 1, background: INK, width: lineW, marginTop: 46}} />
+      <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 28,
+        color: ASH, marginTop: 28, opacity: subOp, letterSpacing:'0.02em'}}>
+        浏览器里演讲  ·  PowerPoint 里二次编辑
+      </div>
+      <div style={{fontFamily: mono, fontSize: 18, color: INK, marginTop: 34,
+        opacity: monoOp, letterSpacing:'0.1em',
+        padding:'12px 28px', background:'#fff', border:`1px solid ${LINE}`}}>
+        <span style={{color: OLIVE}}>deck.html</span>
+        <span style={{color: TERRA, margin:'0 14px'}}>⇌</span>
+        <span style={{color: DEEP_BLUE}}>deck.pptx</span>
+      </div>
+    </div>
+  );
+}
+
+// ── Watermark ─────────────────────────────────────────────
+function Watermark() {
+  return (
+    <div style={{position:'absolute', bottom: 24, right: 32,
+      fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
+      fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
+      Created by Huashu-Design
+    </div>
+  );
+}
+
+// ── Main composition ──────────────────────────────────────
+function App() {
+  return (
+    <Stage duration={24} width={1920} height={1080} bgColor={CREAM}>
+      <Sprite start={0} end={3}><Scene1_Title /></Sprite>
+      <Sprite start={3} end={9}><Scene2_DeckFlip /></Sprite>
+      <Sprite start={9} end={15}><Scene3_Pipeline /></Sprite>
+      <Sprite start={15} end={20}><Scene4_PPTEdit /></Sprite>
+      <Sprite start={20} end={24}><Scene5_Final /></Sprite>
+      <Watermark />
+    </Stage>
+  );
+}
+
+ReactDOM.createRoot(document.getElementById('root')).render(<App />);
+</script>
+</body>
+</html>

+ 556 - 0
demos/c3-motion-design.html

@@ -0,0 +1,556 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<title>Huashu-Design · Motion Design</title>
+<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
+<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
+<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@400;500;600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
+<style>
+  * { box-sizing: border-box; margin: 0; padding: 0; }
+  html, body { width: 100%; height: 100%; overflow: hidden; }
+  body { background: #0c0c0c; font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif; color: #1a1a1a; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
+</style>
+</head>
+<body>
+<div id="root"></div>
+
+<!-- animations.jsx inlined -->
+<script type="text/babel">
+(function() {
+  const { createContext, useContext, useState, useEffect, useRef } = React;
+  const TimeContext = createContext({ time: 0, duration: 10, playing: false });
+  const SpriteContext = createContext(null);
+  const Easing = {
+    linear: t => t,
+    easeIn: t => t * t,
+    easeOut: t => 1 - (1 - t) * (1 - t),
+    easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
+    spring: t => {
+      const c = (2 * Math.PI) / 3;
+      return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
+    },
+  };
+  function interpolate(t, input, output, easing) {
+    const [a, b] = input, [x, y] = output;
+    if (t <= a) return x; if (t >= b) return y;
+    let p = (t - a) / (b - a); if (easing) p = easing(p);
+    return x + (y - x) * p;
+  }
+  function useTime() { return useContext(TimeContext).time; }
+  function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
+  function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
+    const [time, setTime] = useState(0);
+    const [playing, setPlaying] = useState(true);
+    const [scale, setScale] = useState(1);
+    const rafRef = useRef(null);
+    const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
+    useEffect(() => {
+      const update = () => {
+        const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
+        setScale(s);
+      };
+      update(); window.addEventListener('resize', update);
+      return () => window.removeEventListener('resize', update);
+    }, [width, height]);
+    useEffect(() => {
+      if (!playing) return;
+      let cancelled = false, last = null;
+      function tick(now) {
+        if (cancelled) return;
+        if (last === null) { last = now; if (typeof window !== 'undefined') window.__ready = true; }
+        const delta = (now - last) / 1000; last = now;
+        setTime(prev => {
+          const next = prev + delta;
+          if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
+          return next;
+        });
+        rafRef.current = requestAnimationFrame(tick);
+      }
+      const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
+      if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
+      return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
+    }, [playing, duration, effectiveLoop]);
+    const progress = time / duration;
+    const ctx = { time, duration, playing, setPlaying, setTime };
+    return (
+      <TimeContext.Provider value={ctx}>
+        <div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
+          <div style={{flex:1, position:'relative', overflow:'hidden'}}>
+            <div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
+              {children}
+            </div>
+          </div>
+          <div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
+            <button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
+            <button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
+            <div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
+            <div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
+              <div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
+            </div>
+          </div>
+        </div>
+      </TimeContext.Provider>
+    );
+  }
+  function Sprite({ start = 0, end, children, style }) {
+    const { time } = useContext(TimeContext);
+    const actualEnd = end == null ? Infinity : end;
+    if (time < start || time >= actualEnd) return null;
+    const duration = actualEnd - start;
+    const elapsed = time - start;
+    const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
+    return (
+      <SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
+        <div style={{position:'absolute', inset:0, ...style}}>{children}</div>
+      </SpriteContext.Provider>
+    );
+  }
+  window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
+})();
+</script>
+
+<!-- Demo scene -->
+<script type="text/babel">
+const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
+
+const CREAM = '#FAF6EF';
+const INK = '#1a1a1a';
+const TERRA = '#C04A1A';
+const OLIVE = '#6a6b4e';
+const DEEP_BLUE = '#2a3552';
+const ASH = '#6b6b6b';
+const LINE = '#d9d2c5';
+
+const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
+const sans = "'Inter', -apple-system, sans-serif";
+const mono = "'JetBrains Mono', ui-monospace, monospace";
+
+// ── Scene 1: Title (0 – 3s) ────────────────────────────
+function Scene1_Title() {
+  const { elapsed } = useSprite();
+  const titleY = interpolate(elapsed, [0, 1.2], [60, 0], Easing.easeOut);
+  const titleOp = interpolate(elapsed, [0, 0.8], [0, 1]);
+  const subOp = interpolate(elapsed, [0.6, 1.4], [0, 1]);
+  const lineW = interpolate(elapsed, [0.9, 1.6], [0, 520]);
+  const apiOp = interpolate(elapsed, [1.4, 2.2], [0, 1]);
+  const fadeOut = interpolate(elapsed, [2.6, 3.0], [1, 0]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
+      display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
+      <div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
+        color: TERRA, marginBottom: 24, opacity: titleOp}}>
+        动画引擎 · Stage + Sprite
+      </div>
+      <div style={{fontFamily: serif, fontSize: 148, fontWeight: 500, color: INK,
+        lineHeight: 1, letterSpacing:'-0.02em', opacity: titleOp,
+        transform: `translateY(${titleY}px)`}}>
+        <span style={{fontStyle:'italic', color: TERRA}}>Motion</span> Design
+      </div>
+      <div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
+      <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24, color: ASH,
+        marginTop: 28, opacity: subOp}}>
+        时间驱动 · 可编排 · 60fps 导出
+      </div>
+      <div style={{fontFamily: mono, fontSize: 14, color: ASH,
+        marginTop: 40, opacity: apiOp, letterSpacing:'0.1em'}}>
+        &lt;Stage&gt; · &lt;Sprite&gt; · useTime() · useSprite() · interpolate() · Easing
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 2: Easing functions comparison (3 – 8s) ────────
+function Scene2_Easing() {
+  const { elapsed } = useSprite();
+  const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
+  const fadeOut = interpolate(elapsed, [4.6, 5.0], [1, 0]);
+
+  // Lane sweep cycle every 2s
+  const cycle = (elapsed % 2.2) / 2.0;
+  const sweepT = Math.min(1, Math.max(0, cycle));
+
+  const curves = [
+    { name: 'linear', label: 'linear', fn: Easing.linear, color: ASH },
+    { name: 'easeOut', label: 'easeOut', fn: Easing.easeOut, color: OLIVE },
+    { name: 'spring', label: 'spring', fn: Easing.spring, color: TERRA },
+    { name: 'easeInOut', label: 'easeInOut', fn: Easing.easeInOut, color: DEEP_BLUE },
+  ];
+
+  const trackLeft = 320;
+  const trackRight = 1480;
+  const trackLen = trackRight - trackLeft;
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
+      padding: '80px 100px', display:'flex', flexDirection:'column'}}>
+      <div style={{opacity: titleOp, marginBottom: 50,
+        display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+            letterSpacing:'0.3em', marginBottom: 6}}>场景 1 · EASING</div>
+          <div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
+            letterSpacing:'-0.01em'}}>
+            四种缓动曲线同跑
+          </div>
+        </div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
+          textAlign:'right'}}>
+          同样的 2 秒,<br/>
+          走出四种不同的「节奏感」
+        </div>
+      </div>
+
+      <div style={{flex: 1, position:'relative'}}>
+        {curves.map((c, i) => {
+          const y = 80 + i * 140;
+          const t = c.fn(sweepT);
+          const x = trackLeft + trackLen * t;
+          // Draw the curve as a mini sparkline right of track
+          const sparkW = 160, sparkH = 50;
+          const sparkPts = Array.from({length: 30}, (_, k) => {
+            const tx = k / 29;
+            const ty = 1 - c.fn(tx);
+            return `${tx * sparkW},${ty * sparkH}`;
+          }).join(' ');
+          return (
+            <div key={i}>
+              {/* Label (left) */}
+              <div style={{position:'absolute', left: 0, top: y - 22, width: 280,
+                fontFamily: mono, fontSize: 14, color: INK, letterSpacing:'0.05em'}}>
+                <span style={{color: c.color, marginRight: 12}}>●</span>
+                Easing.<span style={{color: c.color}}>{c.label}</span>
+              </div>
+              {/* Track */}
+              <div style={{position:'absolute', left: trackLeft, top: y,
+                width: trackLen, height: 2, background: LINE}} />
+              {/* Dot */}
+              <div style={{position:'absolute', left: x - 14, top: y - 14,
+                width: 28, height: 28, borderRadius: '50%',
+                background: c.color,
+                boxShadow: `0 4px 12px ${c.color}55`}} />
+              {/* Sparkline */}
+              <svg style={{position:'absolute', left: trackRight + 60, top: y - sparkH/2 - 5,
+                width: sparkW, height: sparkH}}>
+                <polyline points={sparkPts} stroke={c.color} strokeWidth="1.5" fill="none" />
+                <circle cx={sweepT * sparkW} cy={(1 - c.fn(sweepT)) * sparkH}
+                  r="3.5" fill={c.color} />
+              </svg>
+            </div>
+          );
+        })}
+      </div>
+
+      {/* Timeline at bottom */}
+      <div style={{marginTop: 10, position:'relative', height: 30}}>
+        <div style={{fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.15em'}}>
+          t = <span style={{color: INK}}>{sweepT.toFixed(2)}</span> &nbsp; · &nbsp; 周期 2.0s
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 3: interpolate() function demo (8 – 14s) ───────
+function Scene3_Interpolate() {
+  const { elapsed } = useSprite();
+  const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
+  const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
+
+  // Animated t value: 0→1 cycle over ~3s
+  const cycle = (elapsed % 3.2) / 3.0;
+  const t = Math.min(1, Math.max(0, cycle));
+
+  // Three mapped outputs from same t:
+  const opacity = interpolate(t, [0, 1], [0, 1]);
+  const scale = interpolate(t, [0, 1], [0.4, 1.2], Easing.spring);
+  const rotation = interpolate(t, [0, 1], [-30, 30], Easing.easeInOut);
+  const translateX = interpolate(t, [0, 0.5, 1], [-80, 40, 0]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
+      padding: '80px 100px', display:'flex', flexDirection:'column'}}>
+      <div style={{opacity: titleOp, marginBottom: 40,
+        display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+            letterSpacing:'0.3em', marginBottom: 6}}>场景 2 · INTERPOLATE</div>
+          <div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
+            letterSpacing:'-0.01em'}}>
+            一个 <span style={{fontStyle:'italic', color: TERRA}}>t</span>,四种变化
+          </div>
+        </div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
+          textAlign:'right'}}>
+          用同一条时间轴,<br/>
+          映射出透明度、尺寸、旋转、位移
+        </div>
+      </div>
+
+      {/* t value progress bar */}
+      <div style={{background:'#fff', border: `1px solid ${LINE}`,
+        padding: '20px 32px', marginBottom: 30}}>
+        <div style={{display:'flex', justifyContent:'space-between',
+          alignItems:'baseline', marginBottom: 14}}>
+          <div style={{fontFamily: mono, fontSize: 13, color: INK}}>
+            <span style={{color: ASH}}>const</span> t = <span style={{color: TERRA}}>{t.toFixed(3)}</span>
+          </div>
+          <div style={{fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.15em'}}>
+            时间 → 0 到 1
+          </div>
+        </div>
+        <div style={{height: 4, background: LINE, position:'relative'}}>
+          <div style={{position:'absolute', top:0, left:0, height:'100%',
+            width: `${t * 100}%`, background: TERRA}} />
+        </div>
+      </div>
+
+      {/* Four demos */}
+      <div style={{flex: 1, display:'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: 24}}>
+        {[
+          { name: 'opacity', code: 'interpolate(t, [0,1], [0,1])', val: opacity, render:
+            <div style={{width: 120, height: 120, background: TERRA, opacity}} /> },
+          { name: 'scale + spring', code: 'interpolate(t, [0,1], [0.4,1.2], spring)', val: scale, render:
+            <div style={{width: 120, height: 120, background: OLIVE,
+              transform: `scale(${scale})`}} /> },
+          { name: 'rotate', code: 'interpolate(t, [0,1], [-30,30], easeInOut)', val: rotation, render:
+            <div style={{width: 120, height: 120, background: DEEP_BLUE,
+              transform: `rotate(${rotation}deg)`}} /> },
+          { name: 'translateX (3 stops)', code: 'interpolate(t, [0,.5,1], [-80,40,0])', val: translateX, render:
+            <div style={{width: 120, height: 120, background: INK,
+              transform: `translateX(${translateX}px)`}} /> },
+        ].map((d, i) => (
+          <div key={i} style={{background:'#fff', border:`1px solid ${LINE}`,
+            padding: '18px 18px 14px', display:'flex', flexDirection:'column'}}>
+            <div style={{fontFamily: mono, fontSize: 9, color: TERRA,
+              letterSpacing:'0.2em', marginBottom: 6}}>0{i+1}</div>
+            <div style={{fontFamily: serif, fontSize: 18, fontWeight: 500, color: INK,
+              marginBottom: 6}}>{d.name}</div>
+            <div style={{fontFamily: mono, fontSize: 10, color: ASH,
+              marginBottom: 20, minHeight: 32, wordBreak:'break-all', lineHeight: 1.5}}>
+              {d.code}
+            </div>
+            <div style={{flex: 1, display:'flex', alignItems:'center',
+              justifyContent:'center', overflow:'hidden'}}>
+              {d.render}
+            </div>
+            <div style={{fontFamily: mono, fontSize: 10, color: ASH,
+              marginTop: 12, textAlign:'right'}}>
+              = <span style={{color: TERRA}}>{d.val.toFixed(2)}</span>
+            </div>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 4: Sprite sequencing on timeline (14 – 20s) ───
+function Scene4_Sprite() {
+  const { elapsed } = useSprite();
+  const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
+  const fadeOut = interpolate(elapsed, [5.6, 6.0], [1, 0]);
+
+  // Timeline plays 6s
+  const localTime = Math.min(elapsed, 5.6);
+
+  const sprites = [
+    { name: 'Title',  start: 0.0, end: 2.5, color: TERRA,   y: 0, label: '标题' },
+    { name: 'Image',  start: 0.8, end: 3.5, color: OLIVE,   y: 1, label: '图像淡入' },
+    { name: 'Text',   start: 1.8, end: 4.5, color: DEEP_BLUE, y: 2, label: '正文' },
+    { name: 'Outro',  start: 4.0, end: 5.5, color: '#8b4a2b', y: 3, label: '结尾' },
+  ];
+
+  const timelineLeft = 100;
+  const timelineRight = 1820;
+  const timelineW = timelineRight - timelineLeft;
+  const totalDur = 5.6;
+  const cursorX = timelineLeft + (localTime / totalDur) * timelineW;
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
+      padding: '80px 100px 60px', display:'flex', flexDirection:'column'}}>
+      <div style={{opacity: titleOp, marginBottom: 30,
+        display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+            letterSpacing:'0.3em', marginBottom: 6}}>场景 3 · SPRITE 编排</div>
+          <div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
+            letterSpacing:'-0.01em'}}>
+            时间片段 · <span style={{fontStyle:'italic'}}>同台起舞</span>
+          </div>
+        </div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
+          textAlign:'right'}}>
+          每个 &lt;Sprite start=... end=...&gt;<br/>
+          在自己的时间窗口出场、退场
+        </div>
+      </div>
+
+      {/* Live visualization area */}
+      <div style={{background:'#fff', border:`1px solid ${LINE}`, flex: 1,
+        position:'relative', overflow:'hidden', marginBottom: 30}}>
+        {sprites.map((s, i) => {
+          const active = localTime >= s.start && localTime < s.end;
+          if (!active) return null;
+          const localT = (localTime - s.start) / (s.end - s.start);
+          const op = interpolate(localT, [0, 0.15, 0.85, 1], [0, 1, 1, 0]);
+          const ty = interpolate(localT, [0, 0.2], [30, 0], Easing.easeOut);
+
+          if (s.name === 'Title') {
+            return (
+              <div key={i} style={{position:'absolute', top: 60, left: 80, right: 80,
+                opacity: op, transform: `translateY(${ty}px)`}}>
+                <div style={{fontFamily: mono, fontSize: 10, color: s.color,
+                  letterSpacing:'0.3em', marginBottom: 10}}>CHAPTER 01</div>
+                <div style={{fontFamily: serif, fontSize: 64, fontWeight: 500,
+                  color: INK, lineHeight: 1.05, letterSpacing:'-0.01em'}}>
+                  如何让动画 <span style={{fontStyle:'italic', color: s.color}}>好看</span>
+                </div>
+              </div>
+            );
+          }
+          if (s.name === 'Image') {
+            return (
+              <div key={i} style={{position:'absolute', top: 60, right: 80,
+                width: 380, height: 240, opacity: op,
+                transform: `translateY(${ty}px)`,
+                background: `linear-gradient(135deg, ${s.color}, ${s.color}88 50%, ${s.color}33)`,
+                overflow: 'hidden'}}>
+                <div style={{position:'absolute', inset: 0,
+                  background: `radial-gradient(circle at 30% 30%, ${s.color}aa, transparent 50%)`}} />
+                <div style={{position:'absolute', bottom: 14, left: 16,
+                  fontFamily: mono, fontSize: 9, color: '#fff',
+                  letterSpacing:'0.2em', opacity: 0.8}}>
+                  IMAGE · FADE-IN
+                </div>
+              </div>
+            );
+          }
+          if (s.name === 'Text') {
+            return (
+              <div key={i} style={{position:'absolute', bottom: 80, left: 80, right: 80,
+                opacity: op, transform: `translateY(${ty}px)`}}>
+                <div style={{fontFamily: mono, fontSize: 10, color: s.color,
+                  letterSpacing:'0.3em', marginBottom: 10}}>BODY</div>
+                <div style={{fontFamily: serif, fontSize: 20, color: INK,
+                  lineHeight: 1.55, maxWidth: 720}}>
+                  好的 motion 不是每个元素都在抢戏——是<span style={{fontStyle:'italic'}}>一个
+                  </span>进、<span style={{fontStyle:'italic'}}>一个</span>退、留足呼吸,
+                  最后合奏收尾。
+                </div>
+              </div>
+            );
+          }
+          if (s.name === 'Outro') {
+            return (
+              <div key={i} style={{position:'absolute', inset: 0,
+                opacity: op, display:'flex', alignItems:'center',
+                justifyContent:'center', flexDirection:'column'}}>
+                <div style={{fontFamily: serif, fontSize: 88, fontWeight: 500,
+                  color: s.color, fontStyle:'italic', letterSpacing:'-0.01em'}}>
+                  — fin —
+                </div>
+                <div style={{fontFamily: mono, fontSize: 11, color: ASH,
+                  letterSpacing:'0.3em', marginTop: 14}}>
+                  4 SPRITES · 5.5 SECONDS · 1 STAGE
+                </div>
+              </div>
+            );
+          }
+        })}
+      </div>
+
+      {/* Timeline viz (showing sprite spans) */}
+      <div style={{position:'relative', height: 110}}>
+        {/* Labels */}
+        {sprites.map((s, i) => (
+          <div key={i} style={{position:'absolute', left: 0, top: i * 22,
+            fontFamily: mono, fontSize: 10, color: ASH, letterSpacing:'0.05em'}}>
+            <span style={{color: s.color, marginRight: 8}}>●</span>{s.label}
+          </div>
+        ))}
+        {/* Tracks */}
+        {sprites.map((s, i) => {
+          const x0 = timelineLeft + (s.start / totalDur) * timelineW;
+          const x1 = timelineLeft + (s.end / totalDur) * timelineW;
+          const active = localTime >= s.start && localTime < s.end;
+          return (
+            <div key={i} style={{position:'absolute',
+              left: x0, top: i * 22 - 2, width: x1 - x0, height: 16,
+              background: active ? s.color : `${s.color}55`,
+              borderLeft: `2px solid ${s.color}`}} />
+          );
+        })}
+        {/* Playhead cursor */}
+        <div style={{position:'absolute', left: cursorX - 1, top: -6,
+          width: 2, height: 110, background: INK, zIndex: 5}} />
+        <div style={{position:'absolute', left: cursorX - 20, top: -20,
+          fontFamily: mono, fontSize: 10, color: INK,
+          letterSpacing:'0.1em'}}>
+          {localTime.toFixed(2)}s
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 5: Outro (20 – 22s) ─────────────────────────
+function Scene5_Outro() {
+  const { elapsed } = useSprite();
+  const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1], Easing.easeOut);
+  const lineW = interpolate(elapsed, [0.5, 1.3], [0, 620]);
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeIn,
+      display:'flex', alignItems:'center', justifyContent:'center',
+      flexDirection:'column'}}>
+      <div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
+        color: TERRA, marginBottom: 20}}>
+        导出 · MP4 / GIF / 60FPS / BGM
+      </div>
+      <div style={{fontFamily: serif, fontSize: 108, fontWeight: 500,
+        color: INK, lineHeight: 1, letterSpacing:'-0.015em'}}>
+        从 <span style={{fontStyle:'italic', color: TERRA}}>时间</span>,到成片
+      </div>
+      <div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
+      <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22, color: ASH,
+        marginTop: 26, maxWidth: 800, textAlign:'center', lineHeight: 1.55}}>
+        render-video.js · convert-formats.sh · add-music.sh<br/>
+        一条命令跑完,产出社交媒体可直接用的素材
+      </div>
+    </div>
+  );
+}
+
+// ── Watermark ──────────────────────────────────────────
+function Watermark() {
+  return (
+    <div style={{position:'absolute', bottom: 24, right: 32,
+      fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
+      fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
+      Created by Huashu-Design
+    </div>
+  );
+}
+
+function App() {
+  return (
+    <Stage duration={22} width={1920} height={1080} bgColor={CREAM}>
+      <Sprite start={0} end={3}><Scene1_Title /></Sprite>
+      <Sprite start={3} end={8}><Scene2_Easing /></Sprite>
+      <Sprite start={8} end={14}><Scene3_Interpolate /></Sprite>
+      <Sprite start={14} end={20}><Scene4_Sprite /></Sprite>
+      <Sprite start={20} end={22}><Scene5_Outro /></Sprite>
+      <Watermark />
+    </Stage>
+  );
+}
+
+ReactDOM.createRoot(document.getElementById('root')).render(<App />);
+</script>
+</body>
+</html>

+ 762 - 0
demos/c4-tweaks.html

@@ -0,0 +1,762 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<title>Huashu-Design · Tweaks 实时变体</title>
+<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
+<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
+<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
+<style>
+  * { box-sizing: border-box; margin: 0; padding: 0; }
+  html, body { width: 100%; height: 100%; overflow: hidden; }
+  body {
+    background: #0c0c0c;
+    font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
+    color: #1a1a1a;
+    -webkit-font-smoothing: antialiased;
+    text-rendering: optimizeLegibility;
+  }
+</style>
+</head>
+<body>
+<div id="root"></div>
+
+<!-- animations.jsx inlined -->
+<script type="text/babel">
+(function() {
+  const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
+  const TimeContext = createContext({ time: 0, duration: 10, playing: false });
+  const SpriteContext = createContext(null);
+
+  const Easing = {
+    linear: t => t,
+    easeIn: t => t * t,
+    easeOut: t => 1 - (1 - t) * (1 - t),
+    easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
+    spring: t => {
+      const c = (2 * Math.PI) / 3;
+      return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
+    },
+  };
+
+  function interpolate(t, input, output, easing) {
+    const [inStart, inEnd] = input;
+    const [outStart, outEnd] = output;
+    if (t <= inStart) return outStart;
+    if (t >= inEnd) return outEnd;
+    let progress = (t - inStart) / (inEnd - inStart);
+    if (easing) progress = easing(progress);
+    return outStart + (outEnd - outStart) * progress;
+  }
+
+  function useTime() { return useContext(TimeContext).time; }
+  function useSprite() {
+    const sprite = useContext(SpriteContext);
+    return sprite || { t: 0, elapsed: 0, duration: 0 };
+  }
+
+  function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
+    const [time, setTime] = useState(0);
+    const [playing, setPlaying] = useState(true);
+    const [scale, setScale] = useState(1);
+    const rafRef = useRef(null);
+    const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
+
+    useEffect(() => {
+      function updateScale() {
+        const vw = window.innerWidth;
+        const vh = window.innerHeight - 56;
+        const s = Math.min(vw / width, vh / height);
+        setScale(s);
+      }
+      updateScale();
+      window.addEventListener('resize', updateScale);
+      return () => window.removeEventListener('resize', updateScale);
+    }, [width, height]);
+
+    useEffect(() => {
+      if (!playing) return;
+      let cancelled = false;
+      let last = null;
+      function tick(now) {
+        if (cancelled) return;
+        if (last === null) {
+          last = now;
+          if (typeof window !== 'undefined') window.__ready = true;
+        }
+        const delta = (now - last) / 1000;
+        last = now;
+        setTime(prev => {
+          const next = prev + delta;
+          if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
+          return next;
+        });
+        rafRef.current = requestAnimationFrame(tick);
+      }
+      const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
+      if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
+      return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
+    }, [playing, duration, effectiveLoop]);
+
+    const progress = time / duration;
+    const ctx = { time, duration, playing, setPlaying, setTime };
+
+    const canvasStyle = {
+      position: 'absolute',
+      top: '50%',
+      left: '50%',
+      transformOrigin: 'center center',
+      width,
+      height,
+      background: bgColor,
+      overflow: 'hidden',
+      transform: `translate(-50%, -50%) scale(${scale})`,
+    };
+
+    return (
+      <TimeContext.Provider value={ctx}>
+        <div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
+          <div style={{flex:1, position:'relative', overflow:'hidden'}}>
+            <div style={canvasStyle}>{children}</div>
+          </div>
+          <div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', backdropFilter:'blur(10px)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
+            <button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
+            <button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
+            <div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
+            <div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
+              <div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
+            </div>
+          </div>
+        </div>
+      </TimeContext.Provider>
+    );
+  }
+
+  function Sprite({ start = 0, end, children, style }) {
+    const { time } = useContext(TimeContext);
+    const actualEnd = end == null ? Infinity : end;
+    if (time < start || time >= actualEnd) return null;
+    const duration = actualEnd - start;
+    const elapsed = time - start;
+    const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
+    const spriteValue = { t, elapsed, duration, start, end: actualEnd };
+    return (
+      <SpriteContext.Provider value={spriteValue}>
+        <div style={{position:'absolute', inset:0, ...style}}>{children}</div>
+      </SpriteContext.Provider>
+    );
+  }
+
+  window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
+})();
+</script>
+
+<!-- Demo scene -->
+<script type="text/babel">
+const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
+
+// ── Design tokens ─────────────────────────────────────────
+const CREAM = '#FAF6EF';
+const INK = '#1a1a1a';
+const TERRA = '#C04A1A';
+const ASH = '#6b6b6b';
+const LINE = '#d9d2c5';
+const OLIVE = '#6a6b4e';
+const DEEP_BLUE = '#2a3552';
+
+const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
+const sansCN = "'Noto Sans SC', -apple-system, sans-serif";
+const playfair = "'Playfair Display', Georgia, serif";
+const sans = "'Inter', -apple-system, sans-serif";
+const mono = "'JetBrains Mono', ui-monospace, monospace";
+
+// Palettes — the actual Tweak presets
+const PALETTES = {
+  warm:  { bg: CREAM,     accent: TERRA,       text: INK,    sub: ASH,    line: LINE,    name: '暖米 + 赤陶',  enName: 'WARM · TERRA' },
+  olive: { bg: '#f2efdf', accent: OLIVE,       text: '#2a2a1e', sub: '#7a7a5e', line: '#d4d1b8', name: '墨绿 + 鹅黄',  enName: 'OLIVE · CITRON' },
+  deep:  { bg: '#f4efe6', accent: DEEP_BLUE,   text: INK,    sub: '#5a6478', line: '#c9c3b3', name: '深蓝 + 沙金',  enName: 'DEEP · SAND' },
+};
+
+const FONTS = {
+  serif: { ui: serif, display: serif, name: 'Newsreader(衬线)' },
+  sans:  { ui: sansCN, display: sansCN, name: '思源黑体' },
+  play:  { ui: playfair, display: playfair, name: 'Playfair Display' },
+};
+
+// ── Scene 1: Title (0 – 3s) ────────────────────────────────
+function Scene1_Title() {
+  const { elapsed } = useSprite();
+  const labelOp = interpolate(elapsed, [0.2, 0.8], [0, 1]);
+  const mainY = interpolate(elapsed, [0.3, 1.3], [40, 0], Easing.easeOut);
+  const mainOp = interpolate(elapsed, [0.3, 1.1], [0, 1]);
+  const lineW = interpolate(elapsed, [1.1, 1.9], [0, 460]);
+  const subOp = interpolate(elapsed, [1.4, 2.1], [0, 1]);
+  const fadeOut = interpolate(elapsed, [2.6, 3.0], [1, 0], Easing.easeIn);
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
+      display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
+      <div style={{fontFamily: mono, fontSize: 14, letterSpacing:'0.35em',
+        color: TERRA, marginBottom: 30, opacity: labelOp}}>
+        实时变体 · TWEAKS
+      </div>
+      <div style={{fontFamily: serif, fontSize: 120, fontWeight: 500, color: INK,
+        lineHeight: 1.05, letterSpacing: '-0.01em',
+        opacity: mainOp, transform: `translateY(${mainY}px)`}}>
+        一个 HTML · <span style={{fontStyle:'italic', color: TERRA}}>多种</span>设计
+      </div>
+      <div style={{height: 1, background: INK, width: lineW, marginTop: 40}} />
+      <div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 22,
+        color: ASH, marginTop: 26, opacity: subOp, letterSpacing:'0.02em'}}>
+        不需要重新生成代码 · 只切参数
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 2: Main stage — control panel + live card (3 – 12s) ──
+function Scene2_MainStage() {
+  const { elapsed } = useSprite();
+
+  // Decide current Tweaks state based on elapsed time inside the scene.
+  // Scene2 elapsed: 0 – 9s
+  //   0 – 4s : warm + serif + 40
+  //   4 – 7s : olive + serif + 40   (palette change @ 4s)
+  //   7 – 9s : olive + sans + 40    (font change @ 7s)
+  let palette = 'warm';
+  let font = 'serif';
+  const density = 40;
+
+  if (elapsed >= 4) palette = 'olive';
+  if (elapsed >= 7) font = 'sans';
+
+  // Ripple trigger times
+  const rippleTimes = [4, 7];
+  const ripples = rippleTimes.map(t => {
+    const e = elapsed - t;
+    if (e < 0 || e > 1.2) return null;
+    return { t, progress: e / 1.2 };
+  }).filter(Boolean);
+
+  // Fade-in intro
+  const introOp = interpolate(elapsed, [0, 0.4], [0, 1]);
+  const fadeOut = interpolate(elapsed, [8.6, 9.0], [1, 0]);
+
+  const pal = PALETTES[palette];
+  const fnt = FONTS[font];
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM,
+      display:'flex', opacity: introOp * fadeOut}}>
+      {/* Left: Control Panel (30%) */}
+      <ControlPanel palette={palette} font={font} density={density} elapsed={elapsed} />
+
+      {/* Right: Live Card (70%) */}
+      <div style={{flex: 1, position:'relative', padding: '60px 80px',
+        display:'flex', alignItems:'center', justifyContent:'center',
+        transition: 'background 600ms ease-in-out',
+        background: pal.bg}}>
+        <LiveCard palette={palette} font={font} density={density} />
+
+        {/* Ripples */}
+        {ripples.map((r, i) => (
+          <Ripple key={r.t} progress={r.progress}
+            x={r.t === 4 ? 180 : 180}
+            y={r.t === 4 ? 340 : 490} />
+        ))}
+      </div>
+    </div>
+  );
+}
+
+function ControlPanel({ palette, font, density, elapsed }) {
+  return (
+    <div style={{width: '30%', background: '#f2ece0',
+      borderRight: `1px solid ${LINE}`, padding: '60px 44px 40px',
+      display:'flex', flexDirection:'column', gap: 38,
+      fontFamily: sans}}>
+      <div>
+        <div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.35em',
+          color: TERRA, marginBottom: 6}}>
+          TWEAKS
+        </div>
+        <div style={{fontFamily: serif, fontSize: 28, fontWeight: 500, color: INK,
+          letterSpacing:'-0.01em'}}>
+          设计调参面板
+        </div>
+      </div>
+
+      {/* Group 1: palette */}
+      <ControlGroup label="01 · 配色方案" en="PALETTE">
+        <Radio checked={palette==='warm'}  label="暖米 + 赤陶"   swatches={[CREAM, TERRA]} />
+        <Radio checked={palette==='olive'} label="墨绿 + 鹅黄"   swatches={['#f2efdf', OLIVE]} />
+        <Radio checked={palette==='deep'}  label="深蓝 + 沙金"   swatches={['#f4efe6', DEEP_BLUE]} />
+      </ControlGroup>
+
+      {/* Group 2: font */}
+      <ControlGroup label="02 · 字型" en="TYPEFACE">
+        <Radio checked={font==='serif'} label="Newsreader(衬线)" fontFamily={serif} />
+        <Radio checked={font==='sans'}  label="思源黑体"         fontFamily={sansCN} />
+        <Radio checked={font==='play'}  label="Playfair Display" fontFamily={playfair} />
+      </ControlGroup>
+
+      {/* Group 3: density */}
+      <ControlGroup label="03 · 信息密度" en="DENSITY">
+        <div style={{position:'relative', height: 4, background:'#e0dbcc',
+          marginTop: 16, marginBottom: 10}}>
+          <div style={{position:'absolute', left: 0, top: 0, height:'100%',
+            width: `${density}%`, background: TERRA}} />
+          <div style={{position:'absolute', left: `${density}%`, top: -6,
+            transform:'translateX(-50%)', width: 16, height: 16,
+            borderRadius:'50%', background: TERRA,
+            boxShadow:'0 2px 6px rgba(0,0,0,0.15)'}} />
+        </div>
+        <div style={{display:'flex', justifyContent:'space-between',
+          fontFamily: mono, fontSize: 9, letterSpacing:'0.2em', color: ASH}}>
+          <span>克制</span><span style={{color: TERRA}}>标准</span><span>密集</span>
+        </div>
+      </ControlGroup>
+
+      <div style={{flex: 1}} />
+
+      <div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.12em',
+        color: ASH, lineHeight: 1.6, borderTop: `1px solid ${LINE}`,
+        paddingTop: 16}}>
+        localStorage 持久化<br/>
+        <span style={{color: TERRA}}>→</span> 刷新不丢
+      </div>
+    </div>
+  );
+}
+
+function ControlGroup({ label, en, children }) {
+  return (
+    <div>
+      <div style={{display:'flex', justifyContent:'space-between',
+        alignItems:'baseline', marginBottom: 14}}>
+        <div style={{fontFamily: serif, fontSize: 15, fontWeight: 500, color: INK}}>
+          {label}
+        </div>
+        <div style={{fontFamily: mono, fontSize: 9, letterSpacing:'0.25em',
+          color: ASH}}>{en}</div>
+      </div>
+      <div style={{display:'flex', flexDirection:'column', gap: 8}}>{children}</div>
+    </div>
+  );
+}
+
+function Radio({ checked, label, swatches, fontFamily }) {
+  return (
+    <div style={{display:'flex', alignItems:'center', gap: 12,
+      padding:'9px 12px', background: checked ? '#fff' : 'transparent',
+      border: `1px solid ${checked ? TERRA : 'transparent'}`,
+      transition:'all 240ms ease-out'}}>
+      <div style={{width: 14, height: 14, borderRadius:'50%',
+        border: `1.5px solid ${checked ? TERRA : '#b0a898'}`,
+        display:'flex', alignItems:'center', justifyContent:'center',
+        flexShrink: 0}}>
+        {checked && <div style={{width: 7, height: 7, borderRadius:'50%',
+          background: TERRA}} />}
+      </div>
+      <div style={{flex: 1, fontFamily: fontFamily || sans, fontSize: 13,
+        color: checked ? INK : '#4a4a4a'}}>{label}</div>
+      {swatches && (
+        <div style={{display:'flex', gap: 3}}>
+          {swatches.map((c, i) => (
+            <div key={i} style={{width: 12, height: 12, background: c,
+              border:'1px solid rgba(0,0,0,0.06)'}} />
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}
+
+function Ripple({ progress, x, y }) {
+  const size = progress * 420;
+  const op = 1 - progress;
+  return (
+    <div style={{position:'absolute', left: x, top: y,
+      width: size, height: size, borderRadius:'50%',
+      border: `2px solid ${TERRA}`, opacity: op,
+      transform: 'translate(-50%, -50%)',
+      pointerEvents:'none'}} />
+  );
+}
+
+function LiveCard({ palette, font, density }) {
+  const pal = PALETTES[palette];
+  const fnt = FONTS[font];
+
+  return (
+    <div style={{width: '100%', maxWidth: 880, background: '#fff',
+      border: `1px solid ${pal.line}`,
+      transition:'border-color 600ms ease-in-out',
+      position:'relative'}}>
+      {/* Header bar */}
+      <div style={{padding:'18px 32px', borderBottom:`1px solid ${pal.line}`,
+        display:'flex', justifyContent:'space-between', alignItems:'center',
+        transition:'border-color 600ms ease-in-out'}}>
+        <div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
+          color: pal.accent, transition:'color 600ms ease-in-out'}}>
+          LUMINA · v3.2
+        </div>
+        <div style={{fontFamily: mono, fontSize: 10, color: pal.sub,
+          letterSpacing:'0.15em', transition:'color 600ms ease-in-out'}}>
+          PALETTE · {pal.enName}
+        </div>
+      </div>
+
+      {/* Hero content */}
+      <div style={{padding:'56px 60px 48px', display:'grid',
+        gridTemplateColumns:'1.4fr 1fr', gap: 48}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 11,
+            color: pal.accent, letterSpacing:'0.25em', marginBottom: 16,
+            transition:'color 600ms ease-in-out'}}>
+            READING · MEMORY
+          </div>
+          <div style={{fontFamily: fnt.display, fontSize: 68,
+            fontWeight: font === 'sans' ? 700 : 500, color: pal.text,
+            lineHeight: 1.05, letterSpacing:'-0.02em',
+            transition:'color 600ms ease-in-out',
+            marginBottom: 14}}>
+            Lumina
+          </div>
+          <div style={{fontFamily: fnt.ui,
+            fontStyle: font === 'play' ? 'italic' : 'normal',
+            fontSize: 22, color: pal.sub, lineHeight: 1.4,
+            letterSpacing: font === 'sans' ? 0 : '0.01em',
+            transition:'color 600ms ease-in-out',
+            marginBottom: 28}}>
+            阅读记忆 · 让每一次阅读被看见
+          </div>
+          <div style={{fontFamily: fnt.ui, fontSize: 14, color: pal.text,
+            lineHeight: 1.7, opacity: 0.78, marginBottom: 32,
+            transition:'color 600ms ease-in-out'}}>
+            把你读过的每一行、标注过的每一段,<br/>
+            汇成一条属于你的阅读河流。
+          </div>
+          <div style={{display:'flex', gap: 12, alignItems:'center'}}>
+            <div style={{padding:'12px 26px', background: pal.accent,
+              color:'#fff', fontFamily: fnt.ui, fontSize: 13,
+              letterSpacing: font === 'sans' ? '0.05em' : '0.12em',
+              transition:'background 600ms ease-in-out'}}>
+              {font === 'sans' ? '开始使用' : 'Start Reading'}
+            </div>
+            <div style={{fontFamily: mono, fontSize: 11, color: pal.sub,
+              letterSpacing:'0.2em',
+              transition:'color 600ms ease-in-out'}}>
+              FREE · BETA
+            </div>
+          </div>
+        </div>
+
+        {/* Right image block */}
+        <div style={{background: pal.bg,
+          border: `1px solid ${pal.line}`,
+          transition:'all 600ms ease-in-out',
+          aspectRatio: '3 / 4', position:'relative', overflow:'hidden'}}>
+          {/* Abstract book spine illustration */}
+          <svg width="100%" height="100%" viewBox="0 0 300 400"
+            preserveAspectRatio="xMidYMid slice">
+            {[0,1,2,3,4,5].map(i => (
+              <rect key={i} x={40 + i * 35} y={60 + (i % 2) * 20}
+                width={26} height={280 - (i % 3) * 30}
+                fill="none" stroke={pal.accent}
+                strokeWidth={i === 2 ? 2 : 1}
+                opacity={0.55 + (i === 2 ? 0.4 : 0)}
+                style={{transition:'stroke 600ms ease-in-out'}} />
+            ))}
+            <circle cx={150} cy={200} r={58} fill="none"
+              stroke={pal.accent} strokeWidth={1.5} opacity={0.5}
+              style={{transition:'stroke 600ms ease-in-out'}} />
+            <line x1={40} y1={350} x2={260} y2={350}
+              stroke={pal.text} strokeWidth={0.8} opacity={0.5}
+              style={{transition:'stroke 600ms ease-in-out'}} />
+          </svg>
+          <div style={{position:'absolute', bottom: 16, left: 18,
+            fontFamily: mono, fontSize: 9, letterSpacing:'0.2em',
+            color: pal.sub,
+            transition:'color 600ms ease-in-out'}}>
+            FIG. 01 — SHELF
+          </div>
+        </div>
+      </div>
+
+      {/* Footer meta */}
+      <div style={{padding:'14px 32px', borderTop:`1px solid ${pal.line}`,
+        display:'flex', justifyContent:'space-between',
+        fontFamily: mono, fontSize: 10, letterSpacing:'0.2em',
+        color: pal.sub,
+        transition:'all 600ms ease-in-out'}}>
+        <span>DENSITY · {density}</span>
+        <span>FONT · {font.toUpperCase()}</span>
+        <span>TWEAK ID · #{palette}-{font}-{density}</span>
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 3: Code view (12 – 17s) ─────────────────────────
+function Scene3_CodeView() {
+  const { elapsed } = useSprite();
+  const introOp = interpolate(elapsed, [0, 0.5], [0, 1]);
+  const fadeOut = interpolate(elapsed, [4.6, 5.0], [1, 0]);
+
+  // Code typing effect
+  const fullCode = `// Tweaks via localStorage + CSS vars
+const tweaks = {
+  palette: 'warm',   // ← user 选
+  font:    'serif',
+  density: 40,
+};
+
+document.documentElement.style
+  .setProperty(
+    '--accent',
+    PALETTES[tweaks.palette].accent
+  );
+
+localStorage.setItem(
+  'tweaks', JSON.stringify(tweaks)
+);`;
+
+  const typeProgress = Math.max(0, Math.min(1, (elapsed - 0.6) / 2.4));
+  const visibleChars = Math.floor(fullCode.length * typeProgress);
+  const visibleCode = fullCode.slice(0, visibleChars);
+  const cursorBlink = Math.floor(elapsed * 2.5) % 2 === 0 && typeProgress < 1;
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM,
+      display:'flex', flexDirection:'column', opacity: introOp * fadeOut,
+      padding:'60px 80px'}}>
+      <div style={{display:'flex', justifyContent:'space-between',
+        alignItems:'baseline', marginBottom: 36}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.35em',
+            color: TERRA, marginBottom: 6}}>
+            UNDER THE HOOD
+          </div>
+          <div style={{fontFamily: serif, fontSize: 52, fontWeight: 500,
+            color: INK, letterSpacing:'-0.01em'}}>
+            原理 · <span style={{fontStyle:'italic', color: TERRA}}>一行配置</span>,无限变体
+          </div>
+        </div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
+          color: ASH, textAlign:'right', lineHeight: 1.5}}>
+          纯前端 · 无后端依赖<br/>
+          <span style={{fontSize: 14}}>刷新保留状态</span>
+        </div>
+      </div>
+
+      <div style={{display:'grid', gridTemplateColumns:'1fr 1.4fr', gap: 40,
+        flex: 1}}>
+        {/* Left: simplified tweak visualization */}
+        <div style={{background:'#fff', border:`1px solid ${LINE}`,
+          padding: 36, display:'flex', flexDirection:'column', gap: 28}}>
+          <div style={{fontFamily: mono, fontSize: 11, letterSpacing:'0.3em',
+            color: TERRA}}>TWEAK · STATE</div>
+
+          <MiniRow label="palette" value="warm" swatch={TERRA} />
+          <MiniRow label="font"    value="serif" />
+          <MiniRow label="density" value="40" />
+
+          <div style={{height: 1, background: LINE, margin:'8px 0'}} />
+
+          <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 16,
+            color: ASH, lineHeight: 1.55}}>
+            三个参数的组合空间:<br/>
+            <span style={{color: TERRA, fontFamily: mono, fontStyle:'normal',
+              fontSize: 14}}>3 × 3 × ∞ = 无限</span>
+          </div>
+
+          <div style={{flex: 1}} />
+
+          <div style={{fontFamily: mono, fontSize: 10, color: ASH,
+            letterSpacing:'0.15em', lineHeight: 1.7}}>
+            → 改代码:不必要<br/>
+            → 重新生成:不必要<br/>
+            → 只改变量:30ms 生效
+          </div>
+        </div>
+
+        {/* Right: code block */}
+        <div style={{background:'#0e1016', padding:'28px 32px',
+          fontFamily: mono, fontSize: 15, color:'#d4c9b5',
+          lineHeight: 1.7, position:'relative', overflow:'hidden'}}>
+          <div style={{display:'flex', gap: 8, marginBottom: 20}}>
+            <div style={{width: 10, height: 10, borderRadius:'50%',
+              background:'#ff5f57'}} />
+            <div style={{width: 10, height: 10, borderRadius:'50%',
+              background:'#febc2e'}} />
+            <div style={{width: 10, height: 10, borderRadius:'50%',
+              background:'#28c840'}} />
+            <div style={{marginLeft: 14, fontSize: 10, color:'#888',
+              letterSpacing:'0.15em'}}>tweaks.js</div>
+          </div>
+          <pre style={{whiteSpace:'pre-wrap', margin: 0,
+            fontFamily: mono, fontSize: 15, lineHeight: 1.65}}>
+            <CodeColorize text={visibleCode} />
+            {cursorBlink && <span style={{color:'#ff6a3d'}}>▌</span>}
+          </pre>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function MiniRow({ label, value, swatch }) {
+  return (
+    <div style={{display:'flex', alignItems:'center', gap: 14}}>
+      <div style={{fontFamily: mono, fontSize: 11, color: ASH,
+        letterSpacing:'0.2em', width: 80}}>{label}</div>
+      <div style={{flex: 1, fontFamily: mono, fontSize: 14, color: INK}}>
+        {value}
+      </div>
+      {swatch && (
+        <div style={{width: 14, height: 14, background: swatch}} />
+      )}
+    </div>
+  );
+}
+
+// Very light syntax coloring
+function CodeColorize({ text }) {
+  const lines = text.split('\n');
+  return (
+    <>
+      {lines.map((line, i) => (
+        <span key={i}>
+          {colorizeLine(line)}
+          {'\n'}
+        </span>
+      ))}
+    </>
+  );
+}
+
+function colorizeLine(line) {
+  const parts = [];
+  let rest = line;
+
+  // comment
+  const cIdx = rest.indexOf('//');
+  if (cIdx >= 0) {
+    const before = rest.slice(0, cIdx);
+    const comment = rest.slice(cIdx);
+    return (
+      <>
+        {tokenize(before)}
+        <span style={{color:'#6a7d6a'}}>{comment}</span>
+      </>
+    );
+  }
+  return tokenize(line);
+}
+
+function tokenize(s) {
+  // keywords + strings
+  const kw = ['const', 'let', 'var', 'function', 'return'];
+  const words = s.split(/(\s+|[{}();,=.:'])/);
+  return words.map((w, i) => {
+    if (kw.includes(w)) return <span key={i} style={{color:'#c79cff'}}>{w}</span>;
+    if (/^'[^']*'$/.test(w)) return <span key={i} style={{color:'#ffb86c'}}>{w}</span>;
+    if (/^[0-9]+$/.test(w)) return <span key={i} style={{color:'#ff6a3d'}}>{w}</span>;
+    if (['palette', 'font', 'density', 'tweaks', 'PALETTES', 'accent'].includes(w)) {
+      return <span key={i} style={{color:'#8be9fd'}}>{w}</span>;
+    }
+    if (['document', 'localStorage'].includes(w)) {
+      return <span key={i} style={{color:'#ff79c6'}}>{w}</span>;
+    }
+    return <span key={i}>{w}</span>;
+  });
+}
+
+// ── Scene 4: Finale (17 – 20s) ────────────────────────────
+function Scene4_Final() {
+  const { elapsed } = useSprite();
+  const labelOp = interpolate(elapsed, [0.1, 0.7], [0, 1]);
+  const mainY = interpolate(elapsed, [0.2, 1.2], [30, 0], Easing.easeOut);
+  const mainOp = interpolate(elapsed, [0.2, 1.0], [0, 1]);
+  const lineW = interpolate(elapsed, [1.0, 1.8], [0, 540]);
+  const dimsOp = interpolate(elapsed, [1.3, 2.1], [0, 1]);
+
+  const dimensions = ['配色', '字型', '密度', '布局', '动画速度'];
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM,
+      display:'flex', alignItems:'center', justifyContent:'center',
+      flexDirection:'column'}}>
+      <div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
+        color: TERRA, marginBottom: 26, opacity: labelOp}}>
+        TWEAKS · EVERYTHING IS A VARIABLE
+      </div>
+      <div style={{fontFamily: serif, fontSize: 108, fontWeight: 500,
+        color: INK, lineHeight: 1.05, letterSpacing:'-0.01em',
+        opacity: mainOp, transform: `translateY(${mainY}px)`,
+        textAlign:'center'}}>
+        一个源文件 · <span style={{fontStyle:'italic', color: TERRA}}>无限</span>变体
+      </div>
+      <div style={{height: 1, background: INK, width: lineW, marginTop: 38}} />
+
+      <div style={{marginTop: 36, display:'flex', gap: 10,
+        opacity: dimsOp, alignItems:'center'}}>
+        {dimensions.map((d, i) => (
+          <React.Fragment key={i}>
+            {i > 0 && (
+              <span style={{fontFamily: mono, fontSize: 14,
+                color: LINE, margin:'0 2px'}}>·</span>
+            )}
+            <span style={{fontFamily: mono, fontSize: 14,
+              letterSpacing:'0.2em',
+              color: i === 0 ? TERRA : ASH,
+              padding:'6px 14px',
+              border: `1px solid ${i === 0 ? TERRA : LINE}`}}>
+              {d}
+            </span>
+          </React.Fragment>
+        ))}
+      </div>
+
+      <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 20,
+        color: ASH, marginTop: 36, opacity: dimsOp,
+        maxWidth: 720, textAlign:'center', lineHeight: 1.5}}>
+        设计不是一次性的结果 ——<br/>
+        而是一组可以随时拨动的旋钮
+      </div>
+    </div>
+  );
+}
+
+// ── Watermark ─────────────────────────────────────────────
+function Watermark() {
+  return (
+    <div style={{position:'absolute', bottom: 24, right: 32,
+      fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
+      fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
+      Created by Huashu-Design
+    </div>
+  );
+}
+
+// ── Main composition ──────────────────────────────────────
+function App() {
+  return (
+    <Stage duration={20} width={1920} height={1080} bgColor={CREAM}>
+      <Sprite start={0}  end={3}><Scene1_Title /></Sprite>
+      <Sprite start={3}  end={12}><Scene2_MainStage /></Sprite>
+      <Sprite start={12} end={17}><Scene3_CodeView /></Sprite>
+      <Sprite start={17} end={20}><Scene4_Final /></Sprite>
+      <Watermark />
+    </Stage>
+  );
+}
+
+ReactDOM.createRoot(document.getElementById('root')).render(<App />);
+</script>
+</body>
+</html>

+ 767 - 0
demos/c5-infographic.html

@@ -0,0 +1,767 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<title>Huashu-Design · Infographic Demo</title>
+<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
+<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
+<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
+<style>
+  * { box-sizing: border-box; margin: 0; padding: 0; }
+  html, body { width: 100%; height: 100%; overflow: hidden; }
+  body {
+    background: #0c0c0c;
+    font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
+    color: #1a1a1a;
+    -webkit-font-smoothing: antialiased;
+    text-rendering: optimizeLegibility;
+  }
+</style>
+</head>
+<body>
+<div id="root"></div>
+
+<!-- animations.jsx inlined -->
+<script type="text/babel">
+(function() {
+  const { createContext, useContext, useState, useEffect, useRef } = React;
+  const TimeContext = createContext({ time: 0, duration: 10, playing: false });
+  const SpriteContext = createContext(null);
+  const Easing = {
+    linear: t => t,
+    easeIn: t => t * t,
+    easeOut: t => 1 - (1 - t) * (1 - t),
+    easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
+    spring: t => {
+      const c = (2 * Math.PI) / 3;
+      return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
+    },
+  };
+  function interpolate(t, input, output, easing) {
+    const [a, b] = input, [x, y] = output;
+    if (t <= a) return x; if (t >= b) return y;
+    let p = (t - a) / (b - a); if (easing) p = easing(p);
+    return x + (y - x) * p;
+  }
+  function useTime() { return useContext(TimeContext).time; }
+  function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
+  function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
+    const [time, setTime] = useState(0);
+    const [playing, setPlaying] = useState(true);
+    const [scale, setScale] = useState(1);
+    const rafRef = useRef(null);
+    const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
+    useEffect(() => {
+      const update = () => {
+        const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
+        setScale(s);
+      };
+      update(); window.addEventListener('resize', update);
+      return () => window.removeEventListener('resize', update);
+    }, [width, height]);
+    useEffect(() => {
+      if (!playing) return;
+      let cancelled = false, last = null;
+      function tick(now) {
+        if (cancelled) return;
+        if (last === null) { last = now; if (typeof window !== 'undefined') window.__ready = true; }
+        const delta = (now - last) / 1000; last = now;
+        setTime(prev => {
+          const next = prev + delta;
+          if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
+          return next;
+        });
+        rafRef.current = requestAnimationFrame(tick);
+      }
+      const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
+      if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
+      return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
+    }, [playing, duration, effectiveLoop]);
+    const progress = time / duration;
+    const ctx = { time, duration, playing, setPlaying, setTime };
+    return (
+      <TimeContext.Provider value={ctx}>
+        <div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
+          <div style={{flex:1, position:'relative', overflow:'hidden'}}>
+            <div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
+              {children}
+            </div>
+          </div>
+          <div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
+            <button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
+            <button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
+            <div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
+            <div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
+              <div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
+            </div>
+          </div>
+        </div>
+      </TimeContext.Provider>
+    );
+  }
+  function Sprite({ start = 0, end, children, style }) {
+    const { time } = useContext(TimeContext);
+    const actualEnd = end == null ? Infinity : end;
+    if (time < start || time >= actualEnd) return null;
+    const duration = actualEnd - start;
+    const elapsed = time - start;
+    const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
+    return (
+      <SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
+        <div style={{position:'absolute', inset:0, ...style}}>{children}</div>
+      </SpriteContext.Provider>
+    );
+  }
+  window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
+})();
+</script>
+
+<!-- Demo scene -->
+<script type="text/babel">
+const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
+
+// ── Design tokens ─────────────────────────────────────────
+const CREAM = '#FAF6EF';
+const INK = '#1a1a1a';
+const TERRA = '#C04A1A';
+const ASH = '#6b6b6b';
+const LINE = '#d9d2c5';
+const OLIVE = '#6a6b4e';
+const DEEP_BLUE = '#2a3552';
+
+const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
+const sans = "'Inter', -apple-system, sans-serif";
+const mono = "'JetBrains Mono', ui-monospace, monospace";
+
+// ── Scene 1: Title (0 – 3s) ───────────────────────────────
+function Scene1_Title() {
+  const { elapsed } = useSprite();
+  const topOp = interpolate(elapsed, [0.0, 0.6], [0, 1]);
+  const topLineW = interpolate(elapsed, [0.3, 1.0], [0, 220]);
+  const mainOp = interpolate(elapsed, [0.5, 1.2], [0, 1]);
+  const mainY = interpolate(elapsed, [0.5, 1.3], [32, 0], Easing.easeOut);
+  const italicOp = interpolate(elapsed, [1.0, 1.6], [0, 1]);
+  const subOp = interpolate(elapsed, [1.5, 2.1], [0, 1]);
+  const fadeOut = interpolate(elapsed, [2.6, 3.0], [1, 0], Easing.easeIn);
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
+      display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
+      <div style={{display:'flex', alignItems:'center', gap: 18, opacity: topOp, marginBottom: 48}}>
+        <div style={{height: 1, background: TERRA, width: topLineW}}/>
+        <div style={{fontFamily: mono, fontSize: 12, color: TERRA,
+          letterSpacing:'0.35em'}}>
+          信息图 · 数据驱动 · 印刷级
+        </div>
+        <div style={{height: 1, background: TERRA, width: topLineW}}/>
+      </div>
+      <div style={{fontFamily: serif, fontSize: 160, fontWeight: 500,
+        color: INK, lineHeight: 1, letterSpacing: '-0.02em',
+        opacity: mainOp, transform: `translateY(${mainY}px)`}}>
+        让数据<span style={{fontStyle:'italic', color: TERRA, opacity: italicOp}}>说话</span>
+      </div>
+      <div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 24,
+        color: ASH, marginTop: 44, opacity: subOp, letterSpacing: '0.02em'}}>
+        精确排版 · 一眼看懂 · 可印刷
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 2: Full infographic layout (3 – 10s) ────────────
+// Uses a magazine spread style: headline, three columns (big numbers / bars / pie), trend line footer
+function Scene2_Spread() {
+  const { elapsed } = useSprite();
+  const headerOp = interpolate(elapsed, [0, 0.5], [0, 1]);
+  const ruleW = interpolate(elapsed, [0.3, 1.0], [0, 1800]);
+  const colDelay = [0.6, 1.0, 1.4];
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM,
+      padding: '60px 80px 50px', display:'flex', flexDirection:'column'}}>
+      {/* Masthead */}
+      <div style={{opacity: headerOp, display:'flex', justifyContent:'space-between',
+        alignItems:'baseline', marginBottom: 14, fontFamily: mono, fontSize: 11,
+        letterSpacing: '0.3em', color: ASH}}>
+        <span>HUASHU · INFOGRAPHIC REPORT</span>
+        <span>VOL. 01 · 2026.04</span>
+      </div>
+      {/* Headline */}
+      <div style={{opacity: headerOp, fontFamily: serif, fontSize: 72,
+        fontWeight: 500, color: INK, lineHeight: 1.05, letterSpacing: '-0.01em',
+        marginBottom: 8}}>
+        2026 AI 写作工具
+        <span style={{fontStyle:'italic', color: TERRA, marginLeft: 20}}>年度观察</span>
+      </div>
+      <div style={{opacity: headerOp, fontFamily: serif, fontStyle:'italic',
+        fontSize: 20, color: ASH, marginBottom: 22}}>
+        156 位创作者匿名问卷 · 3 月 15 日 – 4 月 10 日
+      </div>
+      {/* Top rule */}
+      <div style={{width: ruleW, height: 1, background: INK, marginBottom: 28}}/>
+
+      {/* Three-column grid */}
+      <div style={{display:'grid', gridTemplateColumns:'1fr 1px 1.15fr 1px 0.95fr',
+        gap: 36, flex: 1}}>
+        <ColumnLeft elapsed={elapsed - colDelay[0]} />
+        <div style={{background: LINE}}/>
+        <ColumnMid elapsed={elapsed - colDelay[1]} />
+        <div style={{background: LINE}}/>
+        <ColumnRight elapsed={elapsed - colDelay[2]} />
+      </div>
+
+      {/* Footer trend line */}
+      <FooterTrend elapsed={elapsed - 3.5} />
+    </div>
+  );
+}
+
+function ColumnLeft({ elapsed }) {
+  const e = Math.max(0, elapsed);
+  const labelOp = interpolate(e, [0, 0.4], [0, 1]);
+  // 87%
+  const n1 = Math.round(interpolate(e, [0.3, 1.8], [0, 87], Easing.easeOut));
+  const bar1 = interpolate(e, [0.3, 1.8], [0, 87], Easing.easeOut);
+  // 3.2x
+  const n2 = interpolate(e, [1.3, 2.8], [1.0, 3.2], Easing.easeOut);
+  const bar2 = interpolate(e, [1.3, 2.8], [0, 3.2/5*100], Easing.easeOut);
+  // 156
+  const n3 = Math.round(interpolate(e, [2.3, 3.6], [0, 156], Easing.easeOut));
+  const bar3 = interpolate(e, [2.3, 3.6], [0, 100], Easing.easeOut);
+
+  return (
+    <div style={{display:'flex', flexDirection:'column', gap: 30}}>
+      <div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.3em',
+        color: TERRA, opacity: labelOp}}>
+        COLUMN / 01 · 核心指标
+      </div>
+      <MetricRow
+        value={`${n1}%`}
+        width={`${bar1}%`}
+        label="用户每周使用 AI 辅助写作"
+        note="¹ 每周 ≥ 3 次"
+        color={TERRA}
+      />
+      <MetricRow
+        value={`${n2.toFixed(1)}×`}
+        width={`${bar2}%`}
+        label="平均产出效率提升"
+        note="² 自述周稿字数"
+        color={OLIVE}
+      />
+      <MetricRow
+        value={String(n3)}
+        width={`${bar3}%`}
+        label="有效样本数"
+        note="³ 剔除 AI 默认答卷"
+        color={DEEP_BLUE}
+      />
+    </div>
+  );
+}
+
+function MetricRow({ value, width, label, note, color }) {
+  return (
+    <div>
+      <div style={{display:'flex', alignItems:'baseline', gap: 10, marginBottom: 8}}>
+        <div style={{fontFamily: serif, fontSize: 72, fontWeight: 500,
+          color: INK, lineHeight: 0.95, letterSpacing:'-0.02em',
+          fontVariantNumeric:'tabular-nums'}}>
+          {value}
+        </div>
+      </div>
+      <div style={{height: 6, background: '#eee7d7', width:'100%',
+        marginBottom: 10, position:'relative'}}>
+        <div style={{position:'absolute', top:0, left:0, height:'100%',
+          width, background: color}}/>
+      </div>
+      <div style={{fontFamily: serif, fontSize: 15, color: INK, lineHeight: 1.4}}>
+        {label}
+        <span style={{fontFamily: mono, fontSize: 9, color: ASH,
+          verticalAlign:'super', marginLeft: 4}}>{note}</span>
+      </div>
+    </div>
+  );
+}
+
+function ColumnMid({ elapsed }) {
+  const e = Math.max(0, elapsed);
+  const labelOp = interpolate(e, [0, 0.4], [0, 1]);
+  // 5 bars, staggered 0.15s
+  const bars = [
+    { name:'长文创作', pct: 78, color: TERRA },
+    { name:'短内容', pct: 64, color: OLIVE },
+    { name:'标题/文案', pct: 52, color: DEEP_BLUE },
+    { name:'润色校对', pct: 41, color: ASH },
+    { name:'翻译', pct: 29, color: ASH },
+  ];
+  const chartH = 320;
+  const maxPct = 100;
+
+  return (
+    <div style={{display:'flex', flexDirection:'column', gap: 18}}>
+      <div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.3em',
+        color: TERRA, opacity: labelOp}}>
+        COLUMN / 02 · 用途分布
+      </div>
+      <div style={{fontFamily: serif, fontSize: 28, fontWeight: 500,
+        color: INK, lineHeight: 1.2, opacity: labelOp,
+        letterSpacing: '-0.01em'}}>
+        你最常用 AI 做什么?
+      </div>
+      <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13,
+        color: ASH, opacity: labelOp}}>
+        多选题 · 百分比占全样本
+      </div>
+      {/* chart */}
+      <div style={{position:'relative', height: chartH, display:'flex',
+        alignItems:'flex-end', gap: 18, padding: '0 8px', marginTop: 4,
+        borderBottom:`1px solid ${INK}`}}>
+        {/* y-axis gridlines */}
+        {[25, 50, 75, 100].map(v => (
+          <div key={v} style={{position:'absolute', left: 0, right: 0,
+            bottom: (v/maxPct)*chartH, height: 1,
+            borderTop:`1px dashed ${LINE}`, pointerEvents:'none'}}>
+            <div style={{position:'absolute', left: -36, top: -8,
+              fontFamily: mono, fontSize: 9, color: ASH,
+              letterSpacing:'0.05em'}}>
+              {v}%
+            </div>
+          </div>
+        ))}
+        {bars.map((b, i) => {
+          const delay = 0.8 + i * 0.15;
+          const growT = Math.max(0, Math.min(1, (e - delay) / 0.55));
+          const h = growT * (b.pct / maxPct) * chartH;
+          const labelOpB = Math.max(0, Math.min(1, (e - delay - 0.35) / 0.3));
+          return (
+            <div key={i} style={{flex: 1, position:'relative',
+              display:'flex', flexDirection:'column', alignItems:'center',
+              justifyContent:'flex-end', height:'100%'}}>
+              <div style={{position:'absolute', top: -22,
+                fontFamily: mono, fontSize: 11, color: INK,
+                letterSpacing:'0.02em', fontVariantNumeric:'tabular-nums',
+                opacity: labelOpB}}>
+                {b.pct}%
+              </div>
+              <div style={{width: '100%', height: h, background: b.color,
+                transition:'none'}}/>
+            </div>
+          );
+        })}
+      </div>
+      {/* x-axis labels */}
+      <div style={{display:'flex', gap: 18, padding: '0 8px', marginTop: -8}}>
+        {bars.map((b, i) => (
+          <div key={i} style={{flex: 1, textAlign:'center',
+            fontFamily: serif, fontSize: 12, color: INK,
+            letterSpacing:'0.02em', opacity: labelOp}}>
+            {b.name}
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}
+
+function ColumnRight({ elapsed }) {
+  const e = Math.max(0, elapsed);
+  const labelOp = interpolate(e, [0, 0.4], [0, 1]);
+  // Three pie slices sweep in
+  const slices = [
+    { label:'Claude', pct: 46, color: TERRA },
+    { label:'GPT', pct: 31, color: DEEP_BLUE },
+    { label:'GLM/国产', pct: 23, color: OLIVE },
+  ];
+  const cx = 130, cy = 130, r = 104;
+  const C = 2 * Math.PI * r;
+
+  // cumulative pct as fractions
+  let acc = 0;
+  const slicesCalc = slices.map((s, i) => {
+    const delay = 0.6 + i * 0.45;
+    const sweepT = Math.max(0, Math.min(1, (e - delay) / 0.7));
+    const start = acc;
+    const end = acc + s.pct / 100;
+    acc = end;
+    return { ...s, start, end, sweepT, delay };
+  });
+
+  return (
+    <div style={{display:'flex', flexDirection:'column', gap: 14}}>
+      <div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.3em',
+        color: TERRA, opacity: labelOp}}>
+        COLUMN / 03 · 模型占有率
+      </div>
+      <div style={{fontFamily: serif, fontSize: 24, fontWeight: 500,
+        color: INK, lineHeight: 1.2, opacity: labelOp,
+        letterSpacing:'-0.01em'}}>
+        主力模型分布
+      </div>
+      <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 12,
+        color: ASH, opacity: labelOp, marginBottom: 6}}>
+        单选题 · 日常首选
+      </div>
+
+      <div style={{display:'flex', alignItems:'center', gap: 18}}>
+        <svg width="260" height="260" viewBox="0 0 260 260">
+          {/* Background ring */}
+          <circle cx={cx} cy={cy} r={r} fill="none"
+            stroke={LINE} strokeWidth={1}/>
+          {slicesCalc.map((s, i) => {
+            // Draw partial arc with stroke-dasharray
+            const sweepLen = (s.end - s.start) * s.sweepT;
+            const dash = sweepLen * C;
+            const gap = C - dash;
+            const rot = s.start * 360 - 90;
+            return (
+              <circle key={i} cx={cx} cy={cy} r={r}
+                fill="none" stroke={s.color} strokeWidth={28}
+                strokeDasharray={`${dash} ${gap}`}
+                strokeDashoffset={0}
+                transform={`rotate(${rot} ${cx} ${cy})`}
+                opacity={0.95}/>
+            );
+          })}
+          {/* Inner text */}
+          <text x={cx} y={cy - 4} textAnchor="middle"
+            fontFamily={serif} fontSize={34} fill={INK}
+            fontWeight={500} letterSpacing="-0.5">
+            n=156
+          </text>
+          <text x={cx} y={cy + 22} textAnchor="middle"
+            fontFamily={mono} fontSize={10} fill={ASH}
+            letterSpacing="0.2em">
+            TOTAL
+          </text>
+        </svg>
+        <div style={{display:'flex', flexDirection:'column', gap: 14, flex: 1}}>
+          {slicesCalc.map((s, i) => {
+            const txtOp = Math.max(0, Math.min(1, (e - s.delay - 0.3) / 0.4));
+            return (
+              <div key={i} style={{display:'flex', alignItems:'baseline',
+                gap: 10, opacity: txtOp}}>
+                <div style={{width: 10, height: 10, background: s.color,
+                  marginTop: 4, flexShrink: 0}}/>
+                <div style={{flex: 1}}>
+                  <div style={{fontFamily: serif, fontSize: 18,
+                    fontWeight: 500, color: INK, letterSpacing:'0.01em'}}>
+                    {s.label}
+                  </div>
+                </div>
+                <div style={{fontFamily: serif, fontSize: 22,
+                  fontWeight: 500, color: INK,
+                  fontVariantNumeric:'tabular-nums'}}>
+                  {s.pct}%
+                </div>
+              </div>
+            );
+          })}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function FooterTrend({ elapsed }) {
+  const e = Math.max(0, elapsed);
+  const op = interpolate(e, [0, 0.5], [0, 1]);
+  const data = [12, 18, 24, 31, 38, 48, 57, 64, 71, 78, 84, 87];
+  const months = ['05','06','07','08','09','10','11','12','01','02','03','04'];
+  const W = 1760, H = 86, PAD = 8;
+  const maxV = 100;
+  // progressive reveal of line
+  const revealT = Math.max(0, Math.min(1, (e - 0.3) / 1.4));
+  const nPoints = Math.max(1, Math.floor(revealT * data.length));
+  const pts = [];
+  for (let i = 0; i < data.length; i++) {
+    const x = (i / (data.length - 1)) * W;
+    const y = H - (data[i] / maxV) * (H - PAD * 2) - PAD;
+    pts.push([x, y]);
+  }
+  const visiblePts = pts.slice(0, nPoints);
+  const d = visiblePts.map((p, i) => (i === 0 ? 'M' : 'L') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ');
+  const area = visiblePts.length > 1
+    ? d + ` L ${visiblePts[visiblePts.length-1][0].toFixed(1)} ${H} L 0 ${H} Z`
+    : '';
+
+  return (
+    <div style={{marginTop: 26, opacity: op}}>
+      <div style={{display:'flex', justifyContent:'space-between',
+        alignItems:'baseline', marginBottom: 8}}>
+        <div style={{fontFamily: mono, fontSize: 10, color: TERRA,
+          letterSpacing:'0.3em'}}>TREND · 过去 12 个月 AI 周使用率(%)</div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13,
+          color: ASH}}>
+          从 12% 到 87% · 增长 7.25×
+        </div>
+      </div>
+      <svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}
+        style={{display:'block', width:'100%', height: H}}>
+        {area && <path d={area} fill={TERRA} opacity={0.08}/>}
+        {d && <path d={d} fill="none" stroke={TERRA} strokeWidth={1.6}/>}
+        {visiblePts.map((p, i) => (
+          <circle key={i} cx={p[0]} cy={p[1]} r={2.4} fill={TERRA}/>
+        ))}
+        {/* Axis labels */}
+        {pts.map((p, i) => (
+          <text key={i} x={p[0]} y={H - 0}
+            textAnchor="middle" fontFamily={mono} fontSize={9} fill={ASH}
+            opacity={0.6}>
+            {months[i]}
+          </text>
+        ))}
+      </svg>
+    </div>
+  );
+}
+
+// ── Scene 3: Typography close-up (10 – 17s) ───────────────
+function Scene3_Typography() {
+  const { elapsed } = useSprite();
+  const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1]);
+  const fadeOut = interpolate(elapsed, [6.5, 7.0], [1, 0], Easing.easeIn);
+  const opacity = Math.min(fadeIn, fadeOut);
+  const labelOp = interpolate(elapsed, [0.2, 0.8], [0, 1]);
+  const leftOp = interpolate(elapsed, [0.4, 1.2], [0, 1]);
+  const compareOp = interpolate(elapsed, [1.8, 2.6], [0, 1]);
+  const captionOp = interpolate(elapsed, [3.6, 4.4], [0, 1]);
+
+  // Pulsing scale on "87"
+  const pulse = 1 + Math.sin(elapsed * 1.6) * 0.008;
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM, opacity,
+      padding: '60px 80px', display:'flex', flexDirection:'column'}}>
+      {/* Top label */}
+      <div style={{display:'flex', alignItems:'baseline',
+        justifyContent:'space-between', marginBottom: 32, opacity: labelOp}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+            letterSpacing:'0.3em', marginBottom: 6}}>DETAIL · ZOOM 1.5×</div>
+          <div style={{fontFamily: serif, fontSize: 46, fontWeight: 500,
+            color: INK, letterSpacing:'-0.01em'}}>
+            排版细节:<span style={{fontStyle:'italic', color: TERRA}}>品味税</span>
+          </div>
+        </div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
+          color: ASH, textAlign:'right', maxWidth: 400, lineHeight: 1.5}}>
+          "AI 能写中文,但分不清什么是好的中文排版"
+        </div>
+      </div>
+
+      <div style={{display:'grid', gridTemplateColumns:'1.25fr 1fr',
+        gap: 56, flex: 1}}>
+        {/* Left: Number gradient showcase */}
+        <div style={{opacity: leftOp, display:'flex', flexDirection:'column',
+          gap: 28}}>
+          <div style={{fontFamily: mono, fontSize: 10, color: ASH,
+            letterSpacing:'0.3em'}}>01 · 字号梯度 · HIERARCHY</div>
+          <div style={{display:'flex', alignItems:'baseline', gap: 36,
+            borderBottom:`1px solid ${LINE}`, paddingBottom: 28}}>
+            <div style={{fontFamily: serif, fontSize: 220, fontWeight: 500,
+              color: INK, lineHeight: 0.88, letterSpacing:'-0.04em',
+              fontVariantNumeric:'tabular-nums', display:'inline-block',
+              transform:`scale(${pulse})`, transformOrigin:'left bottom'}}>
+              87<span style={{fontSize: 80, color: TERRA,
+                verticalAlign:'super', marginLeft: 4, fontStyle:'italic'}}>%</span>
+            </div>
+            <div style={{fontFamily: serif, fontSize: 110, fontWeight: 400,
+              color: OLIVE, lineHeight: 0.88, letterSpacing:'-0.02em',
+              fontVariantNumeric:'tabular-nums'}}>
+              3.2<span style={{fontSize: 44, fontStyle:'italic',
+                color: ASH, marginLeft: 2}}>×</span>
+            </div>
+            <div style={{fontFamily: serif, fontSize: 56, fontWeight: 400,
+              color: DEEP_BLUE, lineHeight: 0.88,
+              fontVariantNumeric:'tabular-nums'}}>
+              156
+            </div>
+          </div>
+          <div style={{fontFamily: serif, fontSize: 15, color: ASH,
+            lineHeight: 1.55, maxWidth: 580}}>
+            主数据 <span style={{color: INK, fontWeight: 500}}>220pt</span>、
+            次级 <span style={{color: INK, fontWeight: 500}}>110pt</span>、
+            辅助 <span style={{color: INK, fontWeight: 500}}>56pt</span>——
+            梯度 2× 不是工程师拍脑袋,是几百年印刷品的视觉惯性。
+          </div>
+
+          <div style={{fontFamily: mono, fontSize: 10, color: ASH,
+            letterSpacing:'0.3em', marginTop: 10}}>02 · 换行 · TEXT-WRAP: PRETTY</div>
+          <div style={{fontFamily: serif, fontSize: 34, fontWeight: 500,
+            color: INK, lineHeight: 1.25, letterSpacing:'-0.01em',
+            maxWidth: 620, textWrap:'pretty'}}>
+            标题在该断的地方断开<br/>
+            避免孤字和单字成行
+          </div>
+
+          <div style={{fontFamily: mono, fontSize: 10, color: ASH,
+            letterSpacing:'0.3em', marginTop: 4}}>03 · 上标辅注 · MONO FOOTNOTE</div>
+          <div style={{fontFamily: serif, fontSize: 20, color: INK,
+            lineHeight: 1.6, maxWidth: 620}}>
+            87%
+            <span style={{fontFamily: mono, fontSize: 11, color: TERRA,
+              verticalAlign:'super', marginLeft: 4}}>¹</span>
+            用户每周用 AI 辅助写作
+            <div style={{fontFamily: mono, fontSize: 11, color: ASH,
+              marginTop: 10, letterSpacing:'0.05em'}}>
+              ¹ 基于 156 位创作者调研,每周 ≥ 3 次
+            </div>
+          </div>
+        </div>
+
+        {/* Right: AI slop vs 精致 */}
+        <div style={{opacity: compareOp, display:'flex', flexDirection:'column',
+          gap: 20}}>
+          <div style={{fontFamily: mono, fontSize: 10, color: ASH,
+            letterSpacing:'0.3em'}}>04 · AI SLOP vs 精致版</div>
+          {/* Slop version */}
+          <div style={{position:'relative', border: `1.5px dashed #c06060`,
+            padding: '22px 22px', borderRadius: 16,
+            background: 'linear-gradient(135deg, #6a47d4 0%, #3a1a7a 100%)'}}>
+            <div style={{position:'absolute', top: -10, left: 14,
+              background: '#c06060', color:'#fff', fontFamily: mono,
+              fontSize: 9, padding:'2px 10px', letterSpacing:'0.2em'}}>
+              ✕ 反例 · 不要这样做
+            </div>
+            <div style={{fontFamily: sans, fontSize: 28, fontWeight: 700,
+              color:'#fff', marginBottom: 6, letterSpacing:'-0.01em'}}>
+              🚀 AI 写作工具爆发增长!
+            </div>
+            <div style={{fontFamily: sans, fontSize: 13, color:'rgba(255,255,255,0.85)',
+              lineHeight: 1.5}}>
+              ✨ 87% 用户都在用!💡 效率提升 3.2 倍!🎯 赶紧加入!
+            </div>
+            <div style={{marginTop: 14, display:'flex', gap: 8}}>
+              <div style={{background:'rgba(255,255,255,0.2)',
+                padding:'6px 12px', borderRadius: 999,
+                fontFamily: sans, fontSize: 11, color:'#fff'}}>
+                #AI写作
+              </div>
+              <div style={{background:'rgba(255,255,255,0.2)',
+                padding:'6px 12px', borderRadius: 999,
+                fontFamily: sans, fontSize: 11, color:'#fff'}}>
+                #爆款
+              </div>
+            </div>
+          </div>
+
+          {/* Good version */}
+          <div style={{position:'relative', background:'#fff',
+            border: `1px solid ${LINE}`, padding: '22px 22px'}}>
+            <div style={{position:'absolute', top: -10, left: 14,
+              background: TERRA, color:'#fff', fontFamily: mono,
+              fontSize: 9, padding:'2px 10px', letterSpacing:'0.2em'}}>
+              ✓ 精致版 · DO THIS
+            </div>
+            <div style={{fontFamily: mono, fontSize: 9, color: TERRA,
+              letterSpacing:'0.3em', marginBottom: 4}}>ESSAY · 2026.04</div>
+            <div style={{fontFamily: serif, fontSize: 26, fontWeight: 500,
+              color: INK, lineHeight: 1.2, letterSpacing:'-0.01em',
+              marginBottom: 8}}>
+              AI 写作<br/>
+              <span style={{fontStyle:'italic'}}>悄然</span>改变创作者
+            </div>
+            <div style={{height: 1, background: INK, width: 70, marginBottom: 10}}/>
+            <div style={{fontFamily: serif, fontSize: 13, color:'#444',
+              lineHeight: 1.6}}>
+              87% 的创作者已经把 AI 纳入日常工作流;
+              效率提升 3.2×,但人味不减反增——
+              工具不定义内容,品味才定义。
+            </div>
+          </div>
+        </div>
+      </div>
+
+      {/* Caption */}
+      <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 20,
+        color: ASH, textAlign:'center', marginTop: 22, opacity: captionOp,
+        letterSpacing:'0.02em'}}>
+        排版细节是 AI 分不清的 <span style={{color: TERRA, fontWeight: 500,
+          fontStyle:'normal'}}>品味税</span>
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 4: Outro (17 – 22s) ─────────────────────────────
+function Scene4_Outro() {
+  const { elapsed } = useSprite();
+  const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1]);
+  const mainY = interpolate(elapsed, [0, 1.2], [28, 0], Easing.easeOut);
+  const italicOp = interpolate(elapsed, [0.8, 1.4], [0, 1]);
+  const lineW = interpolate(elapsed, [1.0, 1.8], [0, 680]);
+  const subOp = interpolate(elapsed, [1.6, 2.2], [0, 1]);
+  const monoOp = interpolate(elapsed, [2.4, 3.2], [0, 1]);
+  const monoLineW = interpolate(elapsed, [2.8, 3.8], [0, 520]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeIn,
+      display:'flex', alignItems:'center', justifyContent:'center',
+      flexDirection:'column'}}>
+      <div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
+        color: TERRA, marginBottom: 32, opacity: fadeIn}}>
+        HUASHU-DESIGN · INFOGRAPHIC CAPABILITY
+      </div>
+
+      <div style={{fontFamily: serif, fontSize: 148, fontWeight: 500,
+        color: INK, lineHeight: 1, letterSpacing:'-0.02em',
+        transform:`translateY(${mainY}px)`}}>
+        <span style={{fontStyle:'italic', opacity: italicOp}}>数据</span>
+        <span style={{opacity: fadeIn}}> 配得上 </span>
+        <span style={{color: TERRA, opacity: italicOp}}>好看</span>
+      </div>
+
+      <div style={{height: 1, background: INK, width: lineW, marginTop: 44}}/>
+
+      <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24,
+        color: ASH, marginTop: 26, opacity: subOp, letterSpacing:'0.02em'}}>
+        印刷级 · 不因为缩放而失真
+      </div>
+
+      <div style={{marginTop: 60, opacity: monoOp,
+        display:'flex', alignItems:'center', flexDirection:'column', gap: 14}}>
+        <div style={{height: 1, background: LINE, width: monoLineW}}/>
+        <div style={{fontFamily: mono, fontSize: 14, color: INK,
+          letterSpacing:'0.18em'}}>
+          export → <span style={{color: TERRA}}>PDF 矢量</span> /
+          <span style={{color: OLIVE}}> PNG 300dpi</span> /
+          <span style={{color: DEEP_BLUE}}> SVG 原生</span>
+        </div>
+        <div style={{height: 1, background: LINE, width: monoLineW}}/>
+      </div>
+    </div>
+  );
+}
+
+// ── Watermark ─────────────────────────────────────────────
+function Watermark() {
+  return (
+    <div style={{position:'absolute', bottom: 24, right: 32,
+      fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
+      fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
+      Created by Huashu-Design
+    </div>
+  );
+}
+
+// ── Composition ───────────────────────────────────────────
+function App() {
+  return (
+    <Stage duration={22} width={1920} height={1080} bgColor={CREAM}>
+      <Sprite start={0} end={3}><Scene1_Title /></Sprite>
+      <Sprite start={3} end={10}><Scene2_Spread /></Sprite>
+      <Sprite start={10} end={17}><Scene3_Typography /></Sprite>
+      <Sprite start={17} end={22}><Scene4_Outro /></Sprite>
+      <Watermark />
+    </Stage>
+  );
+}
+
+ReactDOM.createRoot(document.getElementById('root')).render(<App />);
+</script>
+</body>
+</html>

+ 652 - 0
demos/c6-expert-review.html

@@ -0,0 +1,652 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<title>Huashu-Design · Expert Review</title>
+<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
+<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
+<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
+<style>
+  * { box-sizing: border-box; margin: 0; padding: 0; }
+  html, body { width: 100%; height: 100%; overflow: hidden; }
+  body { background: #0c0c0c; font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif; color: #1a1a1a; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
+</style>
+</head>
+<body>
+<div id="root"></div>
+
+<!-- animations.jsx inlined -->
+<script type="text/babel">
+(function() {
+  const { createContext, useContext, useState, useEffect, useRef } = React;
+  const TimeContext = createContext({ time: 0, duration: 10, playing: false });
+  const SpriteContext = createContext(null);
+  const Easing = {
+    linear: t => t,
+    easeIn: t => t * t,
+    easeOut: t => 1 - (1 - t) * (1 - t),
+    easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
+    spring: t => {
+      const c = (2 * Math.PI) / 3;
+      return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
+    },
+  };
+  function interpolate(t, input, output, easing) {
+    const [a, b] = input, [x, y] = output;
+    if (t <= a) return x; if (t >= b) return y;
+    let p = (t - a) / (b - a); if (easing) p = easing(p);
+    return x + (y - x) * p;
+  }
+  function useTime() { return useContext(TimeContext).time; }
+  function useSprite() { return useContext(SpriteContext) || { t: 0, elapsed: 0, duration: 0 }; }
+  function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
+    const [time, setTime] = useState(0);
+    const [playing, setPlaying] = useState(true);
+    const [scale, setScale] = useState(1);
+    const rafRef = useRef(null);
+    const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
+    useEffect(() => {
+      const update = () => {
+        const s = Math.min(window.innerWidth / width, (window.innerHeight - 56) / height);
+        setScale(s);
+      };
+      update(); window.addEventListener('resize', update);
+      return () => window.removeEventListener('resize', update);
+    }, [width, height]);
+    useEffect(() => {
+      if (!playing) return;
+      let cancelled = false, last = null;
+      function tick(now) {
+        if (cancelled) return;
+        if (last === null) { last = now; if (typeof window !== 'undefined') window.__ready = true; }
+        const delta = (now - last) / 1000; last = now;
+        setTime(prev => {
+          const next = prev + delta;
+          if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
+          return next;
+        });
+        rafRef.current = requestAnimationFrame(tick);
+      }
+      const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
+      if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
+      return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
+    }, [playing, duration, effectiveLoop]);
+    const progress = time / duration;
+    const ctx = { time, duration, playing, setPlaying, setTime };
+    return (
+      <TimeContext.Provider value={ctx}>
+        <div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
+          <div style={{flex:1, position:'relative', overflow:'hidden'}}>
+            <div style={{position:'absolute', top:'50%', left:'50%', transformOrigin:'center center', width, height, background: bgColor, overflow:'hidden', transform:`translate(-50%, -50%) scale(${scale})`}}>
+              {children}
+            </div>
+          </div>
+          <div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
+            <button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
+            <button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
+            <div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
+            <div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
+              <div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
+            </div>
+          </div>
+        </div>
+      </TimeContext.Provider>
+    );
+  }
+  function Sprite({ start = 0, end, children, style }) {
+    const { time } = useContext(TimeContext);
+    const actualEnd = end == null ? Infinity : end;
+    if (time < start || time >= actualEnd) return null;
+    const duration = actualEnd - start;
+    const elapsed = time - start;
+    const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
+    return (
+      <SpriteContext.Provider value={{ t, elapsed, duration, start, end: actualEnd }}>
+        <div style={{position:'absolute', inset:0, ...style}}>{children}</div>
+      </SpriteContext.Provider>
+    );
+  }
+  window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
+})();
+</script>
+
+<!-- Demo scene -->
+<script type="text/babel">
+const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
+
+// ── Design tokens ─────────────────────────────────────────
+const CREAM = '#FAF6EF';
+const INK = '#1a1a1a';
+const TERRA = '#C04A1A';
+const ASH = '#6b6b6b';
+const LINE = '#d9d2c5';
+const OLIVE = '#6a6b4e';
+const DEEP_BLUE = '#2a3552';
+
+const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
+const sans = "'Inter', -apple-system, sans-serif";
+const mono = "'JetBrains Mono', ui-monospace, monospace";
+
+// ── 5 dimensions ──────────────────────────────────────────
+const DIMENSIONS = [
+  { no: '01', name: '哲学一致性', desc: '是否遵循既定的设计风格', score: 9 },
+  { no: '02', name: '视觉层级',   desc: '信息优先级是否一目了然', score: 8 },
+  { no: '03', name: '细节执行',   desc: '排版、间距、字重是否到位', score: 7 },
+  { no: '04', name: '功能性',     desc: '交互是否顺畅、可用',       score: 6 },
+  { no: '05', name: '创新性',     desc: '是否超出了平均水准',       score: 8 },
+];
+
+const COMMENTS = [
+  '赤陶橙贯穿,serif + 留白很 Kenya Hara',
+  'Hero 和 body 强度接近,主次需再拉开',
+  '行距、字号梯度还差一点点克制',
+  'CTA 可达但色值和主色冲突',
+  '版面节奏有想法,避开了模板感',
+];
+
+// ── Scene 1: Title (0 – 3s) ────────────────────────────
+function Scene1_Title() {
+  const { elapsed } = useSprite();
+  const topOp = interpolate(elapsed, [0, 0.5], [0, 1]);
+  const titleY = interpolate(elapsed, [0.2, 1.3], [50, 0], Easing.easeOut);
+  const titleOp = interpolate(elapsed, [0.2, 1.1], [0, 1]);
+  const lineW = interpolate(elapsed, [1.0, 1.8], [0, 540]);
+  const subOp = interpolate(elapsed, [1.4, 2.1], [0, 1]);
+  const fadeOut = interpolate(elapsed, [2.6, 3.0], [1, 0]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
+      display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
+      <div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
+        color: TERRA, marginBottom: 28, opacity: topOp}}>
+        设计评审 · 5 维度评分
+      </div>
+      <div style={{fontFamily: serif, fontSize: 120, fontWeight: 500, color: INK,
+        lineHeight: 1, letterSpacing:'-0.02em', opacity: titleOp,
+        transform: `translateY(${titleY}px)`}}>
+        <span style={{fontStyle:'italic', color: TERRA}}>评</span>设计 · 不评设计师
+      </div>
+      <div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
+      <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22, color: ASH,
+        marginTop: 28, opacity: subOp, letterSpacing:'0.02em'}}>
+        做完之后 · 用 5 个刻度看清楚
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 2: 5 dimensions intro (3 – 8s) ───────────────
+function Scene2_Dimensions() {
+  const { elapsed } = useSprite();
+  const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
+  const fadeOut = interpolate(elapsed, [4.5, 5.0], [1, 0]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
+      padding: '100px 100px 80px', display:'flex', flexDirection:'column'}}>
+      <div style={{opacity: titleOp, marginBottom: 60,
+        display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+            letterSpacing:'0.3em', marginBottom: 6}}>步骤 1 / 3 · 评审维度</div>
+          <div style={{fontFamily: serif, fontSize: 60, fontWeight: 500, color: INK,
+            letterSpacing:'-0.01em'}}>
+            五把<span style={{fontStyle:'italic', color: TERRA}}>尺子</span>
+          </div>
+        </div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 19, color: ASH,
+          textAlign:'right', lineHeight: 1.5}}>
+          主观审美变不可辩论,<br/>
+          客观维度变可打分
+        </div>
+      </div>
+
+      <div style={{flex: 1, display:'grid', gridTemplateColumns:'repeat(5, 1fr)',
+        gap: 22, alignItems:'stretch'}}>
+        {DIMENSIONS.map((d, i) => {
+          const appearStart = 0.6 + i * 0.4;
+          const appearEnd = appearStart + 0.7;
+          const op = interpolate(elapsed, [appearStart, appearEnd], [0, 1], Easing.easeOut);
+          const ty = interpolate(elapsed, [appearStart, appearEnd], [30, 0], Easing.easeOut);
+          return (
+            <div key={i} style={{
+              opacity: op,
+              transform: `translateY(${ty}px)`,
+              background: '#fff',
+              border: `1px solid ${LINE}`,
+              padding: '32px 26px 30px',
+              display:'flex', flexDirection:'column',
+              position:'relative',
+            }}>
+              <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 72,
+                fontWeight: 400, color: TERRA, lineHeight: 1,
+                letterSpacing:'-0.02em', marginBottom: 20}}>
+                {d.no}
+              </div>
+              <div style={{height: 1, background: INK, width: 40, marginBottom: 18}} />
+              <div style={{fontFamily: serif, fontSize: 26, fontWeight: 500,
+                color: INK, lineHeight: 1.15, marginBottom: 12}}>
+                {d.name}
+              </div>
+              <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
+                color: ASH, lineHeight: 1.55, flex: 1}}>
+                {d.desc}
+              </div>
+              <div style={{fontFamily: mono, fontSize: 10, color: ASH,
+                letterSpacing:'0.2em', marginTop: 22}}>
+                0 – 10 PT
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 3: Radar + scoring (8 – 14s) ────────────────
+function Scene3_Radar() {
+  const { elapsed } = useSprite();
+  const headerOp = interpolate(elapsed, [0, 0.5], [0, 1]);
+  const fadeOut = interpolate(elapsed, [5.5, 6.0], [1, 0]);
+
+  // Radar reveal progress — polygon expands from center
+  const reveal = interpolate(elapsed, [0.8, 2.4], [0, 1], Easing.easeOut);
+
+  // Total score count-up
+  const totalT = interpolate(elapsed, [1.6, 2.8], [0, 1], Easing.easeOut);
+  const totalVal = Math.round(totalT * 38);
+
+  // Radar geometry
+  const cx = 340, cy = 440, R = 260;
+  const N = 5;
+  const maxScore = 10;
+  const angle = i => -Math.PI/2 + i * 2 * Math.PI / N;
+
+  // Axis endpoints
+  const axisPts = DIMENSIONS.map((_, i) => ({
+    x: cx + Math.cos(angle(i)) * R,
+    y: cy + Math.sin(angle(i)) * R,
+  }));
+
+  // Score polygon points (animated)
+  const scorePts = DIMENSIONS.map((d, i) => {
+    const r = (d.score / maxScore) * R * reveal;
+    return {
+      x: cx + Math.cos(angle(i)) * r,
+      y: cy + Math.sin(angle(i)) * r,
+    };
+  });
+  const scorePath = scorePts.map(p => `${p.x},${p.y}`).join(' ');
+
+  // Concentric rings
+  const rings = [2, 4, 6, 8, 10];
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
+      padding: '70px 90px 50px', display:'flex', flexDirection:'column'}}>
+      <div style={{opacity: headerOp, marginBottom: 24,
+        display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+            letterSpacing:'0.3em', marginBottom: 6}}>步骤 2 / 3 · 打分</div>
+          <div style={{fontFamily: serif, fontSize: 54, fontWeight: 500, color: INK,
+            letterSpacing:'-0.01em'}}>
+            五边形 · <span style={{fontStyle:'italic'}}>照见</span>每一维
+          </div>
+        </div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
+          textAlign:'right', lineHeight: 1.5}}>
+          不是给个评价,<br/>
+          是把问题「可视化」出来
+        </div>
+      </div>
+
+      <div style={{flex: 1, display:'grid', gridTemplateColumns: '720px 1fr', gap: 60}}>
+        {/* Radar */}
+        <div style={{position:'relative', background:'#fff', border:`1px solid ${LINE}`}}>
+          <svg viewBox="0 0 720 880" width="100%" height="100%" style={{display:'block'}}>
+            {/* Concentric rings */}
+            {rings.map((r, i) => {
+              const ringR = (r / maxScore) * R;
+              const pts = DIMENSIONS.map((_, k) => {
+                const x = cx + Math.cos(angle(k)) * ringR;
+                const y = cy + Math.sin(angle(k)) * ringR;
+                return `${x},${y}`;
+              }).join(' ');
+              return (
+                <g key={i}>
+                  <polygon points={pts} fill="none" stroke={LINE} strokeWidth="1" />
+                  <text x={cx + 6} y={cy - ringR + 4}
+                    fontFamily={mono} fontSize="10" fill={ASH}
+                    letterSpacing="0.1em">{r}</text>
+                </g>
+              );
+            })}
+            {/* Axes */}
+            {axisPts.map((p, i) => (
+              <line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y}
+                stroke={LINE} strokeWidth="1" />
+            ))}
+            {/* Score polygon */}
+            <polygon points={scorePath}
+              fill={TERRA} fillOpacity="0.18"
+              stroke={TERRA} strokeWidth="2" />
+            {/* Score dots */}
+            {scorePts.map((p, i) => reveal > 0.6 && (
+              <circle key={i} cx={p.x} cy={p.y} r="5"
+                fill={TERRA} opacity={Math.min(1, (reveal - 0.6) / 0.4)} />
+            ))}
+            {/* Axis labels + score */}
+            {DIMENSIONS.map((d, i) => {
+              const labelR = R + 48;
+              const lx = cx + Math.cos(angle(i)) * labelR;
+              const ly = cy + Math.sin(angle(i)) * labelR;
+              const anchor = Math.abs(Math.cos(angle(i))) < 0.2 ? 'middle'
+                : Math.cos(angle(i)) > 0 ? 'start' : 'end';
+              const showScore = elapsed > 2.4 + i * 0.15;
+              return (
+                <g key={i}>
+                  <text x={lx} y={ly}
+                    fontFamily={mono} fontSize="13" fill={INK}
+                    fontWeight="500" textAnchor={anchor}
+                    letterSpacing="0.08em">
+                    {d.name}
+                  </text>
+                  {showScore && (
+                    <text x={lx} y={ly + 20}
+                      fontFamily={serif} fontSize="22" fill={TERRA}
+                      fontStyle="italic" fontWeight="500" textAnchor={anchor}>
+                      {d.score}
+                      <tspan fontSize="13" fill={ASH} fontStyle="normal"> / 10</tspan>
+                    </text>
+                  )}
+                </g>
+              );
+            })}
+            {/* Center total score */}
+            <text x={cx} y={750}
+              fontFamily={mono} fontSize="11" fill={ASH}
+              letterSpacing="0.3em" textAnchor="middle">
+              总分
+            </text>
+            <text x={cx} y={820}
+              fontFamily={serif} fontSize="72" fill={INK}
+              fontWeight="500" textAnchor="middle"
+              letterSpacing="-0.02em">
+              <tspan fontStyle="italic" fill={TERRA}>{totalVal}</tspan>
+              <tspan fontSize="34" fill={ASH} letterSpacing="0"> / 50</tspan>
+            </text>
+          </svg>
+        </div>
+
+        {/* Right: breakdown list */}
+        <div style={{display:'flex', flexDirection:'column', gap: 18, paddingTop: 4}}>
+          <div style={{fontFamily: mono, fontSize: 10, color: ASH,
+            letterSpacing:'0.25em', marginBottom: 4}}>
+            BREAKDOWN · 逐项
+          </div>
+          {DIMENSIONS.map((d, i) => {
+            const rowAppear = 2.2 + i * 0.25;
+            const op = interpolate(elapsed, [rowAppear, rowAppear + 0.6], [0, 1]);
+            const barT = interpolate(elapsed, [rowAppear + 0.2, rowAppear + 0.9],
+              [0, d.score / 10], Easing.easeOut);
+            const tx = interpolate(elapsed, [rowAppear, rowAppear + 0.5], [20, 0], Easing.easeOut);
+            return (
+              <div key={i} style={{opacity: op, transform:`translateX(${tx}px)`,
+                background:'#fff', border:`1px solid ${LINE}`, padding:'14px 20px'}}>
+                <div style={{display:'flex', justifyContent:'space-between',
+                  alignItems:'baseline', marginBottom: 8}}>
+                  <div style={{fontFamily: serif, fontSize: 22, fontWeight: 500, color: INK}}>
+                    <span style={{fontFamily: mono, fontSize: 11, color: TERRA,
+                      marginRight: 12, letterSpacing:'0.15em'}}>{d.no}</span>
+                    {d.name}
+                  </div>
+                  <div style={{fontFamily: serif, fontSize: 22, fontStyle:'italic',
+                    fontWeight: 500, color: TERRA}}>
+                    {d.score}<span style={{fontSize: 13, color: ASH, fontStyle:'normal'}}> / 10</span>
+                  </div>
+                </div>
+                {/* Progress bar */}
+                <div style={{height: 4, background: LINE, position:'relative', marginBottom: 8}}>
+                  <div style={{position:'absolute', top:0, left:0, height:'100%',
+                    width: `${barT * 100}%`, background: TERRA}} />
+                </div>
+                <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 14,
+                  color: ASH, lineHeight: 1.5}}>
+                  {COMMENTS[i]}
+                </div>
+              </div>
+            );
+          })}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 4: Keep / Fix / Quick Wins (14 – 20s) ──────
+function Scene4_Actions() {
+  const { elapsed } = useSprite();
+  const headerOp = interpolate(elapsed, [0, 0.4], [0, 1]);
+  const fadeOut = interpolate(elapsed, [5.5, 6.0], [1, 0]);
+
+  const keeps = [
+    '赤陶橙 accent 贯穿全文',
+    'serif display 给了文学气质',
+    '留白足够 · 信息不挤',
+  ];
+  const fixes = [
+    { tag: '致命', sev: TERRA,   text: 'Hero 图和 body 抢焦点 · 降低 hero 字号' },
+    { tag: '重要', sev: OLIVE,   text: '侧边 CTA 色和品牌主色冲突' },
+    { tag: '优化', sev: ASH,     text: 'Footer 字号可以再小 2px' },
+  ];
+  const wins = [
+    'Hero 字号 96 → 72',
+    'CTA 改成 terra 主色',
+    'Footer 字号 14 → 12',
+  ];
+
+  const col1T = interpolate(elapsed, [0.4, 1.2], [0, 1], Easing.easeOut);
+  const col2T = interpolate(elapsed, [0.8, 1.6], [0, 1], Easing.easeOut);
+  const col3T = interpolate(elapsed, [1.2, 2.0], [0, 1], Easing.easeOut);
+  const footerOp = interpolate(elapsed, [3.8, 4.6], [0, 1]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeOut,
+      padding: '70px 90px 60px', display:'flex', flexDirection:'column'}}>
+      <div style={{opacity: headerOp, marginBottom: 40,
+        display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+            letterSpacing:'0.3em', marginBottom: 6}}>步骤 3 / 3 · 行动清单</div>
+          <div style={{fontFamily: serif, fontSize: 56, fontWeight: 500, color: INK,
+            letterSpacing:'-0.01em'}}>
+            Keep · Fix · <span style={{fontStyle:'italic', color: TERRA}}>Quick Wins</span>
+          </div>
+        </div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH,
+          textAlign:'right', lineHeight: 1.5}}>
+          打完分 · 不是扔下报告,<br/>
+          是给一张可执行的「修复清单」
+        </div>
+      </div>
+
+      <div style={{flex: 1, display:'grid', gridTemplateColumns:'1fr 1.15fr 1fr',
+        gap: 28}}>
+        {/* KEEP */}
+        <div style={{opacity: col1T, transform:`translateY(${(1-col1T)*20}px)`,
+          background:'#fff', border:`1px solid ${LINE}`,
+          borderTop: `4px solid ${OLIVE}`, padding: '30px 30px 28px',
+          display:'flex', flexDirection:'column'}}>
+          <div style={{display:'flex', justifyContent:'space-between',
+            alignItems:'baseline', marginBottom: 24}}>
+            <div>
+              <div style={{fontFamily: mono, fontSize: 11, color: OLIVE,
+                letterSpacing:'0.3em', marginBottom: 8}}>KEEP</div>
+              <div style={{fontFamily: serif, fontSize: 32, fontWeight: 500, color: INK}}>
+                保持这些
+              </div>
+            </div>
+            <div style={{fontFamily: serif, fontSize: 44, fontStyle:'italic',
+              fontWeight: 400, color: OLIVE, lineHeight: 1}}>
+              3
+            </div>
+          </div>
+          <div style={{display:'flex', flexDirection:'column', gap: 18, flex: 1}}>
+            {keeps.map((k, i) => (
+              <div key={i} style={{display:'flex', gap: 14, alignItems:'flex-start'}}>
+                <div style={{fontFamily: mono, fontSize: 16, color: OLIVE,
+                  fontWeight: 600, marginTop: 2}}>✓</div>
+                <div style={{fontFamily: serif, fontSize: 19, color: INK,
+                  lineHeight: 1.5, flex: 1}}>{k}</div>
+              </div>
+            ))}
+          </div>
+        </div>
+
+        {/* FIX */}
+        <div style={{opacity: col2T, transform:`translateY(${(1-col2T)*20}px)`,
+          background:'#fff', border:`1px solid ${LINE}`,
+          borderTop: `4px solid ${TERRA}`, padding: '30px 30px 28px',
+          display:'flex', flexDirection:'column'}}>
+          <div style={{display:'flex', justifyContent:'space-between',
+            alignItems:'baseline', marginBottom: 24}}>
+            <div>
+              <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+                letterSpacing:'0.3em', marginBottom: 8}}>FIX</div>
+              <div style={{fontFamily: serif, fontSize: 32, fontWeight: 500, color: INK}}>
+                需修复 · 按严重度
+              </div>
+            </div>
+            <div style={{fontFamily: serif, fontSize: 44, fontStyle:'italic',
+              fontWeight: 400, color: TERRA, lineHeight: 1}}>
+              3
+            </div>
+          </div>
+          <div style={{display:'flex', flexDirection:'column', gap: 16, flex: 1}}>
+            {fixes.map((f, i) => (
+              <div key={i} style={{display:'flex', gap: 14, alignItems:'flex-start',
+                paddingBottom: 14, borderBottom: i < fixes.length - 1 ? `1px solid ${LINE}` : 'none'}}>
+                <div style={{
+                  background: f.sev, color: '#fff',
+                  fontFamily: mono, fontSize: 10, letterSpacing:'0.15em',
+                  padding: '4px 10px', marginTop: 4, minWidth: 58,
+                  textAlign: 'center', fontWeight: 600,
+                }}>
+                  {f.tag}
+                </div>
+                <div style={{fontFamily: serif, fontSize: 17, color: INK,
+                  lineHeight: 1.5, flex: 1}}>{f.text}</div>
+              </div>
+            ))}
+          </div>
+        </div>
+
+        {/* QUICK WINS */}
+        <div style={{opacity: col3T, transform:`translateY(${(1-col3T)*20}px)`,
+          background:'#fff', border:`1px solid ${LINE}`,
+          borderTop: `4px solid ${DEEP_BLUE}`, padding: '30px 30px 28px',
+          display:'flex', flexDirection:'column'}}>
+          <div style={{display:'flex', justifyContent:'space-between',
+            alignItems:'baseline', marginBottom: 24}}>
+            <div>
+              <div style={{fontFamily: mono, fontSize: 11, color: DEEP_BLUE,
+                letterSpacing:'0.3em', marginBottom: 8}}>QUICK WINS</div>
+              <div style={{fontFamily: serif, fontSize: 32, fontWeight: 500, color: INK}}>
+                5 分钟能做的
+              </div>
+            </div>
+            <div style={{fontFamily: mono, fontSize: 10, color: ASH,
+              letterSpacing:'0.2em', textAlign:'right', lineHeight: 1.6}}>
+              TOP<br/>3
+            </div>
+          </div>
+          <div style={{display:'flex', flexDirection:'column', gap: 18, flex: 1}}>
+            {wins.map((w, i) => (
+              <div key={i} style={{display:'flex', gap: 16, alignItems:'flex-start'}}>
+                <div style={{fontFamily: serif, fontSize: 32, fontStyle:'italic',
+                  color: DEEP_BLUE, fontWeight: 400, lineHeight: 1, minWidth: 32,
+                  marginTop: -4}}>
+                  {i+1}
+                </div>
+                <div style={{fontFamily: serif, fontSize: 19, color: INK,
+                  lineHeight: 1.5, flex: 1}}>{w}</div>
+              </div>
+            ))}
+          </div>
+        </div>
+      </div>
+
+      {/* Footer slogan */}
+      <div style={{marginTop: 36, textAlign:'center', opacity: footerOp}}>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 26,
+          color: TERRA, letterSpacing:'0.02em'}}>
+          不是给个评价 · 是给个修复清单
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 5: Outro (20 – 22s) ─────────────────────────
+function Scene5_Outro() {
+  const { elapsed } = useSprite();
+  const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1], Easing.easeOut);
+  const titleY = interpolate(elapsed, [0, 1.0], [40, 0], Easing.easeOut);
+  const lineW = interpolate(elapsed, [0.5, 1.3], [0, 540]);
+  const subOp = interpolate(elapsed, [0.9, 1.6], [0, 1]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background: CREAM, opacity: fadeIn,
+      display:'flex', alignItems:'center', justifyContent:'center',
+      flexDirection:'column'}}>
+      <div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
+        color: TERRA, marginBottom: 22}}>
+        5 维度 · 客观 · 可操作
+      </div>
+      <div style={{fontFamily: serif, fontSize: 108, fontWeight: 500,
+        color: INK, lineHeight: 1, letterSpacing:'-0.015em',
+        transform: `translateY(${titleY}px)`}}>
+        先打分 · 再<span style={{fontStyle:'italic', color: TERRA}}>修</span>
+      </div>
+      <div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
+      <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22, color: ASH,
+        marginTop: 26, opacity: subOp, letterSpacing:'0.02em'}}>
+        Huashu-Design · Expert Review
+      </div>
+    </div>
+  );
+}
+
+// ── Watermark ──────────────────────────────────────────
+function Watermark() {
+  return (
+    <div style={{position:'absolute', bottom: 24, right: 32,
+      fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
+      fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
+      Created by Huashu-Design
+    </div>
+  );
+}
+
+function App() {
+  return (
+    <Stage duration={22} width={1920} height={1080} bgColor={CREAM}>
+      <Sprite start={0} end={3}><Scene1_Title /></Sprite>
+      <Sprite start={3} end={8}><Scene2_Dimensions /></Sprite>
+      <Sprite start={8} end={14}><Scene3_Radar /></Sprite>
+      <Sprite start={14} end={20}><Scene4_Actions /></Sprite>
+      <Sprite start={20} end={22}><Scene5_Outro /></Sprite>
+      <Watermark />
+    </Stage>
+  );
+}
+
+ReactDOM.createRoot(document.getElementById('root')).render(<App />);
+</script>
+</body>
+</html>

+ 787 - 0
demos/w1-brand-protocol.html

@@ -0,0 +1,787 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<title>Huashu-Design · 品牌资产协议 5 步硬流程</title>
+<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
+<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
+<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
+<style>
+  * { box-sizing: border-box; margin: 0; padding: 0; }
+  html, body { width: 100%; height: 100%; overflow: hidden; }
+  body {
+    background: #0c0c0c;
+    font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
+    color: #1a1a1a;
+    -webkit-font-smoothing: antialiased;
+    text-rendering: optimizeLegibility;
+  }
+</style>
+</head>
+<body>
+<div id="root"></div>
+
+<!-- animations.jsx inlined -->
+<script type="text/babel">
+(function() {
+  const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
+  const TimeContext = createContext({ time: 0, duration: 10, playing: false });
+  const SpriteContext = createContext(null);
+
+  const Easing = {
+    linear: t => t,
+    easeIn: t => t * t,
+    easeOut: t => 1 - (1 - t) * (1 - t),
+    easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
+    spring: t => {
+      const c = (2 * Math.PI) / 3;
+      return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
+    },
+  };
+
+  function interpolate(t, input, output, easing) {
+    const [inStart, inEnd] = input;
+    const [outStart, outEnd] = output;
+    if (t <= inStart) return outStart;
+    if (t >= inEnd) return outEnd;
+    let progress = (t - inStart) / (inEnd - inStart);
+    if (easing) progress = easing(progress);
+    return outStart + (outEnd - outStart) * progress;
+  }
+
+  function useTime() { return useContext(TimeContext).time; }
+  function useSprite() {
+    const sprite = useContext(SpriteContext);
+    return sprite || { t: 0, elapsed: 0, duration: 0 };
+  }
+
+  function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
+    const [time, setTime] = useState(0);
+    const [playing, setPlaying] = useState(true);
+    const [scale, setScale] = useState(1);
+    const rafRef = useRef(null);
+    const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
+
+    useEffect(() => {
+      function updateScale() {
+        const vw = window.innerWidth;
+        const vh = window.innerHeight - 56;
+        const s = Math.min(vw / width, vh / height);
+        setScale(s);
+      }
+      updateScale();
+      window.addEventListener('resize', updateScale);
+      return () => window.removeEventListener('resize', updateScale);
+    }, [width, height]);
+
+    useEffect(() => {
+      if (!playing) return;
+      let cancelled = false;
+      let last = null;
+      function tick(now) {
+        if (cancelled) return;
+        if (last === null) {
+          last = now;
+          if (typeof window !== 'undefined') window.__ready = true;
+        }
+        const delta = (now - last) / 1000;
+        last = now;
+        setTime(prev => {
+          const next = prev + delta;
+          if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
+          return next;
+        });
+        rafRef.current = requestAnimationFrame(tick);
+      }
+      const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
+      if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
+      return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
+    }, [playing, duration, effectiveLoop]);
+
+    const progress = time / duration;
+    const ctx = { time, duration, playing, setPlaying, setTime };
+
+    const canvasStyle = {
+      position: 'absolute',
+      top: '50%',
+      left: '50%',
+      transformOrigin: 'center center',
+      width,
+      height,
+      background: bgColor,
+      overflow: 'hidden',
+      transform: `translate(-50%, -50%) scale(${scale})`,
+    };
+
+    return (
+      <TimeContext.Provider value={ctx}>
+        <div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
+          <div style={{flex:1, position:'relative', overflow:'hidden'}}>
+            <div style={canvasStyle}>{children}</div>
+          </div>
+          <div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', backdropFilter:'blur(10px)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
+            <button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
+            <button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
+            <div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
+            <div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
+              <div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
+            </div>
+          </div>
+        </div>
+      </TimeContext.Provider>
+    );
+  }
+
+  function Sprite({ start = 0, end, children, style }) {
+    const { time } = useContext(TimeContext);
+    const actualEnd = end == null ? Infinity : end;
+    if (time < start || time >= actualEnd) return null;
+    const duration = actualEnd - start;
+    const elapsed = time - start;
+    const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
+    const spriteValue = { t, elapsed, duration, start, end: actualEnd };
+    return (
+      <SpriteContext.Provider value={spriteValue}>
+        <div style={{position:'absolute', inset:0, ...style}}>{children}</div>
+      </SpriteContext.Provider>
+    );
+  }
+
+  window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
+})();
+</script>
+
+<!-- Demo scene -->
+<script type="text/babel">
+const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
+
+// ── Design tokens ─────────────────────────────────────────
+const CREAM = '#FAF6EF';
+const INK = '#1a1a1a';
+const TERRA = '#C04A1A';
+const ASH = '#6b6b6b';
+const LINE = '#d9d2c5';
+const OLIVE = '#6a6b4e';
+
+const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
+const sans = "'Inter', -apple-system, sans-serif";
+const mono = "'JetBrains Mono', ui-monospace, monospace";
+
+// ── Scene 1: Trigger (0 – 3s) ─────────────────────────────
+function Scene1_Trigger() {
+  const { elapsed } = useSprite();
+  const labelOp = interpolate(elapsed, [0, 0.5], [0, 1]);
+  const titleY = interpolate(elapsed, [0, 1], [30, 0], Easing.easeOut);
+  const titleOp = interpolate(elapsed, [0.2, 1], [0, 1]);
+  const brandsOp = interpolate(elapsed, [1, 1.6], [0, 1]);
+  const switchOp = interpolate(elapsed, [1.9, 2.4], [0, 1]);
+  const fadeOut = interpolate(elapsed, [2.6, 3], [1, 0]);
+
+  const brands = ['Kimi', 'Linear', 'Lovart', 'Stripe'];
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
+      display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
+      <div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
+        color: TERRA, marginBottom: 28, opacity: labelOp}}>
+        品 牌 资 产 协 议 · BRAND PROTOCOL
+      </div>
+      <div style={{fontFamily: serif, fontSize: 120, fontWeight: 500, color: INK,
+        lineHeight: 1, letterSpacing:'-0.01em',
+        opacity: titleOp, transform: `translateY(${titleY}px)`}}>
+        涉及具体品牌<span style={{color: TERRA, fontStyle:'italic'}}>?</span>
+      </div>
+
+      <div style={{display:'flex', gap: 48, marginTop: 64, opacity: brandsOp}}>
+        {brands.map((b, i) => (
+          <span key={i} style={{fontFamily: serif, fontStyle:'italic',
+            fontSize: 44, color: ASH, letterSpacing:'0.02em'}}>
+            {b}
+          </span>
+        ))}
+      </div>
+
+      <div style={{marginTop: 72, opacity: switchOp, textAlign:'center'}}>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 34,
+          color: TERRA, letterSpacing:'0.02em'}}>
+          先停下——走 5 步硬流程
+        </div>
+        <div style={{height:1, background: TERRA, width: 180, margin:'18px auto 0'}} />
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 2: Step 1 & 2 (3 – 7s) ──────────────────────────
+function Scene2_AskAndSearch() {
+  const { elapsed } = useSprite();
+  const headOp = interpolate(elapsed, [0, 0.4], [0, 1]);
+  const leftOp = interpolate(elapsed, [0.3, 0.9], [0, 1]);
+  const leftY = interpolate(elapsed, [0.3, 0.9], [20, 0], Easing.easeOut);
+  const rightOp = interpolate(elapsed, [0.8, 1.4], [0, 1]);
+  const rightY = interpolate(elapsed, [0.8, 1.4], [20, 0], Easing.easeOut);
+  const fadeOut = interpolate(elapsed, [3.5, 4], [1, 0]);
+
+  // cursor sweeping through search paths 1.6 → 3.2
+  const paths = [
+    '<brand>.com/brand',
+    '<brand>.com/press',
+    'brand.<brand>.com',
+    '<brand>.github.io/brand',
+  ];
+  // current active index based on time
+  const activeIdx = Math.min(3, Math.max(0, Math.floor((elapsed - 1.6) / 0.4)));
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
+      padding:'72px 120px', display:'flex', flexDirection:'column'}}>
+      <div style={{display:'flex', justifyContent:'space-between',
+        alignItems:'baseline', opacity: headOp, marginBottom: 48}}>
+        <div style={{fontFamily: serif, fontSize: 44, fontWeight: 500, color: INK}}>
+          Step 1 & 2 · 问 · 搜
+        </div>
+        <div style={{fontFamily: mono, fontSize: 11, color: ASH, letterSpacing:'0.25em'}}>
+          01 / 05  →  02 / 05
+        </div>
+      </div>
+
+      <div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap: 48, flex:1}}>
+        {/* Left: Step 1 Ask */}
+        <div style={{opacity: leftOp, transform:`translateY(${leftY}px)`,
+          display:'flex', flexDirection:'column', gap: 24}}>
+          <div style={{display:'flex', alignItems:'baseline', gap: 18}}>
+            <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+              letterSpacing:'0.3em'}}>STEP · 01</div>
+            <div style={{fontFamily: serif, fontSize: 40, color: INK,
+              fontWeight: 500}}>问</div>
+          </div>
+
+          <div style={{background:'#fff', border:`1px solid ${LINE}`,
+            padding:'32px 34px', position:'relative'}}>
+            <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 64,
+              color: TERRA, lineHeight:1, position:'absolute',
+              top: 8, left: 14}}>「</div>
+            <div style={{fontFamily: serif, fontSize: 24, color: INK,
+              lineHeight: 1.55, paddingLeft: 36}}>
+              这个品牌有 <span style={{fontStyle:'italic', color: TERRA}}>brand guidelines</span> 吗?
+            </div>
+            <div style={{height:1, background: LINE, margin:'22px 0'}} />
+            <div style={{fontFamily: serif, fontSize: 20, color: ASH,
+              lineHeight: 1.6, paddingLeft: 36}}>
+              有的话直接给我;没有我去搜。
+            </div>
+          </div>
+
+          <div style={{fontFamily: mono, fontSize: 11, color: ASH,
+            letterSpacing:'0.15em', marginTop: 6}}>
+            ▸ 用户提供 &gt; AI 猜测
+          </div>
+        </div>
+
+        {/* Right: Step 2 Search */}
+        <div style={{opacity: rightOp, transform:`translateY(${rightY}px)`,
+          display:'flex', flexDirection:'column', gap: 24}}>
+          <div style={{display:'flex', alignItems:'baseline', gap: 18}}>
+            <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+              letterSpacing:'0.3em'}}>STEP · 02</div>
+            <div style={{fontFamily: serif, fontSize: 40, color: INK,
+              fontWeight: 500}}>搜 官 方 品 牌 页</div>
+          </div>
+
+          <div style={{background:'#1a1a1a', color:'#e8e3d6',
+            padding:'24px 28px', fontFamily: mono, fontSize: 16,
+            lineHeight: 1.9, flex: 1}}>
+            <div style={{color:'#6b6b6b', fontSize: 11,
+              letterSpacing:'0.2em', marginBottom: 14}}>
+              $ HTTP GET · typical paths
+            </div>
+            {paths.map((p, i) => {
+              const isActive = i === activeIdx && elapsed > 1.6 && elapsed < 3.4;
+              const visited = i < activeIdx;
+              return (
+                <div key={i} style={{
+                  color: isActive ? TERRA : (visited ? '#8a8878' : '#e8e3d6'),
+                  background: isActive ? 'rgba(192,74,26,0.12)' : 'transparent',
+                  padding:'2px 8px', marginLeft:-8,
+                  display:'flex', alignItems:'baseline', gap: 14}}>
+                  <span style={{color: isActive ? TERRA : '#555', fontSize: 13}}>
+                    {isActive ? '▸' : (visited ? '✓' : ' ')}
+                  </span>
+                  <span style={{letterSpacing:'0.02em'}}>{p}</span>
+                </div>
+              );
+            })}
+          </div>
+
+          <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
+            color: ASH, marginTop: 6}}>
+            按顺序试,中断也要记 404 状态
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 3: Step 3 · Three fallbacks (7 – 12s) ───────────
+function Scene3_Fallbacks() {
+  const { elapsed } = useSprite();
+  const headOp = interpolate(elapsed, [0, 0.4], [0, 1]);
+  const cardsOp = interpolate(elapsed, [0.3, 1], [0, 1]);
+  const fadeOut = interpolate(elapsed, [4.5, 5], [1, 0]);
+
+  // Card state: 0 idle, 1 active/lit, 2 failed/gray, 3 success
+  // Card 1 active 1.2→2.0, then fails at 2.0
+  // Card 2 active 2.2→3.0, then fails at 3.0
+  // Card 3 active 3.2→4.2, succeeds at 4.2
+  const card1State = elapsed < 1.2 ? 0 : elapsed < 2.0 ? 1 : 2;
+  const card2State = elapsed < 2.2 ? 0 : elapsed < 3.0 ? 1 : (elapsed < 3.2 ? 2 : 2);
+  const card3State = elapsed < 3.2 ? 0 : elapsed < 4.2 ? 1 : 3;
+
+  const captionOp = interpolate(elapsed, [4.2, 4.6], [0, 1]);
+
+  const cards = [
+    { n: '01', title: 'SVG 文件', cmd: 'curl -o logo.svg <url>', status: '最理想', state: card1State },
+    { n: '02', title: '官网 HTML', cmd: 'curl -A "Mozilla/5.0" \\\n  -L <url>', status: '80% 场景必用', state: card2State },
+    { n: '03', title: '产品截图取色', cmd: 'macOS Preview · 吸管', status: 'App 必走', state: card3State },
+  ];
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
+      padding:'70px 100px 60px', display:'flex', flexDirection:'column'}}>
+      <div style={{opacity: headOp, marginBottom: 40}}>
+        <div style={{display:'flex', alignItems:'baseline', gap: 18, marginBottom: 6}}>
+          <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+            letterSpacing:'0.3em'}}>STEP · 03</div>
+          <div style={{fontFamily: serif, fontSize: 18, color: ASH,
+            fontStyle:'italic'}}>下 · download</div>
+        </div>
+        <div style={{fontFamily: serif, fontSize: 60, fontWeight: 500,
+          color: INK, letterSpacing:'-0.01em'}}>
+          三条兜底路径
+        </div>
+        <div style={{height:1, background: LINE, width:'100%', marginTop: 20}} />
+      </div>
+
+      <div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr',
+        gap: 28, flex:1, opacity: cardsOp}}>
+        {cards.map((c, i) => {
+          const isIdle = c.state === 0;
+          const isActive = c.state === 1;
+          const isFailed = c.state === 2;
+          const isSuccess = c.state === 3;
+
+          const borderColor = isSuccess ? TERRA :
+                              isActive ? INK :
+                              isFailed ? '#c4bda7' : LINE;
+          const borderWidth = (isActive || isSuccess) ? 2 : 1;
+          const op = isFailed ? 0.42 : 1;
+          const bg = isSuccess ? '#fffaf3' : '#fff';
+
+          return (
+            <div key={i} style={{
+              background: bg,
+              border:`${borderWidth}px solid ${borderColor}`,
+              opacity: op,
+              padding:'28px 28px 24px',
+              display:'flex', flexDirection:'column',
+              position:'relative',
+              transition: 'none',
+            }}>
+              <div style={{display:'flex', justifyContent:'space-between',
+                alignItems:'baseline', marginBottom: 20}}>
+                <div style={{fontFamily: serif, fontSize: 56, fontWeight: 300,
+                  color: isSuccess ? TERRA : INK, lineHeight:1}}>
+                  {c.n}
+                </div>
+                <div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.2em',
+                  color: isSuccess ? TERRA : isFailed ? ASH : INK}}>
+                  {isIdle && 'READY'}
+                  {isActive && '▸ TRYING…'}
+                  {isFailed && '× FAILED'}
+                  {isSuccess && '✓ GOT IT'}
+                </div>
+              </div>
+
+              <div style={{fontFamily: serif, fontSize: 28, fontWeight: 500,
+                color: INK, marginBottom: 14, letterSpacing:'-0.005em'}}>
+                {c.title}
+              </div>
+
+              <div style={{background:'#1a1a1a', color:'#e8e3d6',
+                fontFamily: mono, fontSize: 12, padding:'14px 16px',
+                whiteSpace:'pre-wrap', lineHeight: 1.6,
+                marginBottom: 20, borderLeft: `2px solid ${isSuccess ? TERRA : '#333'}`}}>
+                {c.cmd}
+              </div>
+
+              <div style={{marginTop:'auto', borderTop:`1px solid ${LINE}`,
+                paddingTop: 14, display:'flex', justifyContent:'space-between',
+                alignItems:'baseline'}}>
+                <div style={{fontFamily: serif, fontStyle:'italic',
+                  fontSize: 15, color: ASH}}>
+                  场景
+                </div>
+                <div style={{fontFamily: serif, fontSize: 16,
+                  color: isSuccess ? TERRA : INK, fontWeight: 500}}>
+                  {c.status}
+                </div>
+              </div>
+
+              {isSuccess && (
+                <div style={{position:'absolute', top:-10, right:-10,
+                  width: 28, height: 28, borderRadius:'50%', background: TERRA,
+                  color:'#fff', display:'flex', alignItems:'center',
+                  justifyContent:'center', fontFamily: serif, fontSize: 14,
+                  fontWeight: 600}}>
+                  ✓
+                </div>
+              )}
+            </div>
+          );
+        })}
+      </div>
+
+      <div style={{opacity: captionOp, marginTop: 22, textAlign:'center',
+        fontFamily: serif, fontStyle:'italic', fontSize: 22, color: INK}}>
+        前一条失败,<span style={{color: TERRA}}>立刻</span>走下一条——不要停
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 4: Step 4 · Grep colors (12 – 17s) ──────────────
+function Scene4_GrepColors() {
+  const { elapsed } = useSprite();
+  const headOp = interpolate(elapsed, [0, 0.4], [0, 1]);
+  const cmdOp = interpolate(elapsed, [0.4, 0.9], [0, 1]);
+  const fadeOut = interpolate(elapsed, [4.5, 5], [1, 0]);
+
+  const results = [
+    { count: 47, hex: '#1783FF', label: 'Kimi primary' },
+    { count: 32, hex: '#FAFAFA', label: 'background' },
+    { count: 18, hex: '#1a1a1a', label: 'ink' },
+    { count: 12, hex: '#FF6B35', label: 'accent' },
+    { count:  8, hex: '#6A6B4E', label: 'muted' },
+  ];
+
+  // Stagger each result row, starting at t=1.5
+  const rowStart = 1.5;
+  const rowStep = 0.32;
+
+  const captionOp = interpolate(elapsed, [3.6, 4.1], [0, 1]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background:'#1a1a1a', opacity: fadeOut,
+      padding:'64px 100px', display:'flex', flexDirection:'column',
+      color:'#e8e3d6'}}>
+      <div style={{display:'flex', justifyContent:'space-between',
+        alignItems:'baseline', marginBottom: 36, opacity: headOp}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+            letterSpacing:'0.3em', marginBottom: 4}}>STEP · 04</div>
+          <div style={{fontFamily: serif, fontSize: 54, fontWeight: 500,
+            color:'#faf6ef', letterSpacing:'-0.01em'}}>
+            Grep 色 值
+          </div>
+        </div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
+          color:'#8a8878', textAlign:'right'}}>
+          频次排序 = 品牌色权重<br/>
+          <span style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.2em'}}>
+            / DON'T GUESS · COUNT
+          </span>
+        </div>
+      </div>
+
+      {/* Command */}
+      <div style={{opacity: cmdOp, background:'#0e0e0e',
+        border:'1px solid #333', padding:'20px 26px',
+        fontFamily: mono, fontSize: 15, lineHeight: 1.7,
+        marginBottom: 28}}>
+        <div style={{color: TERRA, fontSize: 10, letterSpacing:'0.2em',
+          marginBottom: 10}}>$ COMMAND</div>
+        <div style={{color:'#e8e3d6'}}>
+          <span style={{color:'#888'}}>$</span>{' '}
+          <span style={{color:'#7dd3fc'}}>grep</span>{' '}
+          <span style={{color:'#fbbf24'}}>-hoE</span>{' '}
+          <span style={{color:'#a5f3c5'}}>'#[0-9A-Fa-f]{'{6}'}'</span>{' '}
+          <span style={{color:'#e8e3d6'}}>assets/*.{'{svg,html}'}</span> \<br/>
+          {'  '}<span style={{color:'#666'}}>|</span>{' '}
+          <span style={{color:'#7dd3fc'}}>sort</span>{' '}
+          <span style={{color:'#666'}}>|</span>{' '}
+          <span style={{color:'#7dd3fc'}}>uniq -c</span>{' '}
+          <span style={{color:'#666'}}>|</span>{' '}
+          <span style={{color:'#7dd3fc'}}>sort -rn</span>{' '}
+          <span style={{color:'#666'}}>|</span>{' '}
+          <span style={{color:'#7dd3fc'}}>head -20</span>
+        </div>
+      </div>
+
+      {/* Results + swatches */}
+      <div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap: 60,
+        flex: 1}}>
+        {/* Left: result list */}
+        <div style={{fontFamily: mono, fontSize: 18, lineHeight: 2}}>
+          <div style={{color:'#555', fontSize: 10, letterSpacing:'0.2em',
+            marginBottom: 12}}>▸ RESULT · top 5</div>
+          {results.map((r, i) => {
+            const visible = elapsed > (rowStart + i * rowStep);
+            if (!visible) return <div key={i} style={{height: 36}}/>;
+            const fadeIn = interpolate(elapsed,
+              [rowStart + i*rowStep, rowStart + i*rowStep + 0.3],
+              [0, 1]);
+            return (
+              <div key={i} style={{opacity: fadeIn, display:'flex',
+                alignItems:'center', gap: 20}}>
+                <span style={{color:'#fbbf24', minWidth: 40, textAlign:'right'}}>
+                  {String(r.count).padStart(3, ' ').replace(/ /g, '\u00A0')}
+                </span>
+                <span style={{color:'#e8e3d6'}}>{r.hex}</span>
+                <span style={{color:'#666', fontSize: 14}}>←</span>
+                <span style={{color:'#8a8878', fontStyle:'italic',
+                  fontFamily: serif, fontSize: 16}}>{r.label}</span>
+              </div>
+            );
+          })}
+        </div>
+
+        {/* Right: color swatches */}
+        <div style={{display:'flex', flexDirection:'column', gap: 14}}>
+          <div style={{color:'#555', fontFamily: mono, fontSize: 10,
+            letterSpacing:'0.2em', marginBottom: 2}}>▸ PALETTE</div>
+          {results.map((r, i) => {
+            const visible = elapsed > (rowStart + i * rowStep);
+            if (!visible) return <div key={i} style={{height: 54}}/>;
+            const fadeIn = interpolate(elapsed,
+              [rowStart + i*rowStep, rowStart + i*rowStep + 0.3],
+              [0, 1]);
+            const w = interpolate(elapsed,
+              [rowStart + i*rowStep, rowStart + i*rowStep + 0.5],
+              [0, r.count * 9]);
+            return (
+              <div key={i} style={{opacity: fadeIn, display:'flex',
+                alignItems:'center', gap: 14, height: 50}}>
+                <div style={{width: 50, height: 50, background: r.hex,
+                  border:'1px solid #333', flexShrink: 0}} />
+                <div style={{height: 24, background: r.hex,
+                  width: `${w}px`, opacity: 0.7}} />
+                <div style={{fontFamily: mono, fontSize: 12,
+                  color:'#8a8878'}}>×{r.count}</div>
+              </div>
+            );
+          })}
+        </div>
+      </div>
+
+      {/* Key caption */}
+      <div style={{opacity: captionOp, textAlign:'center', marginTop: 18,
+        paddingTop: 18, borderTop:'1px solid #333'}}>
+        <span style={{fontFamily: serif, fontStyle:'italic', fontSize: 26,
+          color: TERRA}}>
+          不要凭记忆猜品牌色
+        </span>
+        <span style={{fontFamily: serif, fontSize: 20, color:'#8a8878',
+          marginLeft: 14}}>
+          ——频次说了算
+        </span>
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 5: Step 5 · brand-spec.md (17 – 22s) ────────────
+function Scene5_SpecFile() {
+  const { elapsed } = useSprite();
+  const headOp = interpolate(elapsed, [0, 0.4], [0, 1]);
+  const mdOp = interpolate(elapsed, [0.3, 0.9], [0, 1]);
+  const mdX = interpolate(elapsed, [0.3, 0.9], [-40, 0], Easing.easeOut);
+  const cssOp = interpolate(elapsed, [0.9, 1.5], [0, 1]);
+  const cssX = interpolate(elapsed, [0.9, 1.5], [40, 0], Easing.easeOut);
+  const arrowOp = interpolate(elapsed, [2.2, 2.7], [0, 1]);
+  const captionOp = interpolate(elapsed, [3.4, 3.9], [0, 1]);
+  const fadeOut = interpolate(elapsed, [4.5, 5], [1, 0]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
+      padding:'64px 80px 48px', display:'flex', flexDirection:'column'}}>
+      <div style={{opacity: headOp, marginBottom: 28, display:'flex',
+        justifyContent:'space-between', alignItems:'baseline'}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+            letterSpacing:'0.3em', marginBottom: 4}}>STEP · 05 · FINAL</div>
+          <div style={{fontFamily: serif, fontSize: 54, fontWeight: 500,
+            color: INK, letterSpacing:'-0.01em'}}>
+            固化为 <span style={{fontStyle:'italic'}}>brand-spec.md</span>
+          </div>
+        </div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
+          color: ASH, textAlign:'right'}}>
+          单一真相源 · single source of truth
+        </div>
+      </div>
+
+      <div style={{display:'grid', gridTemplateColumns:'1.1fr 0.9fr',
+        gap: 40, flex:1, position:'relative'}}>
+        {/* Left: .md preview */}
+        <div style={{opacity: mdOp, transform:`translateX(${mdX}px)`,
+          background:'#fff', border:`1px solid ${LINE}`,
+          display:'flex', flexDirection:'column'}}>
+          <div style={{padding:'12px 20px', borderBottom:`1px solid ${LINE}`,
+            display:'flex', justifyContent:'space-between', alignItems:'center',
+            background:'#f7f2e8'}}>
+            <div style={{fontFamily: mono, fontSize: 11, color: ASH,
+              letterSpacing:'0.15em'}}>
+              ▸ brand-spec.md
+            </div>
+            <div style={{fontFamily: mono, fontSize: 10, color: TERRA,
+              letterSpacing:'0.15em'}}>
+              ✓ COMMITTED
+            </div>
+          </div>
+          <div style={{padding:'22px 28px', fontFamily: mono, fontSize: 12,
+            lineHeight: 1.75, color: INK, flex:1, overflow:'hidden'}}>
+            <div style={{color: ASH}}>---</div>
+            <div><span style={{color: OLIVE}}>brand</span>: Kimi</div>
+            <div><span style={{color: OLIVE}}>fetched</span>: 2026-04-20</div>
+            <div style={{color: ASH}}>---</div>
+            <div style={{height: 10}} />
+            <div style={{fontFamily: serif, fontSize: 20, fontWeight: 500,
+              marginBottom: 4}}>## 色板</div>
+            <div>- primary: <span style={{color: TERRA}}>#1783FF</span> (47)</div>
+            <div>- bg:      <span style={{color: TERRA}}>#FAFAFA</span> (32)</div>
+            <div>- ink:     <span style={{color: TERRA}}>#1a1a1a</span> (18)</div>
+            <div>- accent:  <span style={{color: TERRA}}>#FF6B35</span> (12)</div>
+            <div style={{height: 12}} />
+            <div style={{fontFamily: serif, fontSize: 20, fontWeight: 500,
+              marginBottom: 4}}>## 字型</div>
+            <div>- display: <span style={{color: OLIVE}}>Newsreader</span></div>
+            <div>- body:    <span style={{color: OLIVE}}>Inter</span></div>
+            <div>- mono:    <span style={{color: OLIVE}}>JetBrains Mono</span></div>
+            <div style={{height: 12}} />
+            <div style={{fontFamily: serif, fontSize: 20, fontWeight: 500,
+              marginBottom: 4}}>## 签名细节</div>
+            <div>- 1px hairlines</div>
+            <div>- 圆角半径统一 4px</div>
+            <div style={{height: 12}} />
+            <div style={{fontFamily: serif, fontSize: 20, fontWeight: 500,
+              marginBottom: 4, color: TERRA}}>## 禁区</div>
+            <div style={{color: ASH}}>- 不用紫渐变 / emoji / 4px 以上 shadow</div>
+          </div>
+        </div>
+
+        {/* Right: CSS vars + consumers */}
+        <div style={{opacity: cssOp, transform:`translateX(${cssX}px)`,
+          display:'flex', flexDirection:'column', gap: 20}}>
+          <div style={{background:'#0e0e0e', color:'#e8e3d6',
+            fontFamily: mono, fontSize: 14, padding:'22px 26px',
+            lineHeight: 1.7, flex: 1}}>
+            <div style={{color:'#666', fontSize: 10, letterSpacing:'0.2em',
+              marginBottom: 12}}>/* tokens.css */</div>
+            <div><span style={{color:'#7dd3fc'}}>:root</span> {'{'}</div>
+            <div>  <span style={{color:'#a5f3c5'}}>--brand-primary</span>: <span style={{color:'#fbbf24'}}>#1783FF</span>;</div>
+            <div>  <span style={{color:'#a5f3c5'}}>--brand-bg</span>:      <span style={{color:'#fbbf24'}}>#FAFAFA</span>;</div>
+            <div>  <span style={{color:'#a5f3c5'}}>--brand-ink</span>:     <span style={{color:'#fbbf24'}}>#1a1a1a</span>;</div>
+            <div>  <span style={{color:'#a5f3c5'}}>--brand-accent</span>:  <span style={{color:'#fbbf24'}}>#FF6B35</span>;</div>
+            <div>{'}'}</div>
+          </div>
+
+          {/* Arrow cascade */}
+          <div style={{opacity: arrowOp, position:'relative',
+            background:'#fff', border:`1px solid ${LINE}`,
+            padding:'20px 24px'}}>
+            <div style={{fontFamily: mono, fontSize: 10, color: TERRA,
+              letterSpacing:'0.2em', marginBottom: 12}}>▸ CONSUMERS</div>
+            <div style={{display:'flex', alignItems:'center', gap: 16,
+              fontFamily: mono, fontSize: 13, color: INK, flexWrap:'wrap'}}>
+              <span style={{padding:'4px 10px', border:`1px solid ${TERRA}`,
+                color: TERRA}}>brand-spec.md</span>
+              <span style={{color: TERRA}}>→</span>
+              <span style={{padding:'4px 10px', border:`1px solid ${INK}`}}>:root</span>
+              <span style={{color: TERRA}}>→</span>
+              <span style={{padding:'4px 10px', background: INK, color:'#fff'}}>all .html</span>
+            </div>
+            <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
+              color: ASH, marginTop: 14, lineHeight: 1.5}}>
+              一个文件改动,所有 HTML 自动跟随。
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div style={{opacity: captionOp, marginTop: 22, textAlign:'center',
+        fontFamily: serif, fontStyle:'italic', fontSize: 22, color: INK}}>
+        全协议的<span style={{color: TERRA}}>制胜点</span>——让所有 CSS 引用这份文件
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 6: Final (22 – 24s) ─────────────────────────────
+function Scene6_Final() {
+  const { elapsed } = useSprite();
+  const fadeIn = interpolate(elapsed, [0, 0.5], [0, 1], Easing.easeOut);
+  const titleY = interpolate(elapsed, [0, 0.8], [24, 0], Easing.easeOut);
+  const lineW = interpolate(elapsed, [0.5, 1.3], [0, 580]);
+  const subOp = interpolate(elapsed, [0.8, 1.4], [0, 1]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeIn,
+      display:'flex', alignItems:'center', justifyContent:'center',
+      flexDirection:'column'}}>
+      <div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
+        color: TERRA, marginBottom: 24}}>
+        01 → 02 → 03 → 04 → <span style={{color: INK}}>05 · DONE</span>
+      </div>
+
+      <div style={{fontFamily: serif, fontSize: 92, fontWeight: 500,
+        color: INK, lineHeight: 1.1, letterSpacing:'-0.01em',
+        textAlign:'center', transform: `translateY(${titleY}px)`}}>
+        <span style={{fontStyle:'italic', color: TERRA}}>15 分钟</span>的投资<br/>
+        省下 <span style={{fontStyle:'italic'}}>1–2 小时</span> 返工
+      </div>
+
+      <div style={{height:1, background: INK, width: lineW, marginTop: 40}} />
+
+      <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 24,
+        color: ASH, marginTop: 28, opacity: subOp,
+        letterSpacing:'0.02em'}}>
+        这是稳定性最便宜的投资。
+      </div>
+    </div>
+  );
+}
+
+// ── Watermark ─────────────────────────────────────────────
+function Watermark() {
+  return (
+    <div style={{position:'absolute', bottom: 24, right: 32,
+      fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
+      fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
+      Created by Huashu-Design
+    </div>
+  );
+}
+
+// ── Main composition ──────────────────────────────────────
+function App() {
+  return (
+    <Stage duration={24} width={1920} height={1080} bgColor={CREAM}>
+      <Sprite start={0} end={3}><Scene1_Trigger /></Sprite>
+      <Sprite start={3} end={7}><Scene2_AskAndSearch /></Sprite>
+      <Sprite start={7} end={12}><Scene3_Fallbacks /></Sprite>
+      <Sprite start={12} end={17}><Scene4_GrepColors /></Sprite>
+      <Sprite start={17} end={22}><Scene5_SpecFile /></Sprite>
+      <Sprite start={22} end={24}><Scene6_Final /></Sprite>
+      <Watermark />
+    </Stage>
+  );
+}
+
+ReactDOM.createRoot(document.getElementById('root')).render(<App />);
+</script>
+</body>
+</html>

+ 856 - 0
demos/w2-junior-designer.html

@@ -0,0 +1,856 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<title>Huashu-Design · Junior Designer 模式</title>
+<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
+<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
+<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
+<style>
+  * { box-sizing: border-box; margin: 0; padding: 0; }
+  html, body { width: 100%; height: 100%; overflow: hidden; }
+  body {
+    background: #0c0c0c;
+    font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
+    color: #1a1a1a;
+    -webkit-font-smoothing: antialiased;
+    text-rendering: optimizeLegibility;
+  }
+</style>
+</head>
+<body>
+<div id="root"></div>
+
+<!-- animations.jsx inlined -->
+<script type="text/babel">
+(function() {
+  const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
+  const TimeContext = createContext({ time: 0, duration: 10, playing: false });
+  const SpriteContext = createContext(null);
+
+  const Easing = {
+    linear: t => t,
+    easeIn: t => t * t,
+    easeOut: t => 1 - (1 - t) * (1 - t),
+    easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
+    spring: t => {
+      const c = (2 * Math.PI) / 3;
+      return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
+    },
+  };
+
+  function interpolate(t, input, output, easing) {
+    const [inStart, inEnd] = input;
+    const [outStart, outEnd] = output;
+    if (t <= inStart) return outStart;
+    if (t >= inEnd) return outEnd;
+    let progress = (t - inStart) / (inEnd - inStart);
+    if (easing) progress = easing(progress);
+    return outStart + (outEnd - outStart) * progress;
+  }
+
+  function useTime() { return useContext(TimeContext).time; }
+  function useSprite() {
+    const sprite = useContext(SpriteContext);
+    return sprite || { t: 0, elapsed: 0, duration: 0 };
+  }
+
+  function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
+    const [time, setTime] = useState(0);
+    const [playing, setPlaying] = useState(true);
+    const [scale, setScale] = useState(1);
+    const rafRef = useRef(null);
+    const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
+
+    useEffect(() => {
+      function updateScale() {
+        const vw = window.innerWidth;
+        const vh = window.innerHeight - 56;
+        const s = Math.min(vw / width, vh / height);
+        setScale(s);
+      }
+      updateScale();
+      window.addEventListener('resize', updateScale);
+      return () => window.removeEventListener('resize', updateScale);
+    }, [width, height]);
+
+    useEffect(() => {
+      if (!playing) return;
+      let cancelled = false;
+      let last = null;
+      function tick(now) {
+        if (cancelled) return;
+        if (last === null) {
+          last = now;
+          if (typeof window !== 'undefined') window.__ready = true;
+        }
+        const delta = (now - last) / 1000;
+        last = now;
+        setTime(prev => {
+          const next = prev + delta;
+          if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
+          return next;
+        });
+        rafRef.current = requestAnimationFrame(tick);
+      }
+      const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
+      if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
+      return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
+    }, [playing, duration, effectiveLoop]);
+
+    const progress = time / duration;
+    const ctx = { time, duration, playing, setPlaying, setTime };
+
+    const canvasStyle = {
+      position: 'absolute',
+      top: '50%',
+      left: '50%',
+      transformOrigin: 'center center',
+      width,
+      height,
+      background: bgColor,
+      overflow: 'hidden',
+      transform: `translate(-50%, -50%) scale(${scale})`,
+    };
+
+    return (
+      <TimeContext.Provider value={ctx}>
+        <div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
+          <div style={{flex:1, position:'relative', overflow:'hidden'}}>
+            <div style={canvasStyle}>{children}</div>
+          </div>
+          <div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', backdropFilter:'blur(10px)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
+            <button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
+            <button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
+            <div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
+            <div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
+              <div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
+            </div>
+          </div>
+        </div>
+      </TimeContext.Provider>
+    );
+  }
+
+  function Sprite({ start = 0, end, children, style }) {
+    const { time } = useContext(TimeContext);
+    const actualEnd = end == null ? Infinity : end;
+    if (time < start || time >= actualEnd) return null;
+    const duration = actualEnd - start;
+    const elapsed = time - start;
+    const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
+    const spriteValue = { t, elapsed, duration, start, end: actualEnd };
+    return (
+      <SpriteContext.Provider value={spriteValue}>
+        <div style={{position:'absolute', inset:0, ...style}}>{children}</div>
+      </SpriteContext.Provider>
+    );
+  }
+
+  window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
+})();
+</script>
+
+<!-- Demo scene -->
+<script type="text/babel">
+const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
+
+// ── Design tokens ─────────────────────────────────────────
+const CREAM = '#FAF6EF';
+const INK = '#1a1a1a';
+const TERRA = '#C04A1A';
+const ASH = '#6b6b6b';
+const LINE = '#d9d2c5';
+const OLIVE = '#6a6b4e';
+
+const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
+const sans = "'Inter', -apple-system, sans-serif";
+const mono = "'JetBrains Mono', ui-monospace, monospace";
+
+// ── Scene 1: Two working modes contrast (0 – 3s) ──────────
+function Scene1_Contrast() {
+  const { elapsed } = useSprite();
+  const titleOp = interpolate(elapsed, [0, 0.6], [0, 1]);
+  const leftOp = interpolate(elapsed, [0.3, 1], [0, 1]);
+  const rightOp = interpolate(elapsed, [0.5, 1.2], [0, 1]);
+
+  // Left: long bar filling slowly, then user frown at end
+  const badBar = Math.min(1, Math.max(0, (elapsed - 1.0) / 1.6));
+  const badReveal = elapsed > 2.5 ? 1 : 0;
+
+  // Right: small milestones ticking one by one
+  const ticks = [0.9, 1.3, 1.7, 2.1, 2.5];
+  const fadeOut = interpolate(elapsed, [2.85, 3], [1, 0], Easing.easeIn);
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
+      padding:'80px 120px', display:'flex', flexDirection:'column'}}>
+
+      {/* Title */}
+      <div style={{textAlign:'center', marginBottom: 60, opacity: titleOp}}>
+        <div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
+          color: TERRA, marginBottom: 16}}>
+          JUNIOR DESIGNER MODE · 工作方式对比
+        </div>
+        <div style={{fontFamily: serif, fontSize: 68, fontWeight: 500,
+          color: INK, lineHeight: 1.1, letterSpacing:'-0.01em'}}>
+          理解错了 <span style={{fontStyle:'italic', color: TERRA}}>早改</span> 比 <span style={{fontStyle:'italic', color: TERRA}}>晚改</span> 便宜 100 倍
+        </div>
+      </div>
+
+      {/* Two columns */}
+      <div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap: 60, flex: 1}}>
+
+        {/* Left: bad mode */}
+        <div style={{opacity: leftOp, background:'#f4eee3', padding:'40px 44px',
+          display:'flex', flexDirection:'column', position:'relative'}}>
+          <div style={{display:'flex', alignItems:'center', gap: 14, marginBottom: 28}}>
+            <div style={{width: 32, height: 32, borderRadius:'50%',
+              background:'#ccc', color:'#fff', fontFamily: serif, fontSize: 20,
+              fontWeight: 600, display:'flex', alignItems:'center',
+              justifyContent:'center', lineHeight: 1}}>✗</div>
+            <div style={{fontFamily: serif, fontSize: 30, fontWeight: 500,
+              color:'#555'}}>闷头做大招</div>
+          </div>
+
+          <div style={{fontFamily: mono, fontSize: 11, color: ASH,
+            letterSpacing: '0.2em', marginBottom: 14}}>
+            6 HOURS · 0 FEEDBACK
+          </div>
+
+          <div style={{height: 18, background:'#e2dbcd', position:'relative',
+            marginBottom: 36}}>
+            <div style={{position:'absolute', top:0, left:0, height:'100%',
+              width: `${badBar * 100}%`, background:'#999'}} />
+          </div>
+
+          <div style={{fontFamily: serif, fontSize: 16, color: ASH,
+            lineHeight: 1.7, flex: 1}}>
+            埋头 6 小时<br/>
+            一次性端出成品<br/>
+            默祷「方向没理解错」
+          </div>
+
+          {badReveal > 0 && (
+            <div style={{marginTop: 20, padding:'14px 18px', background:'#fff',
+              border: `1px solid #d0c8b8`, opacity: badReveal,
+              display:'flex', alignItems:'center', gap: 12}}>
+              <div style={{fontFamily: serif, fontSize: 22, color:'#888'}}>😐</div>
+              <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
+                color:'#666', lineHeight: 1.4}}>
+                「这方向不对…<br/>得重来」
+              </div>
+            </div>
+          )}
+        </div>
+
+        {/* Right: good mode */}
+        <div style={{opacity: rightOp, background:'#fff', padding:'40px 44px',
+          border: `1.5px solid ${TERRA}`, display:'flex', flexDirection:'column',
+          position:'relative'}}>
+          <div style={{display:'flex', alignItems:'center', gap: 14, marginBottom: 28}}>
+            <div style={{width: 32, height: 32, borderRadius:'50%',
+              background: TERRA, color:'#fff', fontFamily: serif, fontSize: 20,
+              fontWeight: 600, display:'flex', alignItems:'center',
+              justifyContent:'center', lineHeight: 1}}>✓</div>
+            <div style={{fontFamily: serif, fontSize: 30, fontWeight: 500,
+              color: INK}}>Junior Designer 模式</div>
+          </div>
+
+          <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+            letterSpacing: '0.2em', marginBottom: 14}}>
+            5 CHECKPOINTS · CONTINUOUS FEEDBACK
+          </div>
+
+          {/* Timeline with ticks */}
+          <div style={{position:'relative', height: 18, marginBottom: 36}}>
+            <div style={{position:'absolute', top: 8, left: 0, right: 0,
+              height: 2, background:'#e8e1d3'}} />
+            {ticks.map((tick, i) => {
+              const show = elapsed > tick ? 1 : 0;
+              const pct = (i + 1) / ticks.length * 100;
+              return (
+                <div key={i} style={{position:'absolute',
+                  left: `calc(${pct}% - 10px)`, top: 0, width: 20, height: 20,
+                  borderRadius:'50%', background: TERRA, color:'#fff',
+                  fontSize: 11, display:'flex', alignItems:'center',
+                  justifyContent:'center', opacity: show,
+                  transform: `scale(${show})`, transition:'none',
+                  fontFamily: sans, fontWeight: 600}}>✓</div>
+              );
+            })}
+          </div>
+
+          <div style={{fontFamily: serif, fontSize: 16, color: INK,
+            lineHeight: 1.7, flex: 1}}>
+            假设 · 骨架 · 粗图<br/>
+            每步都 show 一下<br/>
+            错得早,改得小
+          </div>
+
+          {elapsed > 2.5 && (
+            <div style={{marginTop: 20, padding:'14px 18px', background:'#faf6ef',
+              border: `1px solid ${LINE}`, display:'flex', alignItems:'center', gap: 12,
+              opacity: interpolate(elapsed, [2.5, 2.85], [0, 1])}}>
+              <div style={{fontFamily: serif, fontSize: 22}}>🙂</div>
+              <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
+                color: INK, lineHeight: 1.4}}>
+                「方向对,<br/>继续推进」
+              </div>
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 2: HTML skeleton with assumptions + placeholders (3 – 8s) ──
+function Scene2_Skeleton() {
+  const { elapsed } = useSprite();
+  const opacity = interpolate(elapsed, [0, 0.5], [0, 1]);
+  const fadeOut = interpolate(elapsed, [4.6, 5], [1, 0]);
+
+  // Typewriter content
+  const fullText = `<!--
+  ASSUMPTIONS:
+  - 主色用暖调橙(未确认,等品牌 spec)
+  - 3 屏切换,Today / Memory / Chat
+  - 字体:Newsreader + Noto Serif SC
+
+  PLACEHOLDERS:
+  - Hero 图暂用灰块 + 文字标签(有图再替换)
+  - AI 洞察文本用「等用户提供真实数据」
+
+  REASONING:
+  - 选橙调是因为客户说要「温暖感」
+  - Serif 因为是阅读类 App
+-->`;
+
+  // Type at ~50 chars/sec
+  const charCount = Math.floor(interpolate(elapsed, [0.4, 4.0], [0, fullText.length]));
+  const shownText = fullText.slice(0, charCount);
+  const cursorBlink = Math.floor(elapsed * 2.4) % 2 === 0 && charCount < fullText.length;
+
+  // Hint callout appears after most text typed
+  const hintOp = interpolate(elapsed, [3.5, 4.2], [0, 1]);
+  const hintBob = Math.sin(elapsed * 3) * 4;
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM, opacity: opacity * fadeOut,
+      padding:'60px 100px', display:'flex', flexDirection:'column'}}>
+
+      {/* Header */}
+      <div style={{display:'flex', justifyContent:'space-between',
+        alignItems:'baseline', marginBottom: 32}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+            letterSpacing: '0.3em', marginBottom: 6}}>STEP 1 / 3</div>
+          <div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK}}>
+            先写 <span style={{fontStyle:'italic', color: TERRA}}>骨架</span> · 不写渲染
+          </div>
+        </div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
+          color: ASH, textAlign: 'right', lineHeight: 1.5}}>
+          Assumptions + Placeholders + Reasoning<br/>
+          <span style={{fontSize: 14}}>— 把「猜」写在代码里</span>
+        </div>
+      </div>
+
+      {/* Editor */}
+      <div style={{flex: 1, background:'#1a1a1a', position:'relative',
+        display:'flex', flexDirection:'column', overflow:'hidden'}}>
+
+        {/* Editor top bar */}
+        <div style={{padding:'12px 20px', background:'#222',
+          borderBottom:'1px solid #2e2e2e', display:'flex',
+          alignItems:'center', gap: 16}}>
+          <div style={{display:'flex', gap: 8}}>
+            <div style={{width: 12, height: 12, borderRadius:'50%', background:'#ff5f57'}} />
+            <div style={{width: 12, height: 12, borderRadius:'50%', background:'#febc2e'}} />
+            <div style={{width: 12, height: 12, borderRadius:'50%', background:'#28c840'}} />
+          </div>
+          <div style={{fontFamily: mono, fontSize: 11, color:'#888',
+            letterSpacing: '0.1em'}}>
+            index.html · junior-pass-v0
+          </div>
+        </div>
+
+        {/* Code body */}
+        <div style={{flex: 1, padding:'32px 40px', fontFamily: mono,
+          fontSize: 19, lineHeight: 1.7, color:'#8a8a8a',
+          whiteSpace:'pre-wrap', position:'relative'}}>
+          <span style={{color:'#6a8a56'}}>{shownText}</span>
+          {cursorBlink && <span style={{background:'#c04a1a', display:'inline-block',
+            width: 10, height: 22, verticalAlign:'text-bottom'}} />}
+        </div>
+
+        {/* Bottom status bar */}
+        <div style={{padding:'10px 20px', background:'#161616',
+          borderTop:'1px solid #2a2a2a', display:'flex',
+          justifyContent:'space-between', fontFamily: mono, fontSize: 10,
+          color:'#666', letterSpacing:'0.12em'}}>
+          <span>HTML · COMMENTS ONLY</span>
+          <span>LN {Math.min(15, Math.floor(charCount / 30) + 1)} / 15</span>
+        </div>
+      </div>
+
+      {/* Callout */}
+      <div style={{position:'absolute', right: 120, bottom: 100,
+        opacity: hintOp, transform: `translateY(${hintBob}px)`,
+        display:'flex', alignItems:'center', gap: 14}}>
+        <svg width="48" height="24" viewBox="0 0 48 24">
+          <path d="M 46 12 L 6 12 M 14 6 L 6 12 L 14 18"
+            fill="none" stroke={TERRA} strokeWidth="2" strokeLinecap="round"/>
+        </svg>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
+          color: TERRA, letterSpacing:'0.02em'}}>
+          还没写一行渲染代码
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 3: First show to user (8 – 13s) ─────────────────
+function Scene3_FirstShow() {
+  const { elapsed } = useSprite();
+  const opacity = interpolate(elapsed, [0, 0.5], [0, 1]);
+  const fadeOut = interpolate(elapsed, [4.6, 5], [1, 0]);
+
+  // Wireframe fade in
+  const wireOp = interpolate(elapsed, [0.2, 0.9], [0, 1]);
+
+  // Chat bubbles stagger in
+  const b1Op = interpolate(elapsed, [1.0, 1.5], [0, 1]);
+  const b2Op = interpolate(elapsed, [2.0, 2.5], [0, 1]);
+  const b3Op = interpolate(elapsed, [2.9, 3.4], [0, 1]);
+
+  // Bottom subtitle
+  const subOp = interpolate(elapsed, [3.8, 4.3], [0, 1]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM, opacity: opacity * fadeOut,
+      padding:'60px 100px', display:'flex', flexDirection:'column'}}>
+
+      {/* Header */}
+      <div style={{display:'flex', justifyContent:'space-between',
+        alignItems:'baseline', marginBottom: 28}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+            letterSpacing: '0.3em', marginBottom: 6}}>STEP 2 / 3</div>
+          <div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK}}>
+            第一次 <span style={{fontStyle:'italic', color: TERRA}}>show</span> 给用户
+          </div>
+        </div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
+          color: ASH, textAlign:'right', lineHeight: 1.5}}>
+          「先给你看方向 —— 对吗?」<br/>
+          <span style={{fontSize: 14}}>— 10 分钟对齐</span>
+        </div>
+      </div>
+
+      {/* Two panels */}
+      <div style={{display:'grid', gridTemplateColumns:'1fr 1px 1.1fr', gap: 40,
+        flex: 1, alignItems:'stretch'}}>
+
+        {/* Left: wireframe */}
+        <div style={{background:'#fff', border: `1px solid ${LINE}`,
+          padding: 28, opacity: wireOp, display:'flex', flexDirection:'column'}}>
+          <div style={{fontFamily: mono, fontSize: 10, color: ASH,
+            letterSpacing:'0.2em', marginBottom: 14}}>
+            WIREFRAME · NO STYLING
+          </div>
+
+          {/* Hero placeholder */}
+          <div style={{height: 180, background:'#ececec', border:'1.5px dashed #bbb',
+            display:'flex', alignItems:'center', justifyContent:'center',
+            fontFamily: mono, fontSize: 13, color:'#888',
+            letterSpacing:'0.15em', marginBottom: 20}}>
+            [ HERO IMAGE ]
+          </div>
+
+          {/* Title placeholder */}
+          <div style={{height: 26, background:'#e5e0d3', width:'75%',
+            marginBottom: 10}} />
+          <div style={{height: 16, background:'#ede8db', width:'55%',
+            marginBottom: 24}} />
+
+          {/* AI insight block */}
+          <div style={{background:'#f5f0e3', padding:'16px 18px', marginBottom: 20,
+            border: `1px dashed ${LINE}`}}>
+            <div style={{fontFamily: mono, fontSize: 9, color: ASH,
+              letterSpacing:'0.2em', marginBottom: 8}}>[ AI INSIGHT · 3 行 ]</div>
+            <div style={{height: 8, background:'#d9d2c5', width:'92%', marginBottom: 6}} />
+            <div style={{height: 8, background:'#d9d2c5', width:'88%', marginBottom: 6}} />
+            <div style={{height: 8, background:'#d9d2c5', width:'70%'}} />
+          </div>
+
+          <div style={{fontFamily: serif, fontSize: 14, color:'#888',
+            fontStyle:'italic', marginTop:'auto', textAlign:'center',
+            paddingTop: 14, borderTop: `1px solid ${LINE}`}}>
+            Marcus Aurelius · 第四卷
+          </div>
+        </div>
+
+        {/* Divider */}
+        <div style={{background: LINE, width: 1}} />
+
+        {/* Right: chat */}
+        <div style={{display:'flex', flexDirection:'column', gap: 18,
+          paddingTop: 10}}>
+          {/* Designer bubble */}
+          <div style={{opacity: b1Op, display:'flex', gap: 14,
+            alignItems:'flex-start'}}>
+            <div style={{width: 40, height: 40, background: TERRA, color:'#fff',
+              fontFamily: serif, fontSize: 16, fontWeight: 600,
+              display:'flex', alignItems:'center', justifyContent:'center',
+              flexShrink: 0, borderRadius: 2, letterSpacing:'0.05em'}}>JD</div>
+            <div style={{background:'#fff', border: `1px solid ${LINE}`,
+              padding:'16px 20px', maxWidth: '85%'}}>
+              <div style={{fontFamily: mono, fontSize: 9, color: ASH,
+                letterSpacing:'0.2em', marginBottom: 8}}>
+                JUNIOR DESIGNER
+              </div>
+              <div style={{fontFamily: serif, fontSize: 19, color: INK,
+                lineHeight: 1.5}}>
+                我对方向有几个假设,先给你看线框——想法对吗?
+              </div>
+            </div>
+          </div>
+
+          {/* User bubble 1 */}
+          <div style={{opacity: b2Op, display:'flex', gap: 14,
+            alignItems:'flex-start', justifyContent:'flex-end'}}>
+            <div style={{background: OLIVE, color:'#fff',
+              padding:'14px 20px', maxWidth: '80%'}}>
+              <div style={{fontFamily: mono, fontSize: 9,
+                color:'rgba(255,255,255,0.6)', letterSpacing:'0.2em',
+                marginBottom: 8}}>USER · FEEDBACK</div>
+              <div style={{fontFamily: serif, fontSize: 18, lineHeight: 1.5}}>
+                橙调 OK。AI 洞察那块我想要 <span style={{fontStyle:'italic',
+                  textDecoration:'underline'}}>两行</span> 不要三行
+              </div>
+            </div>
+          </div>
+
+          {/* User bubble 2 */}
+          <div style={{opacity: b3Op, display:'flex', gap: 14,
+            alignItems:'flex-start', justifyContent:'flex-end'}}>
+            <div style={{background: OLIVE, color:'#fff',
+              padding:'14px 20px', maxWidth: '80%'}}>
+              <div style={{fontFamily: serif, fontSize: 18, lineHeight: 1.5}}>
+                Hero 图要 <span style={{fontStyle:'italic',
+                  textDecoration:'underline'}}>摄影</span> 不要插画
+              </div>
+            </div>
+          </div>
+
+          {/* Subtitle */}
+          <div style={{marginTop:'auto', paddingTop: 24, opacity: subOp,
+            borderTop: `1px solid ${LINE}`}}>
+            <div style={{fontFamily: mono, fontSize: 11,
+              color: TERRA, letterSpacing:'0.3em', marginBottom: 6}}>
+              ALIGNMENT
+            </div>
+            <div style={{fontFamily: serif, fontSize: 30, fontWeight: 500,
+              color: INK, lineHeight: 1.2}}>
+              对齐 = <span style={{color: TERRA}}>10 分钟</span>
+              <span style={{fontStyle:'italic', color: ASH, fontSize: 22}}> · 不是 2 小时</span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ── ArtBlock: oil painting hero (from c1-ios-prototype) ───
+function ArtBlock({ mood = 'warm' }) {
+  const palettes = {
+    warm: ['#8b4a2b', '#c67b4a', '#e3a876', '#f2d4a7'],
+    quiet: ['#3d4a3a', '#6a8066', '#a8b89c', '#e0d8b8'],
+  };
+  const p = palettes[mood];
+  return (
+    <div style={{
+      width:'100%', height:'100%', position:'relative', overflow:'hidden',
+      background: `linear-gradient(135deg, ${p[0]} 0%, ${p[1]} 35%, ${p[2]} 70%, ${p[3]} 100%)`,
+    }}>
+      <div style={{
+        position:'absolute', inset: 0,
+        background: `
+          radial-gradient(ellipse 80px 30px at 30% 40%, ${p[3]}44, transparent 70%),
+          radial-gradient(ellipse 60px 20px at 70% 60%, ${p[0]}33, transparent 70%),
+          radial-gradient(ellipse 100px 40px at 50% 80%, ${p[2]}44, transparent 70%),
+          radial-gradient(ellipse 50px 25px at 20% 70%, ${p[1]}55, transparent 70%)
+        `,
+        filter:'blur(1px)',
+      }} />
+      <svg width="100%" height="100%" style={{position:'absolute', inset:0, opacity: 0.18}}>
+        <filter id="paint-noise-w2">
+          <feTurbulence baseFrequency="0.9" numOctaves="2" />
+          <feColorMatrix values="0 0 0 0 0.3   0 0 0 0 0.2   0 0 0 0 0.1   0 0 0 1 0" />
+        </filter>
+        <rect width="100%" height="100%" filter="url(#paint-noise-w2)" />
+      </svg>
+    </div>
+  );
+}
+
+// ── Scene 4: Full pass fills in (13 – 18s) ────────────────
+function Scene4_FullPass() {
+  const { elapsed } = useSprite();
+  const opacity = interpolate(elapsed, [0, 0.5], [0, 1]);
+  const fadeOut = interpolate(elapsed, [4.6, 5], [1, 0]);
+
+  // Staggered reveal of real content
+  const heroReveal = interpolate(elapsed, [0.6, 1.8], [0, 1], Easing.easeOut);
+  const titleReveal = interpolate(elapsed, [1.5, 2.3], [0, 1]);
+  const insightReveal = interpolate(elapsed, [2.2, 3.1], [0, 1]);
+  const meta = interpolate(elapsed, [3.0, 3.8], [0, 1]);
+
+  // Bottom subtitle
+  const subOp = interpolate(elapsed, [3.9, 4.4], [0, 1]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM, opacity: opacity * fadeOut,
+      padding:'60px 100px', display:'flex', flexDirection:'column'}}>
+
+      {/* Header */}
+      <div style={{display:'flex', justifyContent:'space-between',
+        alignItems:'baseline', marginBottom: 28}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+            letterSpacing: '0.3em', marginBottom: 6}}>STEP 3 / 3</div>
+          <div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK}}>
+            Full pass · <span style={{fontStyle:'italic', color: TERRA}}>灰块变真图</span>
+          </div>
+        </div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
+          color: ASH, textAlign:'right', lineHeight: 1.5}}>
+          Placeholders → Content<br/>
+          <span style={{fontSize: 14}}>— 方向对了再填色</span>
+        </div>
+      </div>
+
+      {/* Two panels: before wireframe (thumbnail) vs after (full card) */}
+      <div style={{display:'grid', gridTemplateColumns:'0.55fr 60px 1fr', gap: 0,
+        flex: 1, alignItems:'center'}}>
+
+        {/* Left: wireframe thumbnail, still, for contrast */}
+        <div style={{background:'#fff', border: `1px solid ${LINE}`,
+          padding: 20, opacity: 0.55, transform:'scale(0.92)',
+          transformOrigin:'center right'}}>
+          <div style={{fontFamily: mono, fontSize: 9, color: ASH,
+            letterSpacing:'0.2em', marginBottom: 10}}>
+            BEFORE
+          </div>
+          <div style={{height: 100, background:'#ececec',
+            border:'1.5px dashed #bbb', display:'flex',
+            alignItems:'center', justifyContent:'center',
+            fontFamily: mono, fontSize: 10, color:'#888',
+            letterSpacing:'0.15em', marginBottom: 12}}>
+            [ HERO IMAGE ]
+          </div>
+          <div style={{height: 14, background:'#e5e0d3', width:'75%', marginBottom: 6}} />
+          <div style={{height: 10, background:'#ede8db', width:'55%', marginBottom: 14}} />
+          <div style={{background:'#f5f0e3', padding:'10px 12px',
+            border: `1px dashed ${LINE}`}}>
+            <div style={{height: 6, background:'#d9d2c5', width:'92%', marginBottom: 4}} />
+            <div style={{height: 6, background:'#d9d2c5', width:'88%', marginBottom: 4}} />
+            <div style={{height: 6, background:'#d9d2c5', width:'70%'}} />
+          </div>
+        </div>
+
+        {/* Arrow */}
+        <div style={{display:'flex', alignItems:'center', justifyContent:'center'}}>
+          <svg width="40" height="24" viewBox="0 0 40 24">
+            <path d="M 2 12 L 38 12 M 30 4 L 38 12 L 30 20"
+              fill="none" stroke={TERRA} strokeWidth="2" strokeLinecap="round"/>
+          </svg>
+        </div>
+
+        {/* Right: full card */}
+        <div style={{background:'#fff', border: `1px solid ${LINE}`,
+          boxShadow:'0 20px 60px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.04)',
+          display:'flex', flexDirection:'column', overflow:'hidden'}}>
+
+          <div style={{padding:'14px 24px', borderBottom:`1px solid ${LINE}`,
+            display:'flex', justifyContent:'space-between', alignItems:'center'}}>
+            <div style={{fontFamily: mono, fontSize: 10, color: TERRA,
+              letterSpacing:'0.25em'}}>AFTER · FULL PASS</div>
+            <div style={{fontFamily: mono, fontSize: 10, color: ASH,
+              letterSpacing:'0.15em'}}>STOIC READER · v0.3</div>
+          </div>
+
+          {/* Hero image: oil painting */}
+          <div style={{height: 220, position:'relative', overflow:'hidden'}}>
+            <div style={{position:'absolute', inset: 0, opacity: heroReveal}}>
+              <ArtBlock mood="warm" />
+            </div>
+            {heroReveal < 0.95 && (
+              <div style={{position:'absolute', inset: 0, background:'#ececec',
+                opacity: 1 - heroReveal, display:'flex', alignItems:'center',
+                justifyContent:'center', fontFamily: mono, fontSize: 12,
+                color:'#888', letterSpacing:'0.15em',
+                border:'1.5px dashed #bbb'}}>
+                [ HERO IMAGE ]
+              </div>
+            )}
+            {/* Photo credit */}
+            <div style={{position:'absolute', bottom: 10, right: 12,
+              fontFamily: mono, fontSize: 9, color:'rgba(255,255,255,0.7)',
+              letterSpacing:'0.2em', opacity: heroReveal}}>
+              PHOTO · J. TURNER
+            </div>
+          </div>
+
+          {/* Title */}
+          <div style={{padding:'26px 32px 10px', opacity: titleReveal}}>
+            <div style={{fontFamily: serif, fontSize: 36, fontWeight: 500,
+              color: INK, lineHeight: 1.15, letterSpacing:'-0.01em',
+              marginBottom: 6}}>
+              The Obstacle<br/>
+              <span style={{fontStyle:'italic', color: TERRA}}>is the Way</span>
+            </div>
+            <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 15,
+              color: ASH}}>
+              Marcus Aurelius · 第四卷 · 今日阅读
+            </div>
+          </div>
+
+          {/* AI insight — NOW 2 lines, not 3 */}
+          <div style={{margin:'10px 32px 20px', padding:'16px 20px',
+            background:'#faf6ef', borderLeft: `3px solid ${TERRA}`,
+            opacity: insightReveal}}>
+            <div style={{fontFamily: mono, fontSize: 9, color: TERRA,
+              letterSpacing:'0.25em', marginBottom: 8}}>AI INSIGHT · 2 LINES</div>
+            <div style={{fontFamily: serif, fontSize: 15, color: INK,
+              lineHeight: 1.55}}>
+              你最近笔记里「困难」出现 7 次。<br/>
+              Aurelius 今天正好在说:障碍本身就是路。
+            </div>
+          </div>
+
+          {/* Meta row */}
+          <div style={{padding:'14px 32px', borderTop:`1px solid ${LINE}`,
+            display:'flex', justifyContent:'space-between',
+            fontFamily: mono, fontSize: 10, color: ASH,
+            letterSpacing:'0.15em', opacity: meta}}>
+            <span>READ · 4 MIN</span>
+            <span>MEMORY · 12 NOTES</span>
+            <span>CHAT · ASK AURELIUS</span>
+          </div>
+        </div>
+      </div>
+
+      {/* Subtitle */}
+      <div style={{textAlign:'center', marginTop: 30, opacity: subOp}}>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
+          color: INK}}>
+          迭代到 80%,再做最后 20% 的抛光
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 5: Closing + 4 checkpoints (18 – 22s) ───────────
+function Scene5_Closing() {
+  const { elapsed } = useSprite();
+  const opacity = interpolate(elapsed, [0, 0.6], [0, 1]);
+
+  const titleY = interpolate(elapsed, [0, 1.0], [30, 0], Easing.easeOut);
+  const lineW = interpolate(elapsed, [0.8, 1.6], [0, 620]);
+
+  const items = [
+    { text: '问 clarifying questions(一次一批)' },
+    { text: '写 assumptions + placeholders' },
+    { text: '尽早 show(哪怕只是灰块)' },
+    { text: '迭代再抛光,不一次做完' },
+  ];
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM, opacity,
+      display:'flex', alignItems:'center', justifyContent:'center',
+      flexDirection:'column', padding:'60px 120px'}}>
+
+      <div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
+        color: TERRA, marginBottom: 24}}>
+        JUNIOR DESIGNER · RECAP
+      </div>
+
+      <div style={{fontFamily: serif, fontSize: 100, fontWeight: 500,
+        color: INK, lineHeight: 1.05, letterSpacing:'-0.015em',
+        transform: `translateY(${titleY}px)`, textAlign:'center'}}>
+        早 <span style={{fontStyle:'italic', color: TERRA}}>show</span>
+        <span style={{color: ASH, fontWeight: 400, margin:'0 18px'}}>·</span>
+        早 <span style={{fontStyle:'italic', color: TERRA}}>改</span>
+        <span style={{color: ASH, fontWeight: 400, margin:'0 18px'}}>·</span>
+        早 <span style={{fontStyle:'italic', color: TERRA}}>对齐</span>
+      </div>
+
+      <div style={{height: 1, background: INK, width: lineW, marginTop: 34,
+        marginBottom: 46}} />
+
+      {/* 4 checkpoints */}
+      <div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:'18px 60px',
+        maxWidth: 1100, width:'100%'}}>
+        {items.map((it, i) => {
+          const appear = interpolate(elapsed, [1.6 + i * 0.25, 2.2 + i * 0.25], [0, 1]);
+          const checkAppear = interpolate(elapsed, [2.0 + i * 0.25, 2.5 + i * 0.25], [0, 1], Easing.spring);
+          return (
+            <div key={i} style={{display:'flex', alignItems:'center', gap: 20,
+              opacity: appear, borderBottom:`1px solid ${LINE}`, paddingBottom: 14}}>
+              <div style={{width: 36, height: 36, border:`2px solid ${TERRA}`,
+                display:'flex', alignItems:'center', justifyContent:'center',
+                flexShrink: 0, background: checkAppear > 0.5 ? TERRA : 'transparent',
+                color:'#fff', fontFamily: serif, fontWeight: 600, fontSize: 20,
+                lineHeight: 1, transform: `scale(${0.7 + checkAppear * 0.3})`}}>
+                {checkAppear > 0.5 ? '✓' : ''}
+              </div>
+              <div style={{fontFamily: serif, fontSize: 26, color: INK,
+                lineHeight: 1.35, letterSpacing:'0.005em'}}>
+                {it.text}
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
+// ── Watermark ─────────────────────────────────────────────
+function Watermark() {
+  return (
+    <div style={{position:'absolute', bottom: 24, right: 32,
+      fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
+      fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
+      Created by Huashu-Design
+    </div>
+  );
+}
+
+// ── Main composition ──────────────────────────────────────
+function App() {
+  return (
+    <Stage duration={22} width={1920} height={1080} bgColor={CREAM}>
+      <Sprite start={0} end={3}><Scene1_Contrast /></Sprite>
+      <Sprite start={3} end={8}><Scene2_Skeleton /></Sprite>
+      <Sprite start={8} end={13}><Scene3_FirstShow /></Sprite>
+      <Sprite start={13} end={18}><Scene4_FullPass /></Sprite>
+      <Sprite start={18} end={22}><Scene5_Closing /></Sprite>
+      <Watermark />
+    </Stage>
+  );
+}
+
+ReactDOM.createRoot(document.getElementById('root')).render(<App />);
+</script>
+</body>
+</html>

+ 747 - 0
demos/w3-fallback-advisor.html

@@ -0,0 +1,747 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<title>Huashu-Design · Fallback 设计顾问</title>
+<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
+<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
+<script src="https://unpkg.com/@babel/standalone@7.25.6/babel.min.js"></script>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,300;1,6..72,400;1,6..72,500&family=Noto+Serif+SC:wght@300;400;500;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
+<style>
+  * { box-sizing: border-box; margin: 0; padding: 0; }
+  html, body { width: 100%; height: 100%; overflow: hidden; }
+  body {
+    background: #0c0c0c;
+    font-family: 'Newsreader', 'Noto Serif SC', Georgia, serif;
+    color: #1a1a1a;
+    -webkit-font-smoothing: antialiased;
+    text-rendering: optimizeLegibility;
+  }
+</style>
+</head>
+<body>
+<div id="root"></div>
+
+<!-- animations.jsx inlined -->
+<script type="text/babel">
+(function() {
+  const { createContext, useContext, useState, useEffect, useRef, useCallback } = React;
+  const TimeContext = createContext({ time: 0, duration: 10, playing: false });
+  const SpriteContext = createContext(null);
+
+  const Easing = {
+    linear: t => t,
+    easeIn: t => t * t,
+    easeOut: t => 1 - (1 - t) * (1 - t),
+    easeInOut: t => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
+    spring: t => {
+      const c = (2 * Math.PI) / 3;
+      return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c) + 1;
+    },
+  };
+
+  function interpolate(t, input, output, easing) {
+    const [inStart, inEnd] = input;
+    const [outStart, outEnd] = output;
+    if (t <= inStart) return outStart;
+    if (t >= inEnd) return outEnd;
+    let progress = (t - inStart) / (inEnd - inStart);
+    if (easing) progress = easing(progress);
+    return outStart + (outEnd - outStart) * progress;
+  }
+
+  function useTime() { return useContext(TimeContext).time; }
+  function useSprite() {
+    const sprite = useContext(SpriteContext);
+    return sprite || { t: 0, elapsed: 0, duration: 0 };
+  }
+
+  function Stage({ duration = 10, width = 1920, height = 1080, loop = true, children, bgColor = '#fff' }) {
+    const [time, setTime] = useState(0);
+    const [playing, setPlaying] = useState(true);
+    const [scale, setScale] = useState(1);
+    const rafRef = useRef(null);
+    const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
+
+    useEffect(() => {
+      function updateScale() {
+        const vw = window.innerWidth;
+        const vh = window.innerHeight - 56;
+        const s = Math.min(vw / width, vh / height);
+        setScale(s);
+      }
+      updateScale();
+      window.addEventListener('resize', updateScale);
+      return () => window.removeEventListener('resize', updateScale);
+    }, [width, height]);
+
+    useEffect(() => {
+      if (!playing) return;
+      let cancelled = false;
+      let last = null;
+      function tick(now) {
+        if (cancelled) return;
+        if (last === null) {
+          last = now;
+          if (typeof window !== 'undefined') window.__ready = true;
+        }
+        const delta = (now - last) / 1000;
+        last = now;
+        setTime(prev => {
+          const next = prev + delta;
+          if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
+          return next;
+        });
+        rafRef.current = requestAnimationFrame(tick);
+      }
+      const start = () => { if (!cancelled) rafRef.current = requestAnimationFrame(tick); };
+      if (document.fonts && document.fonts.ready) document.fonts.ready.then(start); else start();
+      return () => { cancelled = true; cancelAnimationFrame(rafRef.current); };
+    }, [playing, duration, effectiveLoop]);
+
+    const progress = time / duration;
+    const ctx = { time, duration, playing, setPlaying, setTime };
+
+    const canvasStyle = {
+      position: 'absolute',
+      top: '50%',
+      left: '50%',
+      transformOrigin: 'center center',
+      width,
+      height,
+      background: bgColor,
+      overflow: 'hidden',
+      transform: `translate(-50%, -50%) scale(${scale})`,
+    };
+
+    return (
+      <TimeContext.Provider value={ctx}>
+        <div style={{position:'fixed', inset:0, background:'#0c0c0c', display:'flex', flexDirection:'column'}}>
+          <div style={{flex:1, position:'relative', overflow:'hidden'}}>
+            <div style={canvasStyle}>{children}</div>
+          </div>
+          <div className="no-record" style={{position:'fixed', bottom:0, left:0, right:0, background:'rgba(0,0,0,0.8)', backdropFilter:'blur(10px)', padding:'12px 20px', display:'flex', alignItems:'center', gap:16, color:'#fff', fontSize:12, zIndex:100}}>
+            <button onClick={()=>setPlaying(p=>!p)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>{playing?'⏸ 暂停':'▶ 播放'}</button>
+            <button onClick={()=>setTime(0)} style={{background:'none', border:'1px solid rgba(255,255,255,0.3)', color:'#fff', padding:'6px 14px', borderRadius:4, cursor:'pointer', fontSize:12}}>⏮ 开始</button>
+            <div style={{fontFamily:'ui-monospace, monospace', fontVariantNumeric:'tabular-nums', minWidth:90}}>{time.toFixed(2)}s / {duration.toFixed(2)}s</div>
+            <div style={{flex:1, height:4, background:'rgba(255,255,255,0.2)', borderRadius:2, position:'relative'}}>
+              <div style={{position:'absolute', top:0, left:0, height:'100%', width:`${progress*100}%`, background:'#fff', borderRadius:2}} />
+            </div>
+          </div>
+        </div>
+      </TimeContext.Provider>
+    );
+  }
+
+  function Sprite({ start = 0, end, children, style }) {
+    const { time } = useContext(TimeContext);
+    const actualEnd = end == null ? Infinity : end;
+    if (time < start || time >= actualEnd) return null;
+    const duration = actualEnd - start;
+    const elapsed = time - start;
+    const t = duration === 0 ? 1 : Math.max(0, Math.min(1, elapsed / duration));
+    const spriteValue = { t, elapsed, duration, start, end: actualEnd };
+    return (
+      <SpriteContext.Provider value={spriteValue}>
+        <div style={{position:'absolute', inset:0, ...style}}>{children}</div>
+      </SpriteContext.Provider>
+    );
+  }
+
+  window.Animations = { Stage, Sprite, useTime, useSprite, Easing, interpolate };
+})();
+</script>
+
+<!-- Demo scene -->
+<script type="text/babel">
+const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
+
+// ── Design tokens ─────────────────────────────────────────
+const CREAM = '#FAF6EF';
+const INK = '#1a1a1a';
+const TERRA = '#C04A1A';
+const ASH = '#6b6b6b';
+const LINE = '#d9d2c5';
+
+// ── 20 design philosophies ────────────────────────────────
+const PHILOSOPHIES = [
+  { n: 'Pentagram', school: '信息建筑', en: 'INFO-ARCH' },
+  { n: 'Massimo Vignelli', school: '信息建筑', en: 'INFO-ARCH' },
+  { n: 'Dieter Rams', school: '信息建筑', en: 'INFO-ARCH' },
+  { n: 'Otl Aicher', school: '信息建筑', en: 'INFO-ARCH' },
+  { n: 'Field.io', school: '运动诗学', en: 'KINETIC' },
+  { n: 'Active Theory', school: '运动诗学', en: 'KINETIC' },
+  { n: 'Locomotive', school: '运动诗学', en: 'KINETIC' },
+  { n: 'Joshua Davis', school: '运动诗学', en: 'KINETIC' },
+  { n: 'Kenya Hara', school: '东方哲学', en: 'EASTERN' },
+  { n: 'Naoto Fukasawa', school: '东方哲学', en: 'EASTERN' },
+  { n: 'Kashiwa Sato', school: '东方哲学', en: 'EASTERN' },
+  { n: 'John Maeda', school: '东方哲学', en: 'EASTERN' },
+  { n: 'Sagmeister', school: '实验先锋', en: 'AVANT' },
+  { n: 'David Carson', school: '实验先锋', en: 'AVANT' },
+  { n: 'Paula Scher', school: '实验先锋', en: 'AVANT' },
+  { n: 'Tomato', school: '实验先锋', en: 'AVANT' },
+  { n: 'Dan Flavin', school: '极简主义', en: 'MINIMAL' },
+  { n: 'Ryuichi Sakamoto', school: '极简主义', en: 'MINIMAL' },
+  { n: 'Agnes Martin', school: '极简主义', en: 'MINIMAL' },
+  { n: 'Donald Judd', school: '极简主义', en: 'MINIMAL' },
+];
+
+const SELECTED_INDICES = [0, 4, 8]; // Pentagram, Field.io, Kenya Hara
+
+// ── Shared typography helpers ─────────────────────────────
+const serif = "'Newsreader', 'Noto Serif SC', Georgia, serif";
+const sans = "'Inter', -apple-system, sans-serif";
+const mono = "'JetBrains Mono', ui-monospace, monospace";
+
+// ── Scene 1: Vague brief (0 – 3.5s) ───────────────────────
+function Scene1_VagueBrief() {
+  const { t, elapsed } = useSprite();
+  const charCount = Math.floor(interpolate(elapsed, [0.3, 1.8], [0, 9]));
+  const text = '做个好看的页面'.slice(0, charCount);
+  const cursorBlink = Math.floor(elapsed * 2.4) % 2 === 0;
+  const questionOpacity = interpolate(elapsed, [1.8, 2.4], [0, 1]);
+  const questionBob = Math.sin(elapsed * 4) * 6;
+  const fadeOut = interpolate(elapsed, [2.8, 3.5], [1, 0], Easing.easeIn);
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
+      display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
+      <div style={{fontFamily: sans, fontSize:14, letterSpacing:'0.3em',
+        color: ASH, marginBottom: 40}}>
+        用户需求
+      </div>
+      <div style={{display:'flex', alignItems:'flex-start', gap: 36}}>
+        <div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 120,
+          color: TERRA, lineHeight: 1, marginTop: -20}}>「</div>
+        <div style={{fontFamily: serif, fontSize: 96, fontWeight: 400,
+          color: INK, letterSpacing: '0.02em', position: 'relative'}}>
+          {text}
+          <span style={{opacity: cursorBlink ? 1 : 0, color: TERRA,
+            marginLeft: 4, fontWeight: 300}}>|</span>
+        </div>
+        <div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 120,
+          color: TERRA, lineHeight: 1, marginTop: -20}}>」</div>
+      </div>
+      <div style={{fontFamily: sans, fontSize: 20, color: ASH, marginTop: 60,
+        opacity: questionOpacity, transform: `translateY(${questionBob}px)`,
+        letterSpacing: '0.05em'}}>
+        <span style={{color: TERRA, fontSize: 28, marginRight: 12}}>?</span>
+        风格、受众、情感基调—— 都没说
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 2: Advisor activates (3.5 – 6.5s) ───────────────
+function Scene2_AdvisorIntro() {
+  const { elapsed } = useSprite();
+  const mainY = interpolate(elapsed, [0, 1.2], [40, 0], Easing.easeOut);
+  const mainOpacity = interpolate(elapsed, [0, 0.8], [0, 1]);
+  const lineWidth = interpolate(elapsed, [0.8, 1.8], [0, 320]);
+  const subOpacity = interpolate(elapsed, [1.2, 2], [0, 1]);
+  const fadeOut = interpolate(elapsed, [2.5, 3], [1, 0]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeOut,
+      display:'flex', alignItems:'center', justifyContent:'center', flexDirection:'column'}}>
+      <div style={{fontFamily: sans, fontSize: 12, letterSpacing: '0.4em',
+        color: TERRA, marginBottom: 24, opacity: mainOpacity}}>
+        设计方向顾问 · Fallback
+      </div>
+      <div style={{fontFamily: serif, fontSize: 132, fontWeight: 500,
+        color: INK, lineHeight: 1, letterSpacing: '-0.01em',
+        opacity: mainOpacity, transform: `translateY(${mainY}px)`}}>
+        推荐 <span style={{fontStyle:'italic', color: TERRA}}>3</span> 个方向
+      </div>
+      <div style={{height: 1, background: INK, width: lineWidth, marginTop: 36}} />
+      <div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 26,
+        color: ASH, marginTop: 28, opacity: subOpacity}}>
+        从 20 种设计哲学里,按 5 个不同流派差异化推荐
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 3: 20 philosophies grid scan (6.5 – 10.5s) ──────
+function Scene3_GridScan() {
+  const { elapsed } = useSprite();
+  const titleOp = interpolate(elapsed, [0, 0.4], [0, 1]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM,
+      padding: '80px 120px', display:'flex', flexDirection:'column'}}>
+      <div style={{display:'flex', justifyContent:'space-between',
+        alignItems:'baseline', opacity: titleOp, marginBottom: 50}}>
+        <div style={{fontFamily: serif, fontSize: 52, fontWeight: 500, color: INK}}>
+          设计哲学库
+        </div>
+        <div style={{fontFamily: mono, fontSize: 14, color: ASH, letterSpacing:'0.1em'}}>
+          20 位设计师 · 5 个流派
+        </div>
+      </div>
+      <div style={{display:'grid', gridTemplateColumns:'repeat(5, 1fr)', gap: 20, flex: 1}}>
+        {PHILOSOPHIES.map((p, i) => {
+          const stagger = i * 0.06;
+          const appearT = Math.max(0, Math.min(1, (elapsed - 0.5 - stagger) / 0.4));
+          const op = appearT;
+          const ty = (1 - appearT) * 24;
+
+          // Scanner highlight: sweeps through 20 cards from t=2.2 to t=3.2
+          const scannerStart = 2.2 + i * 0.04;
+          const scannerEnd = scannerStart + 0.25;
+          const scanHighlight = elapsed > scannerStart && elapsed < scannerEnd ? 1 : 0;
+
+          // Selected cards get circled at t=3.3+
+          const isSelected = SELECTED_INDICES.includes(i);
+          const selectT = Math.max(0, Math.min(1, (elapsed - 3.3) / 0.5));
+          const selectOp = isSelected ? selectT : 0;
+          const selectDim = !isSelected && elapsed > 3.4 ? interpolate(elapsed, [3.4, 3.8], [1, 0.28]) : 1;
+
+          return (
+            <div key={i} style={{
+              opacity: op * selectDim,
+              transform: `translateY(${ty}px)`,
+              background: scanHighlight ? '#fff' : 'transparent',
+              border: `1px solid ${isSelected && selectT > 0.3 ? TERRA : LINE}`,
+              borderWidth: isSelected && selectT > 0.3 ? 2 : 1,
+              padding: '20px 18px',
+              position: 'relative',
+              transition: 'none',
+            }}>
+              <div style={{fontFamily: mono, fontSize: 10, color: ASH,
+                letterSpacing: '0.15em', marginBottom: 10}}>
+                {String(i+1).padStart(2,'0')} · {p.en}
+              </div>
+              <div style={{fontFamily: serif, fontSize: 22, fontWeight: 500,
+                color: INK, lineHeight: 1.15, marginBottom: 6}}>
+                {p.n}
+              </div>
+              <div style={{fontFamily: serif, fontStyle: 'italic', fontSize: 14,
+                color: ASH}}>
+                {p.school}
+              </div>
+              {isSelected && selectT > 0.4 && (
+                <div style={{position:'absolute', top: -10, right: -10,
+                  width: 26, height: 26, borderRadius: '50%', background: TERRA,
+                  color: '#fff', display:'flex', alignItems:'center',
+                  justifyContent:'center', fontFamily: serif, fontSize: 14,
+                  fontWeight: 600, opacity: selectOp}}>
+                  {SELECTED_INDICES.indexOf(i) + 1}
+                </div>
+              )}
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 4: Three-panel parallel demo generation (10.5 – 19s) ──
+function Scene4_ParallelDemos() {
+  const { elapsed } = useSprite();
+  const slideIn = interpolate(elapsed, [0, 1], [200, 0], Easing.easeOut);
+  const opacity = interpolate(elapsed, [0, 0.6], [0, 1]);
+
+  const panels = [
+    { name: 'Pentagram', school: '信息建筑派', en: 'Information Architecture',
+      delay: 0, render: 'pentagram' },
+    { name: 'Field.io', school: '运动诗学派', en: 'Kinetic Poetry',
+      delay: 0.3, render: 'field' },
+    { name: 'Kenya Hara', school: '东方哲学派', en: 'Eastern Minimalism',
+      delay: 0.6, render: 'hara' },
+  ];
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM,
+      padding: '60px 60px 40px', display:'flex', flexDirection:'column',
+      opacity}}>
+      <div style={{display:'flex', justifyContent:'space-between',
+        alignItems:'baseline', marginBottom: 28}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+            letterSpacing: '0.3em', marginBottom: 4}}>步骤 3 / 4</div>
+          <div style={{fontFamily: serif, fontSize: 44, fontWeight: 500, color: INK}}>
+            并行生成视觉 Demo
+          </div>
+        </div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18,
+          color: ASH, textAlign: 'right'}}>
+          "看到比说到更有效"<br/>
+          <span style={{fontSize: 14}}>— 设计顾问模式 · Phase 5</span>
+        </div>
+      </div>
+
+      <div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap: 24,
+        flex: 1, transform: `translateY(${slideIn}px)`}}>
+        {panels.map((p, i) => (
+          <DemoPanel key={i} panel={p} localElapsed={elapsed - p.delay} />
+        ))}
+      </div>
+    </div>
+  );
+}
+
+function DemoPanel({ panel, localElapsed }) {
+  const progressT = Math.max(0, Math.min(1, localElapsed / 3.0));
+  const progressPct = progressT * 100;
+  const done = progressT >= 0.92;
+  // Content fades in during the last 0.7s of generation — overlaps with
+  // skeleton fade-out so there's no empty-canvas gap when "READY" appears.
+  const contentReveal = interpolate(localElapsed, [2.4, 3.2], [0, 1], Easing.easeOut);
+  const skeletonOp = interpolate(localElapsed, [2.4, 3.2], [1, 0], Easing.easeOut);
+
+  return (
+    <div style={{
+      background:'#fff',
+      border: `1px solid ${LINE}`,
+      display:'flex', flexDirection:'column',
+      position:'relative',
+    }}>
+      {/* Header */}
+      <div style={{padding: '18px 22px', borderBottom: `1px solid ${LINE}`,
+        display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
+        <div>
+          <div style={{fontFamily: serif, fontSize: 28, fontWeight: 500, color: INK}}>
+            {panel.name}
+          </div>
+          <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13,
+            color: ASH, marginTop: 2}}>
+            {panel.en}
+          </div>
+        </div>
+        <div style={{fontFamily: mono, fontSize: 10, color: done ? TERRA : ASH,
+          letterSpacing: '0.15em'}}>
+          {done ? '✓ READY' : 'GENERATING'}
+        </div>
+      </div>
+
+      {/* Canvas */}
+      <div style={{flex: 1, position: 'relative', overflow: 'hidden'}}>
+        {skeletonOp > 0.02 && (
+          <div style={{position:'absolute', inset:0, opacity: skeletonOp}}>
+            <GenerationSkeleton progress={progressT} />
+          </div>
+        )}
+        <div style={{position:'absolute', inset:0, opacity: contentReveal}}>
+          {panel.render === 'pentagram' && <PentagramDemo />}
+          {panel.render === 'field' && <FieldDemo elapsed={localElapsed - 3.2} />}
+          {panel.render === 'hara' && <HaraDemo />}
+        </div>
+      </div>
+
+      {/* Progress bar */}
+      <div style={{height: 2, background: '#eee', position: 'relative'}}>
+        <div style={{position:'absolute', top:0, left:0, height:'100%',
+          width: `${progressPct}%`, background: TERRA,
+          transition:'none'}} />
+      </div>
+    </div>
+  );
+}
+
+function GenerationSkeleton({ progress }) {
+  const bars = [60, 85, 40, 72, 90, 55, 68];
+  return (
+    <div style={{padding: 24, display:'flex', flexDirection:'column', gap: 14}}>
+      {bars.map((w, i) => (
+        <div key={i} style={{height: 10, width: `${w}%`,
+          background: `linear-gradient(90deg, ${LINE} 0%, ${LINE} ${100-progress*80}%, #fff ${100-progress*80}%)`,
+          opacity: 0.6 + progress * 0.4}} />
+      ))}
+      <div style={{fontFamily: mono, fontSize: 10, color: ASH,
+        marginTop: 20, letterSpacing:'0.1em'}}>
+        {progress < 0.3 && '▸ loading style tokens...'}
+        {progress >= 0.3 && progress < 0.6 && '▸ composing layout...'}
+        {progress >= 0.6 && progress < 0.9 && '▸ applying typography...'}
+        {progress >= 0.9 && '▸ finalizing...'}
+      </div>
+    </div>
+  );
+}
+
+// ── Pentagram demo: serif editorial, strict grid, monochrome ──
+function PentagramDemo() {
+  return (
+    <div style={{padding: '28px 28px 24px', background:'#fafafa', height:'100%',
+      display:'flex', flexDirection:'column', fontFamily: serif, color:'#111'}}>
+      <div style={{display:'flex', justifyContent:'space-between',
+        borderBottom:'1px solid #111', paddingBottom: 10, marginBottom: 16,
+        fontFamily: mono, fontSize: 9, letterSpacing:'0.2em'}}>
+        <span>VOL. 01 · MMXXVI</span>
+        <span>NO. 043</span>
+      </div>
+      <div style={{fontFamily: mono, fontSize: 10, letterSpacing:'0.3em',
+        color:'#888', marginBottom: 10}}>ESSAY</div>
+      <div style={{fontSize: 40, lineHeight: 1.05, fontWeight: 500,
+        letterSpacing: '-0.02em', marginBottom: 18}}>
+        A Pure<br/>
+        <span style={{fontStyle:'italic'}}>Information</span><br/>
+        Architecture
+      </div>
+      <div style={{height: 1, background:'#111', margin:'8px 0 14px'}} />
+      <div style={{fontSize: 13, lineHeight: 1.55, color:'#333', flex: 1}}>
+        Designed not to impress, but to inform. The grid carries meaning; typography does the work.
+      </div>
+      <div style={{borderTop:'1px solid #111', paddingTop: 10, marginTop: 14,
+        display:'flex', justifyContent:'space-between', fontFamily: mono,
+        fontSize: 9, letterSpacing:'0.2em', color:'#888'}}>
+        <span>NEW YORK</span>
+        <span>PENTAGRAM</span>
+      </div>
+    </div>
+  );
+}
+
+// ── Field.io demo: dark, kinetic geometric shapes ──
+function FieldDemo({ elapsed }) {
+  const e = Math.max(0, elapsed || 0);
+  return (
+    <div style={{padding: 0, background:'#0e1016', height:'100%',
+      position:'relative', overflow:'hidden'}}>
+      <svg viewBox="0 0 400 500" width="100%" height="100%"
+        style={{position:'absolute', inset:0}} preserveAspectRatio="xMidYMid slice">
+        <defs>
+          <linearGradient id="fg1" x1="0" y1="0" x2="1" y2="1">
+            <stop offset="0%" stopColor="#ff6a3d" />
+            <stop offset="100%" stopColor="#c04a1a" />
+          </linearGradient>
+          <linearGradient id="fg2" x1="0" y1="0" x2="1" y2="1">
+            <stop offset="0%" stopColor="#4a9eff" />
+            <stop offset="100%" stopColor="#1a4fc0" />
+          </linearGradient>
+        </defs>
+        {/* Concentric circles breathing */}
+        {[0, 1, 2, 3].map(i => (
+          <circle key={i} cx="200" cy="280"
+            r={40 + i * 50 + Math.sin(e * 1.2 + i) * 10}
+            fill="none" stroke="url(#fg1)" strokeWidth={1.5}
+            opacity={0.4 - i * 0.08} />
+        ))}
+        {/* Rotating triangle */}
+        <g transform={`translate(200 280) rotate(${e * 20})`}>
+          <polygon points="0,-70 60,35 -60,35" fill="url(#fg2)" opacity="0.7" />
+        </g>
+        {/* Orbiting dots */}
+        {[0, 1, 2, 3, 4, 5].map(i => {
+          const angle = (e * 0.8 + i * Math.PI / 3);
+          return <circle key={i} cx={200 + Math.cos(angle) * 150}
+            cy={280 + Math.sin(angle) * 150} r={4} fill="#ff6a3d" opacity={0.9}/>;
+        })}
+      </svg>
+      <div style={{position:'absolute', top: 24, left: 24, right: 24,
+        display:'flex', justifyContent:'space-between',
+        fontFamily: mono, fontSize: 10, letterSpacing:'0.3em', color:'#fff', opacity: 0.7}}>
+        <span>FIELD.IO</span>
+        <span>LIVE · RECORDING</span>
+      </div>
+      <div style={{position:'absolute', bottom: 24, left: 24, right: 24,
+        fontFamily: serif, fontStyle:'italic', fontSize: 20, color:'#fff',
+        letterSpacing:'0.02em'}}>
+        kinetic identity<br/>
+        <span style={{fontFamily: mono, fontSize: 10, fontStyle:'normal',
+          letterSpacing:'0.2em', color:'#ff6a3d', opacity: 0.8}}>
+          / motion is the brand
+        </span>
+      </div>
+    </div>
+  );
+}
+
+// ── Kenya Hara demo: vast white space, tiny dot, haiku ──
+function HaraDemo() {
+  return (
+    <div style={{padding: 0, background:'#fdfbf6', height:'100%',
+      position:'relative'}}>
+      <div style={{position:'absolute', top: 28, left: 32,
+        fontFamily: mono, fontSize: 10, letterSpacing:'0.3em', color:'#aaa'}}>
+        HARA · MMXXVI
+      </div>
+      <div style={{position:'absolute', top: '42%', left:'50%',
+        transform:'translate(-50%, -50%)', width: 14, height: 14,
+        borderRadius:'50%', background:'#1a1a1a'}} />
+      <div style={{position:'absolute', top:'58%', left:'50%',
+        transform:'translateX(-50%)', fontFamily: serif, fontStyle:'italic',
+        fontSize: 14, color:'#1a1a1a', letterSpacing:'0.1em'}}>
+        white.
+      </div>
+      <div style={{position:'absolute', bottom: 32, right: 32,
+        writingMode:'vertical-rl', fontFamily: "'Noto Serif SC', serif",
+        fontSize: 16, color:'#888', letterSpacing:'0.2em'}}>
+        原 研 哉
+      </div>
+      <div style={{position:'absolute', bottom: 28, left: 32,
+        fontFamily: serif, fontStyle:'italic', fontSize: 11, color:'#999',
+        maxWidth: 200, lineHeight: 1.6}}>
+        "Emptiness is not nothing—<br/>it is everything that could be."
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 5: User selects Kenya Hara (19 – 22s) ───────────
+function Scene5_Select() {
+  const { elapsed } = useSprite();
+  // Cursor travels from right edge toward middle panel
+  const cursorX = interpolate(elapsed, [0, 1.2], [1750, 960], Easing.easeInOut);
+  const cursorY = interpolate(elapsed, [0, 1.2], [240, 540], Easing.easeInOut);
+  const cursorOp = interpolate(elapsed, [0, 0.2], [0, 1]);
+
+  // Middle panel selection lock-in
+  const selectLock = Math.max(0, Math.min(1, (elapsed - 1.2) / 0.4));
+
+  // Left + right panels dim + shrink
+  const sideDim = interpolate(elapsed, [1.2, 1.8], [1, 0.2]);
+  const sideScale = interpolate(elapsed, [1.2, 1.8], [1, 0.92], Easing.easeOut);
+
+  // Middle scales up
+  const midScale = interpolate(elapsed, [1.2, 1.8], [1, 1.06], Easing.easeOut);
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM,
+      padding: '60px 60px 40px', display:'flex', flexDirection:'column'}}>
+      <div style={{display:'flex', justifyContent:'space-between',
+        alignItems:'baseline', marginBottom: 28}}>
+        <div>
+          <div style={{fontFamily: mono, fontSize: 11, color: TERRA,
+            letterSpacing: '0.3em', marginBottom: 4}}>步骤 4 / 4</div>
+          <div style={{fontFamily: serif, fontSize: 44, fontWeight: 500, color: INK}}>
+            用户选定方向
+          </div>
+        </div>
+        <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 18, color: ASH}}>
+          ——或混合:"A 的配色 + C 的布局"
+        </div>
+      </div>
+
+      <div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap: 24,
+        flex: 1}}>
+        <StaticPanel which="pentagram" opacity={sideDim} scale={sideScale} />
+        <StaticPanel which="hara" opacity={1} scale={midScale} selected={selectLock > 0.5}/>
+        <StaticPanel which="field" opacity={sideDim} scale={sideScale} />
+      </div>
+
+      {/* Cursor */}
+      <div style={{position:'absolute', left: cursorX, top: cursorY,
+        opacity: cursorOp, pointerEvents:'none', zIndex: 50,
+        filter:'drop-shadow(0 4px 8px rgba(0,0,0,0.2))'}}>
+        <svg width="36" height="44" viewBox="0 0 36 44">
+          <path d="M 2 2 L 2 38 L 11 30 L 16 42 L 22 40 L 17 28 L 28 28 Z"
+            fill="#fff" stroke="#1a1a1a" strokeWidth="2" strokeLinejoin="round"/>
+        </svg>
+      </div>
+
+      {/* "Selected" callout */}
+      {selectLock > 0.5 && (
+        <div style={{position:'absolute', left:'50%', top: 140,
+          transform:'translateX(-50%)', background: TERRA, color:'#fff',
+          padding:'10px 24px', fontFamily: mono, fontSize: 12,
+          letterSpacing:'0.25em', opacity: selectLock, zIndex: 40}}>
+          ✓ SELECTED
+        </div>
+      )}
+    </div>
+  );
+}
+
+function StaticPanel({ which, opacity, scale, selected }) {
+  const titles = {
+    pentagram: { n: 'Pentagram', en: 'Information Architecture' },
+    hara: { n: 'Kenya Hara', en: 'Eastern Minimalism' },
+    field: { n: 'Field.io', en: 'Kinetic Poetry' },
+  };
+  const t = titles[which];
+  return (
+    <div style={{
+      background:'#fff',
+      border: selected ? `3px solid ${TERRA}` : `1px solid ${LINE}`,
+      opacity, transform: `scale(${scale})`, transformOrigin:'center center',
+      display:'flex', flexDirection:'column',
+    }}>
+      <div style={{padding: '18px 22px', borderBottom: `1px solid ${LINE}`,
+        display:'flex', justifyContent:'space-between', alignItems:'baseline'}}>
+        <div>
+          <div style={{fontFamily: serif, fontSize: 28, fontWeight: 500, color: INK}}>
+            {t.n}
+          </div>
+          <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 13,
+            color: ASH, marginTop: 2}}>
+            {t.en}
+          </div>
+        </div>
+        <div style={{fontFamily: mono, fontSize: 10,
+          color: selected ? TERRA : '#999',
+          letterSpacing: '0.15em'}}>
+          {selected ? '✓ SELECTED' : 'READY'}
+        </div>
+      </div>
+      <div style={{flex: 1, position:'relative', overflow:'hidden'}}>
+        {which === 'pentagram' && <PentagramDemo />}
+        {which === 'field' && <FieldDemo elapsed={10} />}
+        {which === 'hara' && <HaraDemo />}
+      </div>
+    </div>
+  );
+}
+
+// ── Scene 6: Ready to execute (22 – 24s) ──────────────────
+function Scene6_Final() {
+  const { elapsed } = useSprite();
+  const fadeIn = interpolate(elapsed, [0, 0.6], [0, 1], Easing.easeOut);
+  const lineW = interpolate(elapsed, [0.6, 1.4], [0, 600]);
+
+  return (
+    <div style={{position:'absolute', inset:0, background:CREAM, opacity: fadeIn,
+      display:'flex', alignItems:'center', justifyContent:'center',
+      flexDirection:'column'}}>
+      <div style={{fontFamily: mono, fontSize: 12, letterSpacing:'0.4em',
+        color: TERRA, marginBottom: 20}}>
+        NEXT · JUNIOR DESIGNER PASS
+      </div>
+      <div style={{fontFamily: serif, fontSize: 104, fontWeight: 500,
+        color: INK, lineHeight: 1, letterSpacing:'-0.01em'}}>
+        开始 <span style={{fontStyle:'italic', color: TERRA}}>Kenya Hara</span> 风格
+      </div>
+      <div style={{height: 1, background: INK, width: lineW, marginTop: 36}} />
+      <div style={{fontFamily: serif, fontStyle:'italic', fontSize: 22,
+        color: ASH, marginTop: 28, maxWidth: 700, textAlign:'center', lineHeight: 1.5}}>
+        "方向确认 → 回到 Junior Designer 主干流程<br/>
+        这时已有明确的 design context,不再是凭空做"
+      </div>
+    </div>
+  );
+}
+
+// ── Watermark (always visible) ────────────────────────────
+function Watermark() {
+  return (
+    <div style={{position:'absolute', bottom: 24, right: 32,
+      fontSize: 11, color: 'rgba(0,0,0,0.38)', letterSpacing:'0.15em',
+      fontFamily: mono, pointerEvents:'none', zIndex: 100}}>
+      Created by Huashu-Design
+    </div>
+  );
+}
+
+// ── Main composition ──────────────────────────────────────
+function App() {
+  return (
+    <Stage duration={24} width={1920} height={1080} bgColor={CREAM}>
+      <Sprite start={0} end={3.5}><Scene1_VagueBrief /></Sprite>
+      <Sprite start={3.5} end={6.5}><Scene2_AdvisorIntro /></Sprite>
+      <Sprite start={6.5} end={10.5}><Scene3_GridScan /></Sprite>
+      <Sprite start={10.5} end={19}><Scene4_ParallelDemos /></Sprite>
+      <Sprite start={19} end={22}><Scene5_Select /></Sprite>
+      <Sprite start={22} end={24}><Scene6_Final /></Sprite>
+      <Watermark />
+    </Stage>
+  );
+}
+
+ReactDOM.createRoot(document.getElementById('root')).render(<App />);
+</script>
+</body>
+</html>

+ 153 - 18
references/animation-pitfalls.md

@@ -201,35 +201,164 @@
 
 **反例**:底部画 `00:42 ──── PROJECT NAME`、画面右下角画"CH 03 / 06"章节计数、画面边缘画版本号"v0.3.1"——都是伪 chrome filler。
 
-## 12. 录屏前置空白 —— 用 `window.__ready` 同步动画 t=0
+## 12. 录屏前置空白 + 录屏起点偏移 —— `__ready` × tick × lastTick 三联陷阱
 
-**踩的坑**:60 秒动画导出 MP4,前 2-3 秒是空白页面。`ffmpeg --trim=0.3` 剪不掉。
+**踩的坑(A · 前置空白)**:60 秒动画导出 MP4,前 2-3 秒是空白页面。`ffmpeg --trim=0.3` 剪不掉。
 
-**根因**:Playwright `recordVideo` 从 `newContext()` 那一刻就开始写 WebM。但此时 Babel Standalone 还在编译 inline JSX、React 还没 mount、`document.fonts.ready.then(root.render)` 还没触发。`page.goto(url, { waitUntil: 'networkidle' })` 只等网络空闲,检测不到 JS 执行阶段——所以 WebM 前 1.5-3s 是空白页。
+**踩的坑(B · 起点偏移,2026-04-20 真实事故)**:导出 24 秒视频,用户观感「视频 19 秒才开始播第一帧」。实际上动画从 t=5 开始录,录到 t=24 后 loop 回 t=0,再录 5 秒到 end——所以视频最后 5 秒才是动画真正的开头。
+
+**根因**(两个坑共享一个根因):
+
+Playwright `recordVideo` 从 `newContext()` 那一刻就开始写 WebM,此时 Babel/React/字体加载共耗时 L 秒(2-6s)。录屏脚本等 `window.__ready = true` 作为「动画从这里开始」的锚点——它和动画 `time = 0` 必须严格 pair。有两种常见错法:
+
+| 错法 | 症状 |
+|------|------|
+| `__ready` 在 `useEffect` 或同步 setup 阶段设(在 tick 第一帧之前) | 录屏脚本以为动画开始了,实际 WebM 还在录空白页 → **前置空白** |
+| tick 的 `lastTick = performance.now()` 在**脚本顶层**初始化 | 字体加载 L 秒被算进首帧 `dt`,`time` 瞬间跳到 L → 录屏全程滞后 L 秒 → **起点偏移** |
+
+**✅ 正确的完整 starter tick 模板**(手写动画必须用这个骨架):
+
+```js
+// ━━━━━━ state ━━━━━━
+let time = 0;
+let playing = false;   // ❗ 默认不播,等字体 ready 再启动
+let lastTick = null;   // ❗ sentinel——tick 首帧时 dt 强制为 0(别用 performance.now())
+const fired = new Set();
+
+// ━━━━━━ tick ━━━━━━
+function tick(now) {
+  if (lastTick === null) {
+    lastTick = now;
+    window.__ready = true;   // ✅ pair:「录屏起点」与「动画 t=0」同一帧
+    render(0);               // 再渲一次确保 DOM 就绪(此时字体已 ready)
+    requestAnimationFrame(tick);
+    return;
+  }
+  const dt = (now - lastTick) / 1000;   // 首帧之后 dt 才开始推进
+  lastTick = now;
+
+  if (playing) {
+    let t = time + dt;
+    if (t >= DURATION) {
+      t = window.__recording ? DURATION - 0.001 : 0;  // 录制时不 loop,留 0.001s 保留末帧
+      if (!window.__recording) fired.clear();
+    }
+    time = t;
+    render(time);
+  }
+  requestAnimationFrame(tick);
+}
+
+// ━━━━━━ boot ━━━━━━
+// 不要在顶层立即 rAF——等字体加载完才启动
+document.fonts.ready.then(() => {
+  render(0);                 // 先把初始画面画出来(字体已就绪)
+  playing = true;
+  requestAnimationFrame(tick);  // 首次 tick 会 pair __ready + t=0
+});
+
+// ━━━━━━ seek 接口(供 render-video 防御性矫正用)━━━━━━
+window.__seek = (t) => { fired.clear(); time = t; lastTick = null; render(t); };
+```
+
+**为什么这个模板对**:
+
+| 环节 | 为什么必须这样 |
+|------|-------------|
+| `lastTick = null` + 首帧 `return` | 避免「脚本加载到 tick 首次执行」的 L 秒被算进动画时间 |
+| `playing = false` 默认 | 字体加载期间 `tick` 即使运行也不推进 time,避免渲染错位 |
+| `__ready` 在 tick 首帧设 | 录屏脚本此刻开始计时,对应的画面是动画真正的 t=0 |
+| `document.fonts.ready.then(...)` 里才启动 tick | 规避字体 fallback 宽度测量、避免首帧字体跳变 |
+| `window.__seek` 存在 | 让 `render-video.js` 可以主动矫正——第二道防线 |
+
+**录屏脚本端的对应防御**:
+1. `addInitScript` 注入 `window.__recording = true`(先于 page goto)
+2. `waitForFunction(() => window.__ready === true)`,记录此刻偏移作为 ffmpeg trim
+3. **额外**:`__ready` 之后主动 `page.evaluate(() => window.__seek && window.__seek(0))`,把 HTML 可能的 time 偏差强制归零——这是第二道防线,对付不严格遵守 starter 模板的 HTML
+
+**验证方法**:导出 MP4 后
+```bash
+ffmpeg -i video.mp4 -ss 0 -vframes 1 frame-0.png
+ffmpeg -i video.mp4 -ss $DURATION-0.1 -vframes 1 frame-end.png
+```
+首帧必须是动画 t=0 的初始状态(不是中段,不是黑),末帧必须是动画终态(不是第二轮 loop 的某个时刻)。
+
+**参考实现**:`assets/animations.jsx` 的 Stage 组件、`scripts/render-video.js` 都已按此协议实现。手写 HTML 必须套 starter tick 模板——每一行都是防过具体 bug。
+
+## 13. 录制时禁止 loop —— `window.__recording` 信号
+
+**踩的坑**:动画 Stage 默认 `loop=true`(浏览器里方便看效果)。`render-video.js` 录完 duration 秒还多等 300ms 缓冲才停止,这 300ms 让 Stage 进入下一循环。ffmpeg `-t DURATION` 截取时,最后 0.5-1s 落入下一循环——视频结尾突然回到第一帧(Scene 1),观众以为视频出 bug。
+
+**根因**:录制脚本和 HTML 之间没有"我在录制"的握手协议。HTML 不知道自己被录,依然按浏览器交互场景循环。
 
 **规则**:
 
-1. **动画代码**在 tick 第一帧发 `window.__ready = true`,和动画 t=0 同步:
+1. **录制脚本**:在 `addInitScript` 里注入 `window.__recording = true`(先于 page goto)
    ```js
-   function tick(now) {
-     if (last === null) {
-       last = now;
-       window.__ready = true;  // 必须在这里,不是 useEffect
-     }
-     // ... 动画推进
-   }
+   await recordCtx.addInitScript(() => { window.__recording = true; });
    ```
-   为什么同步?如果 `__ready` 在 tick 之前设(如 `useEffect` 或 rAF 排队),触发时 WebM 光标还在空白页;如果在 tick 之后设(rAF 嵌套),则动画已经跑了几帧被 trim 掉。**pair 起来**才是对的。
 
-2. **录屏脚本**`page.goto` 之后 `waitForFunction(() => window.__ready === true)`,记录此时相对 WebM 起点的秒数作为 ffmpeg trim 偏移。完全不靠猜。
+2. **Stage 组件**:识别这个信号,强制 loop=false:
+   ```js
+   const effectiveLoop = (typeof window !== 'undefined' && window.__recording) ? false : loop;
+   // ...
+   if (next >= duration) return effectiveLoop ? 0 : duration - 0.001;
+   //                                                       ↑ 留 0.001 防止 Sprite end=duration 被关掉
+   ```
 
-3. **字体等待**放在 tick 启动条件里:`document.fonts.ready.then(() => rAF(tick))`。这样 tick 第一帧就是字体已就绪的画面——`__ready` 信号 = WebM 捕到的第一个"用户会看到"的动画帧。
+3. **结尾 Sprite 的 fadeOut**:录制场景下应设 `fadeOut={0}`,否则视频末尾会渐变到透明/暗色——用户期望停在清晰的最后一帧,不是淡出。手写 HTML 时建议结尾 Sprite 都用 `fadeOut={0}`
 
-**不这么做的代价**:固定 trim 靠猜。机器快慢、字体缓存状态、网速每次不同——某台机器上的 3s trim 换到另一台可能不够或截掉开头。动态测量从根本解决。
+**参考实现**:`assets/animations.jsx` 的 Stage / `scripts/render-video.js` 都已内置握手。手写 Stage 必须实现 `__recording` 检测——否则录制必踩这个坑。
+
+**验证**:导出 MP4 后 `ffmpeg -ss 19.8 -i video.mp4 -frames:v 1 end.png`,检查倒数 0.2 秒是否还是预期最后一帧,没有突然切换到另一个 scene。
+
+## 14. 60fps 视频默认用帧复制 —— minterpolate 兼容性差
+
+**踩的坑**:`convert-formats.sh` 用 `minterpolate=fps=60:mi_mode=mci...` 生成的 60fps MP4,在 macOS QuickTime / Safari 部分版本下无法打开(一片黑或直接拒打)。VLC / Chrome 能打开。
+
+**根因**:minterpolate 输出的 H.264 elementary stream 包含某些播放器解析有问题的 SEI / SPS 字段。
+
+**规则**:
+
+- 默认 60fps 用简单 `fps=60` filter(帧复制),兼容性广(QuickTime/Safari/Chrome/VLC 都能开)
+- 高质量插帧用 `--minterpolate` flag 显式启用——但**必须本地测过**目标播放器再交付
+- 60fps 标签价值是**上传平台的算法识别**(Bilibili / YouTube 上 60fps 标记会优先推流),实际感知流畅度对 CSS 动画来说提升微弱
+- 加 `-profile:v high -level 4.0` 提升 H.264 通用兼容性
+
+**`convert-formats.sh` 已默认改成兼容模式**。如果你需要插帧高质量,加 `--minterpolate` flag:
+```bash
+bash convert-formats.sh input.mp4 --minterpolate
+```
+
+## 15. `file://` + 外部 `.jsx` 的 CORS 陷阱 —— 单文件交付必须内联引擎
+
+**踩的坑**:动画 HTML 里用 `<script type="text/babel" src="animations.jsx"></script>` 外部加载引擎。本机双击打开(`file://` 协议)→ Babel Standalone 走 XHR 拉 `.jsx` → Chrome 报 `Cross origin requests are only supported for protocol schemes: http, https, chrome, chrome-extension...` → 整页黑屏,不报 `pageerror` 只报 console error,很容易当"动画没触发"误诊。
+
+启 HTTP server 也未必救得了——本机有全局代理时 `localhost` 也会走代理,返回 502 / 连接失败。
+
+**规则**:
+
+- **单文件交付(双击打开即用的 HTML)** → `animations.jsx` 必须**内联**到 `<script type="text/babel">...</script>` 标签内,不要用 `src="animations.jsx"`
+- **多文件项目(起 HTTP server 演示)** → 可以外部加载,但交付时明确写清 `python3 -m http.server 8000` 命令
+- 判断标准:交付给用户的是"HTML 文件"还是"带 server 的项目目录"?前者用内联
+- Stage 组件 / animations.jsx 经常 200+ 行——贴进 HTML `<script>` 块完全可接受,别怕体积
+
+**最小验证**:双击你生成的 HTML,**不要**通过任何 server 打开。如果 Stage 正常显示动画首帧,才算通过。
+
+## 16. 跨 scene 反色上下文 —— 画面内元素不要硬编码颜色
+
+**踩的坑**:做多场景动画时,`ChapterLabel` / `SceneNumber` / `Watermark` 等**跨 scene 都出现**的元素,在组件里写死 `color: '#1A1A1A'`(深色文字)。前 4 个 scene 浅底 OK,到第 5 个黑底 scene 时"05"和水印直接消失——不报错、不触发任何检查、关键信息隐形。
+
+**规则**:
 
-**参考实现**:`assets/animations.jsx` 的 Stage 组件已内置。`scripts/render-video.js` 已内置 auto-trim 逻辑。非 animations.jsx 的手写 HTML,自行在 tick/渲染循环的第一帧设信号。
+- **跨多 scene 复用的画面内元素**(chapter 标签 / scene 编号 / 时间码 / 水印 / 版权条)**禁止硬编码颜色值**
+- 改用三种方式之一:
+  1. **`currentColor` 继承**:元素只写 `color: currentColor`,父 scene 容器设 `color: 计算值`
+  2. **invert prop**:组件接受 `<ChapterLabel invert />` 手动切换深浅
+  3. **基于底色自动计算**:`color: contrast-color(var(--scene-bg))`(CSS 4 新 API,或 JS 判断)
+- 交付前用 Playwright 抽**每个 scene 的代表帧**,人眼过一遍"跨 scene 元素"是否都可见
 
-**验证方法**:导出后 `ffmpeg -i video.mp4 -ss 0 -vframes 1 frame-0.png`,检查第一帧是动画应有的初始状态(不是动画中段、不是黑屏)。
+这条坑的隐蔽性在于——**没有 bug 报警**。只有人眼或 OCR 能发现
 
 ## 快速自查清单(开工前 5 秒)
 
@@ -242,4 +371,10 @@
 - [ ] 第 0 帧是完整初始状态,不是空白?
 - [ ] 画面内没有「伪 chrome」装饰(进度条/时间码/底部署名条与 Stage scrubber 撞车)?
 - [ ] 动画 tick 第一帧同步设 `window.__ready = true`?(用 animations.jsx 自带;手写 HTML 自己加)
-- [ ] 导出后抽第 0 帧验证是动画初始状态?
+- [ ] Stage 检测 `window.__recording` 强制 loop=false?(手写 HTML 必加)
+- [ ] 结尾 Sprite 的 `fadeOut` 设为 0(视频末尾停清晰帧)?
+- [ ] 60fps MP4 默认用帧复制模式(兼容性),高质量插帧才加 `--minterpolate`?
+- [ ] 导出后抽第 0 帧 + 末帧验证是动画初始/最终状态?
+- [ ] 涉及具体品牌(Stripe/Anthropic/Lovart/...):走完了「品牌资产协议」(SKILL.md §1.a 五步)?有没有写 `brand-spec.md`?
+- [ ] 单文件交付的 HTML:`animations.jsx` 是内联的,不是 `src="..."`?(file:// 下 external .jsx 会 CORS 黑屏)
+- [ ] 跨 scene 出现的元素(chapter 标签/水印/scene 编号)没有硬编码颜色?在每个 scene 底色下都可见?

+ 243 - 0
references/editable-pptx.md

@@ -0,0 +1,243 @@
+# 可编辑 PPTX 导出:HTML 硬约束 + 尺寸决策 + 常见错误
+
+本文档讲的是**用 `scripts/html2pptx.js` + `pptxgenjs` 把 HTML 逐元素翻译成真·可编辑 PowerPoint 文本框**的路径。和 `export_deck_pptx.mjs --mode image`(截图铺底、文字变图片、不可编辑)是两回事。
+
+> **核心前提**:要走这条路,HTML 必须从第一行就按下面 4 条约束写。**不是写完再转**——事后补救会触发 2-3 小时返工(2026-04-20 期权私董会项目实测踩坑)。
+
+---
+
+## 画布尺寸:用 960×540pt(LAYOUT_WIDE)
+
+PPTX 单位是 **inch**(物理尺寸),不是 px。决策原则:body 的 computedStyle 尺寸要**匹配 presentation layout 的 inch 尺寸**(±0.1",由 `html2pptx.js` 的 `validateDimensions` 强制检查)。
+
+### 3 个候选尺寸对比
+
+| HTML body | 物理尺寸 | 对应 PPT layout | 何时选 |
+|---|---|---|---|
+| **`960pt × 540pt`** | **13.333″ × 7.5″** | **pptxgenjs `LAYOUT_WIDE`** | ✅ **默认推荐**(现代 PowerPoint 16:9 标配) |
+| `720pt × 405pt` | 10″ × 5.625″ | 自定义 | 仅当用户指定「老版 PowerPoint Widescreen」模板时 |
+| `1920px × 1080px` | 20″ × 11.25″ | 自定义 | ❌ 非标尺寸,投影后字体显得异常小 |
+
+**别把 HTML 尺寸当分辨率想。** PPTX 是矢量文档,body 尺寸决定的是**物理尺寸**不是清晰度。超大 body(20″×11.25″)不会让文字更清晰——只会让字号 pt 相对画布变小,投影/打印时反而更难看。
+
+### body 写法三选一(等价)
+
+```css
+body { width: 960pt;  height: 540pt; }    /* 最清晰,推荐 */
+body { width: 1280px; height: 720px; }    /* 等价,px 习惯 */
+body { width: 13.333in; height: 7.5in; }  /* 等价,英寸直觉 */
+```
+
+配套的 pptxgenjs 代码:
+
+```js
+const pptx = new pptxgen();
+pptx.layout = 'LAYOUT_WIDE';  // 13.333 × 7.5 inch, 无需自定义
+```
+
+---
+
+## 4 条硬约束(违反会直接报错)
+
+`html2pptx.js` 把 HTML 的 DOM 逐元素翻译成 PowerPoint 对象。PowerPoint 的格式约束投射到 HTML 上 = 下面 4 条规则。
+
+### 规则 1:DIV 里不能直接写文字 — 必须用 `<p>` 或 `<h1>`-`<h6>` 包裹
+
+```html
+<!-- ❌ 错误:文字直接在 div 里 -->
+<div class="title">Q3营收增长23%</div>
+
+<!-- ✅ 正确:文字在 <p> 或 <h1>-<h6> 里 -->
+<div class="title"><h1>Q3营收增长23%</h1></div>
+<div class="body"><p>新用户是主要驱动力</p></div>
+```
+
+**为什么**:PowerPoint 文本必须存在 text frame 里,text frame 对应 HTML 的段落级元素(p/h*/li)。裸 `<div>` 在 PPTX 里没有对应的文本容器。
+
+**也不能用 `<span>` 承载主文字**——span 是行内元素,没法独立对齐成文本框。span 只能**夹在 p/h\* 里**做局部样式(加粗、换色)。
+
+### 规则 2:不支持 CSS 渐变 — 只能用纯色
+
+```css
+/* ❌ 错误 */
+background: linear-gradient(to right, #FF6B6B, #4ECDC4);
+
+/* ✅ 正确:纯色 */
+background: #FF6B6B;
+
+/* ✅ 如果必须多色条纹,用 flex 子元素各自纯色 */
+.stripe-bar { display: flex; }
+.stripe-bar div { flex: 1; }
+.red   { background: #FF6B6B; }
+.teal  { background: #4ECDC4; }
+```
+
+**为什么**:PowerPoint 的 shape fill 只支持 solid/gradient-fill 两种,但 pptxgenjs 的 `fill: { color: ... }` 只映射 solid。渐变走 PowerPoint 原生 gradient 需要另写结构,目前工具链不支持。
+
+### 规则 3:背景/边框/阴影只能在 DIV 上,不能在文字标签上
+
+```html
+<!-- ❌ 错误:<p> 有背景色 -->
+<p style="background: #FFD700; border-radius: 4px;">重点内容</p>
+
+<!-- ✅ 正确:外层 div 承载背景/边框,<p> 只负责文字 -->
+<div style="background: #FFD700; border-radius: 4px; padding: 8pt 12pt;">
+  <p>重点内容</p>
+</div>
+```
+
+**为什么**:PowerPoint 里 shape(方块/圆角矩形)和 text frame 是两个对象。HTML 的 `<p>` 只翻译成 text frame,背景/边框/阴影属于 shape——必须在**包裹 text 的 div** 上写。
+
+### 规则 4:DIV 不能用 `background-image` — 用 `<img>` 标签
+
+```html
+<!-- ❌ 错误 -->
+<div style="background-image: url('chart.png')"></div>
+
+<!-- ✅ 正确 -->
+<img src="chart.png" style="position: absolute; left: 50%; top: 20%; width: 300pt; height: 200pt;" />
+```
+
+**为什么**:`html2pptx.js` 只从 `<img>` 元素提取图片路径,不解析 CSS 的 `background-image` URL。
+
+---
+
+## Path A HTML 模板骨架
+
+每张 slide 一个独立 HTML 文件,彼此作用域隔离(避开单文件 deck 的 CSS 污染)。
+
+```html
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<style>
+  * { margin: 0; padding: 0; box-sizing: border-box; }
+  body {
+    width: 960pt; height: 540pt;           /* ⚠️ 匹配 LAYOUT_WIDE */
+    font-family: system-ui, -apple-system, "PingFang SC", sans-serif;
+    background: #FEFEF9;                    /* 纯色,不能渐变 */
+    overflow: hidden;
+  }
+  /* DIV 负责布局/背景/边框 */
+  .card {
+    position: absolute;
+    background: #1A4A8A;                    /* 背景在 DIV 上 */
+    border-radius: 4pt;
+    padding: 12pt 16pt;
+  }
+  /* 文字标签只负责字体样式,不加背景/边框 */
+  .card h2 { font-size: 24pt; color: #FFFFFF; font-weight: 700; }
+  .card p  { font-size: 14pt; color: rgba(255,255,255,0.85); }
+</style>
+</head>
+<body>
+
+  <!-- 标题区:外层 div 定位,内层文字标签 -->
+  <div style="position: absolute; top: 40pt; left: 60pt; right: 60pt;">
+    <h1 style="font-size: 36pt; color: #1A1A1A; font-weight: 700;">标题用断言句,不是主题词</h1>
+    <p style="font-size: 16pt; color: #555555; margin-top: 10pt;">副标题补充说明</p>
+  </div>
+
+  <!-- 内容卡片:div 负责背景,h2/p 负责文字 -->
+  <div class="card" style="top: 130pt; left: 60pt; width: 240pt; height: 160pt;">
+    <h2>要点一</h2>
+    <p>简短说明文字</p>
+  </div>
+
+  <!-- 列表:使用 ul/li,不用手动 • 符号 -->
+  <div style="position: absolute; top: 320pt; left: 60pt; width: 540pt;">
+    <ul style="font-size: 16pt; color: #1A1A1A; padding-left: 24pt; list-style: disc;">
+      <li>第一条要点</li>
+      <li>第二条要点</li>
+      <li>第三条要点</li>
+    </ul>
+  </div>
+
+  <!-- 插图:用 <img> 标签,不用 background-image -->
+  <img src="illustration.png" style="position: absolute; right: 60pt; top: 110pt; width: 320pt; height: 240pt;" />
+
+</body>
+</html>
+```
+
+---
+
+## 常见错误速查
+
+| 错误信息 | 原因 | 修复方法 |
+|---------|------|---------|
+| `DIV element contains unwrapped text "XXX"` | div 里有裸文字 | 把文字包进 `<p>` 或 `<h1>`-`<h6>` |
+| `CSS gradients are not supported` | 用了 linear/radial-gradient | 改为纯色,或用 flex 子元素分段 |
+| `Text element <p> has background` | `<p>` 标签加了背景色 | 外套 `<div>` 承载背景,`<p>` 只写文字 |
+| `Background images on DIV elements are not supported` | div 用了 background-image | 改为 `<img>` 标签 |
+| `HTML content overflows body by Xpt vertically` | 内容超出 540pt | 减少内容或缩小字号,或 `overflow: hidden` 截断 |
+| `HTML dimensions don't match presentation layout` | body 尺寸和 pres layout 对不上 | body 用 `960pt × 540pt` 配 `LAYOUT_WIDE`;或 defineLayout 自定义尺寸 |
+| `Text box "XXX" ends too close to bottom edge` | 大字号 `<p>` 距离 body 底边 < 0.5 inch | 往上挪,留足下边距;PPT 底部本身就会被遮住一部分 |
+
+---
+
+## 基本工作流(3 步出 PPTX)
+
+### Step 1:按约束写每页独立 HTML
+
+```
+我的Deck/
+├── slides/
+│   ├── 01-cover.html    # 每个文件都是完整 960×540pt HTML
+│   ├── 02-agenda.html
+│   └── ...
+└── illustration/        # 所有 <img> 引用的图片
+    ├── chart1.png
+    └── ...
+```
+
+### Step 2:写 build.js 调用 `html2pptx.js`
+
+```js
+const pptxgen = require('pptxgenjs');
+const html2pptx = require('../scripts/html2pptx.js');  // 本 skill 脚本
+
+(async () => {
+  const pres = new pptxgen();
+  pres.layout = 'LAYOUT_WIDE';  // 13.333 × 7.5 inch,匹配 HTML 的 960×540pt
+
+  const slides = ['01-cover.html', '02-agenda.html', '03-content.html'];
+  for (const file of slides) {
+    await html2pptx(`./slides/${file}`, pres);
+  }
+
+  await pres.writeFile({ fileName: 'deck.pptx' });
+})();
+```
+
+### Step 3:打开检查
+
+- PowerPoint/Keynote 打开导出 PPTX
+- 双击任意文字应能直接编辑(如果是图片说明第 1 条违反了)
+- 验证 overflow:每页应该在 body 范围内,没有被截
+
+---
+
+## 这条路径 vs 其他选项(什么时候选什么)
+
+| 需求 | 选什么 |
+|------|------|
+| 同事会改 PPTX 里的文字 / 发给非技术人员继续编辑 | **本文路径**(editable,需从头按 4 条约束写 HTML) |
+| 只是演讲用 / 发存档,不再改 | `export_deck_pdf.mjs`(多文件)或 `export_deck_stage_pdf.mjs`(单文件 deck-stage),出矢量 PDF |
+| 视觉自由度优先(动画、web component、CSS 渐变、复杂 SVG),接受不可编辑 | `export_deck_pptx.mjs --mode image`(图片铺底 PPTX) |
+
+**绝不要在视觉自由写好的 HTML 上硬跑 html2pptx**——实测视觉驱动的 HTML pass 率 < 30%,剩下的逐页改造比重写还慢。
+
+---
+
+## 为什么 4 条约束不是 Bug 而是物理约束
+
+这 4 条不是 `html2pptx.js` 作者偷懒——它们是 **PowerPoint 文件格式(OOXML)本身的约束**投射到 HTML 上的结果:
+
+- PPTX 里文字必须在 text frame(`<a:txBody>`),对应段落级 HTML 元素
+- PPTX 的 shape 和 text frame 是两个对象,无法在同一 element 上同时画背景和写文字
+- PPTX 的 shape fill 对 gradient 支持有限(仅某些 preset gradients,不支持 CSS 任意角度渐变)
+- PPTX 的 picture 对象必须引用真实图片文件,不是 CSS 属性
+
+理解这点后,**不要期待工具变聪明** —— 是 HTML 写法要适配 PPTX 格式,不是反过来。

+ 200 - 21
references/slide-decks.md

@@ -1,8 +1,68 @@
 # Slide Decks:HTML幻灯片制作规范
 
-做幻灯片是设计工作的高频场景。这份文档说明怎么做好HTML幻灯片。
+做幻灯片是设计工作的高频场景。这份文档说明怎么做好HTML幻灯片——从架构选型、单页设计,到 PDF/PPTX 导出的完整路径
 
-**和huashu-slides skill的区别**:huashu-slides focus on「AI演示文稿全流程」(含PPT导出);本文档focus on「用HTML本身作为呈现媒介」的设计方法论。两者可以配合使用——用这里的方法做高质量的HTML deck,再用huashu-slides导出成PPTX。
+**本 skill 的能力覆盖**:
+- HTML 播放/PDF 导出 → 本文档 + `scripts/export_deck_pdf.mjs` / `scripts/export_deck_stage_pdf.mjs`
+- 可编辑 PPTX 导出 → `references/editable-pptx.md` + `scripts/html2pptx.js` + `scripts/export_deck_pptx.mjs --mode editable`
+- 图片铺底 PPTX(不可编辑但视觉保真)→ `scripts/export_deck_pptx.mjs --mode image`
+
+---
+
+## 🛑 开工前先确认交付格式(最硬的 checkpoint)
+
+**这个决策比「单文件还是多文件」更先。** 2026-04-20 期权私董会项目实测:**不在动手前确认交付格式 = 2-3 小时返工。**
+
+### 决策树
+
+```
+│ 问:最终要交付什么?
+├── 只要浏览器全屏演讲 / 本地 HTML    → 视觉最自由,随便做
+├── 要 PDF(打印 / 发群 / 存档)      → 视觉最自由,任何架构都能导出
+└── 要可编辑 PPTX(同事会改文字)    → 🛑 从第一行 HTML 开始就按 `references/editable-pptx.md` 的 4 条硬约束写
+```
+
+### 为什么「要 PPTX 就得从头走 Path A」
+
+PPTX 可编辑的前提是 `html2pptx.js` 能把 DOM 逐元素翻译为 PowerPoint 对象。它需要 **4 条硬约束**:
+
+1. body 固定 720pt × 405pt(不是 1920×1080px)
+2. 所有文字包在 `<p>`/`<h1>`-`<h6>` 里(禁止 div 直接放文字,禁止用 `<span>` 承载主文字)
+3. `<p>`/`<h*>` 自身不能有 background/border/shadow(放外层 div)
+4. `<div>` 不能用 `background-image`(用 `<img>` 标签)
+5. 不用 CSS gradient、不用 web component、不用复杂 SVG 装饰
+
+**本 skill 默认的 HTML 视觉自由度高**——大量 span、嵌套 flex、复杂 SVG、web component(如 `<deck-stage>`)、CSS 渐变——**几乎没有一条能天然过 html2pptx 的约束**(实测视觉驱动的 HTML 直接上 html2pptx,pass 率 < 30%)。
+
+### 两条真实路径的代价对比(2026-04-20 真实踩坑)
+
+| 路径 | 做法 | 结果 | 代价 |
+|------|------|------|------|
+| ❌ **先自由写 HTML,事后补救 PPTX** | 单文件 deck-stage + 大量 SVG/span 装饰 | 要可编辑 PPTX 只剩两条路:<br>A. 手写 pptxgenjs 几百行 hardcode 坐标<br>B. 重写 17 页 HTML 成 Path A 格式 | 2-3 小时返工,且手写版**维护成本永续**(HTML 改一个字,PPTX 要再人肉同步) |
+| ✅ **从第一步按 Path A 约束写** | 每页独立 HTML + 4 条硬约束 + 720×405pt | 一条命令导出 100% 可编辑 PPTX,同时也能浏览器全屏演讲(Path A HTML 就是浏览器可播放的标准 HTML) | 写 HTML 时多花 5 分钟想「文字怎么包进 `<p>`」,零返工 |
+
+### 开工话术(抄走即用)
+
+> 动手之前先确认交付格式:
+> - **浏览器演讲 / PDF** → 我按设计自由度最大的方式做(可以用动画、web component、复杂 SVG、CSS 渐变)
+> - **需要可编辑 PPTX**(同事会改文字) → 我必须从一开始就按 `references/editable-pptx.md` 的 4 条硬约束写 HTML。视觉能力会少一些(无渐变、无 web component、无复杂 SVG),但导出就是一条命令的事
+>
+> 你选哪条?
+
+### 混合交付怎么办
+
+用户说「我要 HTML 演讲 **和** 可编辑 PPTX」——**这不是混合**,是 PPTX 需求覆盖 HTML 需求。按 Path A 写出来的 HTML 本身就能浏览器全屏演讲(加个 `deck_index.html` 拼接器就行)。**没有额外代价。**
+
+用户说「我要 PPTX **和** 动画 / web component」——**这是真矛盾**。告诉用户:要可编辑 PPTX 就得牺牲这些视觉能力。让他做取舍,不要偷偷做手写 pptxgenjs 方案(会变成永续维护债)。
+
+### 事后才知道要 PPTX 怎么办(紧急补救)
+
+极个别情况:HTML 已经写好了才发现要 PPTX。此时两个选项都不完美:
+
+1. **图片铺底 PPTX**(`scripts/export_deck_pptx.mjs` image 模式)——视觉 100% 保真但文字不可编辑。适合「演讲用 PPT 播放、不改内容」场景
+2. **手写 pptxgenjs 重建**(为每页手写 addText/addShape + 图形 PNG 嵌入)——文字可编辑,但位置、字体、对齐都要手调,维护成本高。**只有用户明确接受「HTML 源要改就得重新手调 PPTX」才走这条**
+
+永远优先把选择告诉用户,让他决定。**永远不要第一反应就开始手写 pptxgenjs**——那是最后的兜底手段。
 
 ---
 
@@ -142,49 +202,113 @@ Playwright 截图也是直接 `goto(file://.../slides/05-personas.html)`,不
 ### 基本用法
 
 1. 从 `assets/deck_stage.js` 读取内容,嵌入 HTML 的 `<script>`(或 `<script src="deck_stage.js">`)
-2. 在 body 里用 `<deck-stage>` 包 slide:
+2. 在 body 里用 `<deck-stage>` 包 slide
+3. 🛑 **script 标签必须放在 `</deck-stage>` 之后**(见下方硬约束)
 
 ```html
-<deck-stage>
-  <section>
-    <h1>Slide 1</h1>
-  </section>
-  <section>
-    <h1>Slide 2</h1>
-  </section>
+<body>
+
+  <deck-stage>
+    <section>
+      <h1>Slide 1</h1>
+    </section>
+    <section>
+      <h1>Slide 2</h1>
+    </section>
+  </deck-stage>
+
+  <!-- ✅ 正确:script 在 deck-stage 之后 -->
+  <script src="deck_stage.js"></script>
+
+</body>
+```
+
+### 🛑 Script 位置硬约束(2026-04-20 真实踩坑)
+
+**不能把 `<script src="deck_stage.js">` 放在 `<head>` 里。** 即使它在 `<head>` 里能定义 `customElements`,parser 在解析到 `<deck-stage>` 开始标签时就会触发 `connectedCallback`——此时子 `<section>` 还没被 parse,`_collectSlides()` 拿到空数组,counter 显示 `1 / 0`,所有页同时叠加渲染。
+
+**三条合规写法**(任选其一):
+
+```html
+<!-- ✅ 最推荐:script 在 </deck-stage> 之后 -->
 </deck-stage>
+<script src="deck_stage.js"></script>
+
+<!-- ✅ 也可:script 在 head 但加 defer -->
+<head><script src="deck_stage.js" defer></script></head>
+
+<!-- ✅ 也可:module 脚本天然 defer -->
+<head><script src="deck_stage.js" type="module"></script></head>
 ```
 
+`deck_stage.js` 本身已内置 `DOMContentLoaded` 延迟收集防御,即使 script 放 head 也不会彻底炸掉——但 `defer` 或放 body 底部仍然是更干净的做法,避免依赖防御分支。
+
 ### ⚠️ 单文件架构的 CSS 陷阱(务必阅读)
 
 单文件架构最常见的坑——**`display` 属性被单页样式偷走**。
 
-如果你给某张 slide 的样式写了 `display: flex/grid`:
+常见错误姿势 1(直接写 display: flex 到 section)
 
 ```css
-.emotion-slide { display: grid; }   /* 特异性: 10 */
+/* ❌ 外部 CSS 特异性 2,覆盖了 shadow DOM 的 ::slotted(section){display:none}(也是 2)*/
+deck-stage > section {
+  display: flex;            /* 所有页会同时叠加渲染! */
+  flex-direction: column;
+  padding: 80px;
+  ...
+}
 ```
 
-它会压过 deck 级的「隐藏非 active 页」规则:
+常见错误姿势 2(section 有特异性更高的 class)
 
 ```css
-deck-stage > section { display: none; }   /* 特异性: 2 */
+.emotion-slide { display: grid; }   /* 特异性: 10,更糟 */
 ```
 
-结果所有 slide 同时渲染叠加。
+两种都会让 **所有 slide 同时叠加渲染**——counter 可能显示 `1 / 10` 假装正常,但视觉上第一页盖着第二页盖着第三页。
+
+### ✅ Starter CSS(开工直接 copy,不踩坑)
 
-**修复模式**(写 deck 时必做):
+**section 自身**只管「可见/不可见」;**layout(flex/grid 等)写到 `.active` 上**
 
 ```css
-/* ✅ 用 :not(.active) + !important 锁死「非激活即隐藏」 */
+/* section 只定义非 display 的通用样式 */
+deck-stage > section {
+  background: var(--paper);
+  padding: 80px 120px;
+  overflow: hidden;
+  position: relative;
+  /* ⚠️ 不要在这里写 display! */
+}
+
+/* 锁死「非激活即隐藏」——特异性+权重双保险 */
 deck-stage > section:not(.active) {
   display: none !important;
 }
+
+/* 激活页才写需要的 display + layout */
+deck-stage > section.active {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+}
+
+/* 打印模式:所有页都要显示,覆盖 :not(.active) */
+@media print {
+  deck-stage > section { display: flex !important; }
+  deck-stage > section:not(.active) { display: flex !important; }
+}
 ```
 
-特异性+权重双保险,单页 layout class 的 `display: grid/flex` 就不会影响可见性。
+替代方案:**把单页的 flex/grid 写到内部 wrapper `<div>` 上**,section 本身永远只是 `display: block/none` 的切换器。这是最干净的做法:
 
-另一个替代方案:**把单页的 flex/grid 写到内部 wrapper div 上**,section 本身只管 `display: block/none`。
+```html
+<deck-stage>
+  <section>
+    <div class="slide-content flex-layout">...</div>
+  </section>
+</deck-stage>
+```
 
 ### 自定义尺寸
 
@@ -316,7 +440,7 @@ Deck 需要 **intentional variety**:
 
 HTML 优先是第一公民。但用户经常需要 PPTX/PDF 交付。提供两个通用脚本,**任何多文件 deck 都能用**,位于 `scripts/` 下:
 
-### `export_deck_pdf.mjs` — 导出矢量 PDF(推荐
+### `export_deck_pdf.mjs` — 导出矢量 PDF(多文件架构
 
 ```bash
 node scripts/export_deck_pdf.mjs --slides <slides-dir> --out deck.pdf
@@ -332,6 +456,61 @@ node scripts/export_deck_pdf.mjs --slides <slides-dir> --out deck.pdf
 
 **限制**:PDF 不能再编辑文字——要改回到 HTML 改。
 
+### `export_deck_stage_pdf.mjs` — 单文件 deck-stage 架构专用 ⚠️
+
+**什么时候用**:deck 是单 HTML 文件 + `<deck-stage>` web component 包裹 N 个 `<section>`(即路径 B 架构)。此时 `export_deck_pdf.mjs` 那套「每个 HTML 一次 `page.pdf()`」走不通,需要走这个专用脚本。
+
+```bash
+node scripts/export_deck_stage_pdf.mjs --html deck.html --out deck.pdf
+```
+
+**为什么不能复用 export_deck_pdf.mjs**(2026-04-20 真实踩坑记录):
+
+1. **Shadow DOM 赢过 `!important`**:deck-stage 的 shadow CSS 里有 `::slotted(section) { display: none }`(只 active 的那张 `display: block`)。即使在 light DOM 用 `@media print { deck-stage > section { display: block !important } }` 也压不住——`page.pdf()` 触发 print 媒体后 Chromium 最终渲染只有 active 那一张,结果**整个 PDF 只有 1 页**(当前 active slide 的重复)。
+
+2. **循环 goto 每页还是只出 1 页**:直觉解法「对每个 `#slide-N` navigate 一次再 `page.pdf({pageRanges:'1'})`」也失败——因为 print CSS 在 shadow DOM 之外也有 `deck-stage > section { display: block }` 规则被 override 后,最终渲染永远是 section 列表的第一个(不是你 navigate 到的那一页)。结果 17 次循环得到 17 张 P01 封面。
+
+3. **absolute 子元素跑到下一页**:即使成功让所有 section 渲染出来,section 本身若 `position: static`,其 absolute 定位的 `cover-footer`/`slide-footer` 会相对 initial containing block 定位——当 section 被 print 强制为 1080px 高度,absolute footer 可能被推到下一页(表现为 PDF 比 section 数量多 1 页,多出来的那页只含 footer 孤儿)。
+
+**修复策略**(脚本已实现):
+
+```js
+// 打开 HTML 后,用 page.evaluate 把 section 从 deck-stage slot 中提出来,
+// 直接挂到 body 下一个普通 div 里,并内联 style 确保 position:relative + 固定尺寸
+await page.evaluate(() => {
+  const stage = document.querySelector('deck-stage');
+  const sections = Array.from(stage.querySelectorAll(':scope > section'));
+  document.head.appendChild(Object.assign(document.createElement('style'), {
+    textContent: `
+      @page { size: 1920px 1080px; margin: 0; }
+      html, body { margin: 0 !important; padding: 0 !important; }
+      deck-stage { display: none !important; }
+    `,
+  }));
+  const container = document.createElement('div');
+  sections.forEach(s => {
+    s.style.cssText = 'width:1920px!important;height:1080px!important;display:block!important;position:relative!important;overflow:hidden!important;page-break-after:always!important;break-after:page!important;background:#F7F4EF;margin:0!important;padding:0!important;';
+    container.appendChild(s);
+  });
+  // 最后一页禁分页,避免尾部空白页
+  sections[sections.length - 1].style.pageBreakAfter = 'auto';
+  sections[sections.length - 1].style.breakAfter = 'auto';
+  document.body.appendChild(container);
+});
+
+await page.pdf({ width: '1920px', height: '1080px', printBackground: true, preferCSSPageSize: true });
+```
+
+**为什么这能 work**:
+- 把 section 从 shadow DOM slot 拔到 light DOM 的普通 div——彻底绕过 `::slotted(section) { display: none }` 规则
+- 内联 `position: relative` 让 absolute 子元素相对 section 定位,不会溢出
+- `page-break-after: always` 让浏览器 print 时每 section 独立一页
+- `:last-child` 不分页避免尾部空白页
+
+**用 `mdls -name kMDItemNumberOfPages` 验证时注意**:macOS 的 Spotlight metadata 有缓存,PDF 重写后要跑 `mdimport file.pdf` 强制刷新,否则显示旧的页数。用 `pdfinfo` 或 `pdftoppm` 数文件数才是真数。
+
+---
+
 ### `export_deck_pptx.mjs` — 导出 PPTX(两种模式)
 
 ```bash
@@ -393,7 +572,7 @@ node scripts/export_deck_pptx.mjs --slides <dir> --out deck.pptx --mode editable
 
 ## 导出为可编辑 PPTX 的深度路径(仅长期项目)
 
-如果你的 deck 会长期维护、反复修改、团队协作——建议**一开始就按 html2pptx 约束写 HTML**,让 `--mode editable` 稳定通过。详见 `huashu-slides` skill 的 `references/prompt-templates.md` 第 2 节(4 条硬性约束)。
+如果你的 deck 会长期维护、反复修改、团队协作——建议**一开始就按 html2pptx 约束写 HTML**,让 `--mode editable` 稳定通过。详见 `references/editable-pptx.md`(4 条硬约束 + HTML 模板 + 常见错误速查)。
 
 ---
 

+ 8 - 2
references/verification.md

@@ -21,7 +21,7 @@ open -a "Google Chrome" "/path/to/your/design.html"
 HTML文件里最常见的问题是JS报错导致白屏。用Playwright跑一遍:
 
 ```bash
-python "$SKILL/scripts/verify.py" path/to/design.html
+python ~/.claude/skills/claude-design/scripts/verify.py path/to/design.html
 ```
 
 这个脚本会:
@@ -120,7 +120,13 @@ open screenshot.png
 
 ### 上传图床分享链接
 
-如果需要给远程协作者看(比如 Slack/飞书/微信),让用户用自己的图床工具或 MCP 上传。任何返回永久链接的图床都行(ImgBB / Cloudflare R2 / 七牛 / 自建等),粘贴到任何地方。
+如果需要给远程协作者看(比如 Slack/飞书/微信),让用户用自己的图床工具或 MCP 上传:
+
+```bash
+python ~/Documents/写作/tools/upload_image.py screenshot.png
+```
+
+返回ImgBB的永久链接,可以粘贴到任何地方。
 
 ## 验证出错时
 

+ 14 - 2
references/video-export.md

@@ -86,13 +86,22 @@ bash add-music.sh product-promo.mp4 --mood=ad --out=promo-final.mp4
 从已有 MP4 生成 60fps 版本和 GIF。
 
 ```bash
-bash /path/to/claude-design/scripts/convert-formats.sh <input.mp4> [gif_width]
+bash /path/to/claude-design/scripts/convert-formats.sh <input.mp4> [gif_width] [--minterpolate]
 ```
 
 输出(与输入同目录):
-- `<name>-60fps.mp4` — minterpolate 插帧到 60fps
+- `<name>-60fps.mp4` — 默认用 `fps=60` 帧复制(兼容性广);加 `--minterpolate` 启用高质量插帧
 - `<name>.gif` — palette 优化的 GIF(默认 960 宽,可改)
 
+**60fps 模式选择**:
+
+| 模式 | 命令 | 兼容性 | 使用场景 |
+|---|---|---|---|
+| 帧复制(默认)| `convert-formats.sh in.mp4` | QuickTime/Safari/Chrome/VLC 全通 | 通用交付、上传平台、社交媒体 |
+| minterpolate 插帧 | `convert-formats.sh in.mp4 --minterpolate` | macOS QuickTime/Safari 可能拒打 | B站等需要真插帧的展示场景,**交付前必须本地测**目标播放器 |
+
+为什么默认改成帧复制?minterpolate 输出的 H.264 elementary stream 有 known compat bug——之前默认 minterpolate 时多次踩到「macOS QuickTime 打不开」的问题。详见 `animation-pitfalls.md` §14。
+
 `gif_width` 参数:
 - 960(默认)—— 社交平台通用
 - 1280 —— 更清晰但文件更大
@@ -164,6 +173,9 @@ GIF 只能 256 色。一次 pass 的 GIF 会把全动画色彩压到 256 色通
 - [ ] 动画最后一帧是稳定的收尾状态(不是半截)
 - [ ] 字体/图片/emoji 全部正常渲染(参考 `animation-pitfalls.md`)
 - [ ] Duration 参数与 HTML 里的实际动画时长匹配
+- [ ] HTML 中 Stage 检测 `window.__recording` 强制 loop=false(手写 Stage 必查;用 `assets/animations.jsx` 自带)
+- [ ] 结尾 Sprite 的 `fadeOut={0}`(视频末帧不淡出)
+- [ ] 含「Created by Huashu-Design」水印(仅动画场景必加;第三方品牌作品加「非官方出品 · 」前缀。详见 SKILL.md §「Skill 推广水印」)
 
 ## 交付时附带的说明
 

+ 45 - 13
scripts/convert-formats.sh

@@ -1,18 +1,27 @@
 #!/bin/bash
-# Convert MP4 animations to 60fps MP4 (via minterpolate) and optimized GIF.
+# Convert MP4 animations to 60fps MP4 and optimized GIF.
 #
 # Usage:
-#   ./convert-formats.sh input.mp4 [gif_width]
+#   ./convert-formats.sh input.mp4 [gif_width] [--minterpolate]
 #
 # Produces next to the input:
-#   <name>-60fps.mp4   (1920x1080, 60fps, motion-interpolated)
+#   <name>-60fps.mp4   (1920x1080, 60fps, frame-duplicated by default)
 #   <name>.gif         (scaled width, 15fps, palette-optimized)
 #
-# minterpolate flags:
-#   mi_mode=mci              motion compensation interpolation
-#   mc_mode=aobmc            adaptive overlapped block motion comp
-#   me_mode=bidir            bidirectional motion estimation
-#   vsbmc=1                  variable-size block motion comp
+# Flags:
+#   --minterpolate     Enable motion-compensated interpolation (high quality
+#                      but elementary stream has known QuickTime/Safari
+#                      compat issues — only use if your player handles it).
+#
+# Default 60fps mode: simple `fps=60` filter (frame duplication). Wide
+# compatibility, plays in QuickTime / Safari / Chrome / VLC. The 60fps
+# label is for upload-platform optics; perceived smoothness is identical
+# to the source 25fps for most CSS-driven motion.
+#
+# When to enable --minterpolate: heavy translate/scale motion where you
+# want true 60fps interpolation. WARN: macOS QuickTime sometimes refuses
+# to open minterpolate output. Test before delivering.
+#
 # GIF uses two-pass palette:
 #   pass 1: palettegen with stats_mode=diff (per-video optimal palette)
 #   pass 2: paletteuse with bayer dither + rectangle diff
@@ -20,8 +29,21 @@
 
 set -e
 
-INPUT="${1:?Usage: $0 input.mp4 [gif_width]}"
-GIF_WIDTH="${2:-960}"
+INPUT=""
+GIF_WIDTH="960"
+USE_MINTERPOLATE=0
+for arg in "$@"; do
+  case "$arg" in
+    --minterpolate) USE_MINTERPOLATE=1 ;;
+    --*) echo "Unknown flag: $arg" >&2; exit 1 ;;
+    *)
+      if [ -z "$INPUT" ]; then INPUT="$arg"
+      else GIF_WIDTH="$arg"
+      fi
+      ;;
+  esac
+done
+[ -z "$INPUT" ] && { echo "Usage: $0 input.mp4 [gif_width] [--minterpolate]" >&2; exit 1; }
 
 DIR=$(dirname "$INPUT")
 BASE=$(basename "$INPUT" .mp4)
@@ -29,10 +51,20 @@ OUT60="$DIR/$BASE-60fps.mp4"
 OUTGIF="$DIR/$BASE.gif"
 PAL="$DIR/.palette-$BASE.png"
 
-echo "▸ 60fps interpolate: $OUT60"
+if [ "$USE_MINTERPOLATE" = "1" ]; then
+  echo "▸ 60fps interpolate (minterpolate, high quality): $OUT60"
+  VFILTER="minterpolate=fps=60:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1"
+else
+  echo "▸ 60fps frame-duplicate (compat mode): $OUT60"
+  VFILTER="fps=60"
+fi
+
+# -profile:v high -level 4.0 → broad H.264 compatibility (QuickTime, Safari, mobile)
+# -movflags +faststart        → moov atom upfront, streamable / instant-play
 ffmpeg -y -loglevel error -i "$INPUT" \
-  -vf "minterpolate=fps=60:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1" \
-  -c:v libx264 -pix_fmt yuv420p -crf 18 -preset medium -movflags +faststart \
+  -vf "$VFILTER" \
+  -c:v libx264 -pix_fmt yuv420p -profile:v high -level 4.0 \
+  -crf 18 -preset medium -movflags +faststart \
   "$OUT60"
 MP4_SIZE=$(du -h "$OUT60" | cut -f1)
 echo "  ✓ $MP4_SIZE"

+ 102 - 32
scripts/export_deck_pptx.mjs

@@ -1,26 +1,39 @@
 #!/usr/bin/env node
 /**
- * export_deck_pptx.mjs — 把多文件 slide deck 导出为 PPTX(图片铺底)
+ * export_deck_pptx.mjs — 把多文件 slide deck 导出为 PPTX
+ *
+ * 两种模式:
+ *   --mode image     图片铺底,视觉 100% 保真,⚠️ 文字不可编辑
+ *   --mode editable  文本框原生,文字可编辑,要求 HTML 符合 4 条硬约束(见 references/editable-pptx.md)
  *
  * 用法:
- *   node export_deck_pptx.mjs --slides <dir> --out <file.pptx> [--width 1920] [--height 1080]
+ *   # 图片模式(默认)
+ *   node export_deck_pptx.mjs --slides <dir> --out <file.pptx>
+ *   # 可编辑模式
+ *   node export_deck_pptx.mjs --slides <dir> --out <file.pptx> --mode editable
  *
- * 特点:
+ * --mode image 特点:
  *   - 每张 slide 截图成 PNG,满铺一张 PPTX 页面
  *   - 视觉 100% 保真(因为就是图片)
- *   - ⚠️ 文字不可编辑(文字变成了图片)
+ *   - 文字不可编辑
+ *   - HTML 随便写,不挑格式
  *
- * 如果用户需要「可编辑文字」的 PPTX:
- *   ❌ 不要在 claude-design 的 HTML 上硬上 html2pptx——claude-design 的 HTML 视觉自由度高,
- *      很少能满足 html2pptx 的严格约束(p 标签语法、无 ::before、无 span margin 等)。
- *      实测 32 页里能 pass 的不到 30%,剩下的要逐页改造 + 逐页修字体溢出——工时失控。
- *   ✅ 正确做法:切换到 **huashu-slides** skill 的 Path A,按它的 HTML 格式**从头重构**
- *      每一页。huashu-slides 的 HTML 从一开始就符合 html2pptx 约束,可 100% 导出可编辑 PPTX。
+ * --mode editable 特点:
+ *   - 调用 scripts/html2pptx.js 把 HTML DOM 逐元素翻译成 PowerPoint 对象
+ *   - 文字是真文本框,PPT 里直接双击能编辑
+ *   - ⚠️ HTML 必须符合 4 条硬约束(见 references/editable-pptx.md):
+ *     1. 文字包在 <p>/<h1>-<h6> 里(div 不能直接放文字)
+ *     2. 不用 CSS 渐变
+ *     3. <p>/<h*> 不能有 background/border/shadow(放外层 div)
+ *     4. div 不能 background-image(用 <img>)
+ *   - body 尺寸默认 960pt × 540pt(LAYOUT_WIDE,13.333″ × 7.5″)
+ *   - 视觉驱动的 HTML 几乎无法 pass —— 必须从写 HTML 的第一行就按约束写
  *
- * 依赖:playwright pptxgenjs
- *   npm install playwright pptxgenjs
+ * 依赖:
+ *   --mode image:    npm install playwright pptxgenjs
+ *   --mode editable: npm install playwright pptxgenjs sharp
  *
- * 按文件名排序(01-xxx.html → 02-xxx.html → ...)
+ * 按文件名排序(01-xxx.html → 02-xxx.html → ...)
  */
 
 import { chromium } from 'playwright';
@@ -28,36 +41,32 @@ import pptxgen from 'pptxgenjs';
 import fs from 'fs/promises';
 import path from 'path';
 import os from 'os';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
 function parseArgs() {
-  const args = { width: 1920, height: 1080 };
+  const args = { width: 1920, height: 1080, mode: 'image' };
   const a = process.argv.slice(2);
   for (let i = 0; i < a.length; i += 2) {
     const k = a[i].replace(/^--/, '');
     args[k] = a[i + 1];
   }
   if (!args.slides || !args.out) {
-    console.error('用法: node export_deck_pptx.mjs --slides <dir> --out <file.pptx> [--width 1920] [--height 1080]');
+    console.error('用法: node export_deck_pptx.mjs --slides <dir> --out <file.pptx> [--mode image|editable] [--width 1920] [--height 1080]');
     process.exit(1);
   }
   args.width = parseInt(args.width);
   args.height = parseInt(args.height);
+  if (!['image', 'editable'].includes(args.mode)) {
+    console.error(`未知 --mode: ${args.mode}。支持: image, editable`);
+    process.exit(1);
+  }
   return args;
 }
 
-async function main() {
-  const { slides, out, width, height } = parseArgs();
-  const slidesDir = path.resolve(slides);
-  const outFile = path.resolve(out);
-
-  const files = (await fs.readdir(slidesDir))
-    .filter(f => f.endsWith('.html'))
-    .sort();
-  if (!files.length) {
-    console.error(`No .html files found in ${slidesDir}`);
-    process.exit(1);
-  }
-  console.log(`Found ${files.length} slides, rendering to PNG...`);
+async function exportImage({ slidesDir, outFile, files, width, height }) {
+  console.log(`[image mode] Rendering ${files.length} slides as PNG...`);
 
   const browser = await chromium.launch();
   const ctx = await browser.newContext({ viewport: { width, height } });
@@ -76,7 +85,6 @@ async function main() {
   }
   await browser.close();
 
-  // Build PPTX
   const pres = new pptxgen();
   pres.defineLayout({ name: 'DECK', width: width / 96, height: height / 96 });
   pres.layout = 'DECK';
@@ -86,12 +94,74 @@ async function main() {
   }
   await pres.writeFile({ fileName: outFile });
 
-  // cleanup
   for (const p of pngs) await fs.unlink(p).catch(() => {});
   await fs.rmdir(tmpDir).catch(() => {});
 
-  console.log(`\n✓ Wrote ${outFile}  (${files.length} slides, image mode)`);
-  console.log(`  要可编辑?改走 huashu-slides 的 Path A 从头重构 HTML。`);
+  console.log(`\n✓ Wrote ${outFile}  (${files.length} slides, image mode, 文字不可编辑)`);
+}
+
+async function exportEditable({ slidesDir, outFile, files }) {
+  console.log(`[editable mode] Converting ${files.length} slides via html2pptx...`);
+
+  // 动态 require html2pptx.js(CommonJS 模块)
+  const { createRequire } = await import('module');
+  const require = createRequire(import.meta.url);
+  let html2pptx;
+  try {
+    html2pptx = require(path.join(__dirname, 'html2pptx.js'));
+  } catch (e) {
+    console.error(`✗ 加载 html2pptx.js 失败:${e.message}`);
+    console.error(`  该模块依赖 sharp —— 请跑 npm install sharp 后重试。`);
+    process.exit(1);
+  }
+
+  const pres = new pptxgen();
+  pres.layout = 'LAYOUT_WIDE';  // 13.333 × 7.5 inch,对应 HTML body 960 × 540 pt
+
+  const errors = [];
+  for (let i = 0; i < files.length; i++) {
+    const f = files[i];
+    const fullPath = path.join(slidesDir, f);
+    try {
+      await html2pptx(fullPath, pres);
+      console.log(`  [${i + 1}/${files.length}] ${f} ✓`);
+    } catch (e) {
+      console.error(`  [${i + 1}/${files.length}] ${f} ✗  ${e.message}`);
+      errors.push({ file: f, error: e.message });
+    }
+  }
+
+  if (errors.length) {
+    console.error(`\n⚠️ ${errors.length} 张 slide 转换失败。常见原因:HTML 不符合 4 条硬约束。`);
+    console.error(`  详见 references/editable-pptx.md 的「常见错误速查」。`);
+    if (errors.length === files.length) {
+      console.error(`✗ 全部失败,不生成 PPTX。`);
+      process.exit(1);
+    }
+  }
+
+  await pres.writeFile({ fileName: outFile });
+  console.log(`\n✓ Wrote ${outFile}  (${files.length - errors.length}/${files.length} slides, editable mode, 文字可在 PPT 中直接编辑)`);
+}
+
+async function main() {
+  const { slides, out, width, height, mode } = parseArgs();
+  const slidesDir = path.resolve(slides);
+  const outFile = path.resolve(out);
+
+  const files = (await fs.readdir(slidesDir))
+    .filter(f => f.endsWith('.html'))
+    .sort();
+  if (!files.length) {
+    console.error(`No .html files found in ${slidesDir}`);
+    process.exit(1);
+  }
+
+  if (mode === 'image') {
+    await exportImage({ slidesDir, outFile, files, width, height });
+  } else {
+    await exportEditable({ slidesDir, outFile, files });
+  }
 }
 
 main().catch(e => { console.error(e); process.exit(1); });

+ 130 - 0
scripts/export_deck_stage_pdf.mjs

@@ -0,0 +1,130 @@
+#!/usr/bin/env node
+/**
+ * export_deck_stage_pdf.mjs — 单文件 <deck-stage> 架构专用 PDF 导出
+ *
+ * 用法:
+ *   node export_deck_stage_pdf.mjs --html <deck.html> --out <file.pdf> [--width 1920] [--height 1080]
+ *
+ * 什么时候用这个脚本?
+ *   - 你的 deck 是**单 HTML 文件**,所有 slide 是 `<section>`,外层用 `<deck-stage>` 包裹
+ *   - 此时 `export_deck_pdf.mjs`(多文件专用)用不上
+ *
+ * 为什么不能直接 `page.pdf()`(2026-04-20 踩坑记录):
+ *   1. deck-stage 的 shadow CSS `::slotted(section) { display: none }` 让只有 active slide 可见
+ *   2. print 媒体下外层 `!important` 压不住 shadow DOM 规则
+ *   3. 结果:PDF 永远只有 1 页(active 那张)
+ *
+ * 解决方案:
+ *   打开 HTML 后,用 page.evaluate 把所有 section 从 deck-stage slot 拔出来,
+ *   挂到 body 下一个普通 div,内联 style 强制 position:relative + 固定尺寸,
+ *   每个 section 加 page-break-after: always,最后一个改 auto 避免尾部空白页。
+ *
+ * 依赖:playwright
+ *   npm install playwright
+ *
+ * 输出特点:
+ *   - 文字保留矢量(可复制、可搜索)
+ *   - 视觉 1:1 保真
+ *   - 字体必须能被 Chromium 加载(本地字体或 Google Fonts)
+ */
+
+import { chromium } from 'playwright';
+import fs from 'fs/promises';
+import path from 'path';
+
+function parseArgs() {
+  const args = { width: 1920, height: 1080 };
+  const a = process.argv.slice(2);
+  for (let i = 0; i < a.length; i += 2) {
+    const k = a[i].replace(/^--/, '');
+    args[k] = a[i + 1];
+  }
+  if (!args.html || !args.out) {
+    console.error('用法: node export_deck_stage_pdf.mjs --html <deck.html> --out <file.pdf> [--width 1920] [--height 1080]');
+    process.exit(1);
+  }
+  args.width = parseInt(args.width);
+  args.height = parseInt(args.height);
+  return args;
+}
+
+async function main() {
+  const { html, out, width, height } = parseArgs();
+  const htmlAbs = path.resolve(html);
+  const outFile = path.resolve(out);
+
+  await fs.access(htmlAbs).catch(() => {
+    console.error(`HTML file not found: ${htmlAbs}`);
+    process.exit(1);
+  });
+
+  console.log(`Rendering ${path.basename(htmlAbs)} → ${path.basename(outFile)}`);
+
+  const browser = await chromium.launch();
+  const ctx = await browser.newContext({ viewport: { width, height } });
+  const page = await ctx.newPage();
+
+  await page.goto('file://' + htmlAbs, { waitUntil: 'networkidle' });
+  await page.waitForTimeout(2500);  // 等 Google Fonts + deck-stage init
+
+  // 核心修复:把 section 从 shadow DOM slot 拔出来摊平
+  const sectionCount = await page.evaluate(({ W, H }) => {
+    const stage = document.querySelector('deck-stage');
+    if (!stage) throw new Error('<deck-stage> not found — 这个脚本只适用于单文件 deck-stage 架构');
+    const sections = Array.from(stage.querySelectorAll(':scope > section'));
+    if (!sections.length) throw new Error('No <section> found inside <deck-stage>');
+
+    // 注入打印样式
+    const style = document.createElement('style');
+    style.textContent = `
+      @page { size: ${W}px ${H}px; margin: 0; }
+      html, body { margin: 0 !important; padding: 0 !important; background: #fff; }
+      deck-stage { display: none !important; }
+    `;
+    document.head.appendChild(style);
+
+    // 摊平到 body 下
+    const container = document.createElement('div');
+    container.id = 'print-container';
+    sections.forEach(s => {
+      // 内联 style 拿到最高优先级;确保 position:relative 让 absolute 子元素正确约束
+      s.style.cssText = `
+        width: ${W}px !important;
+        height: ${H}px !important;
+        display: block !important;
+        position: relative !important;
+        overflow: hidden !important;
+        page-break-after: always !important;
+        break-after: page !important;
+        margin: 0 !important;
+        padding: 0 !important;
+      `;
+      container.appendChild(s);
+    });
+    // 最后一页不分页,避免尾部空白页
+    const last = sections[sections.length - 1];
+    last.style.pageBreakAfter = 'auto';
+    last.style.breakAfter = 'auto';
+    document.body.appendChild(container);
+    return sections.length;
+  }, { W: width, H: height });
+
+  await page.waitForTimeout(800);
+
+  await page.pdf({
+    path: outFile,
+    width: `${width}px`,
+    height: `${height}px`,
+    printBackground: true,
+    preferCSSPageSize: true,
+  });
+
+  await browser.close();
+
+  const stat = await fs.stat(outFile);
+  const kb = (stat.size / 1024).toFixed(0);
+  console.log(`\n✓ Wrote ${outFile}  (${kb} KB, ${sectionCount} pages, vector)`);
+  console.log(`  验证页数:mdimport "${outFile}" && pdfinfo "${outFile}" | grep Pages`);
+}
+
+main().catch(e => { console.error(e); process.exit(1); });

+ 45 - 10
scripts/render-video.js

@@ -103,7 +103,10 @@ console.log(`  output: ${MP4_OUT}`);
     viewport: { width: WIDTH, height: HEIGHT },
   });
   const warmupPage = await warmupCtx.newPage();
-  await warmupPage.goto(url, { waitUntil: 'networkidle' });
+  // 'load' not 'networkidle' — unpkg/Google Fonts can keep connections alive
+  // past our 30s budget even after all critical resources are in. __ready
+  // flag + FONT_WAIT handle animation-readiness properly.
+  await warmupPage.goto(url, { waitUntil: 'load', timeout: 60000 });
   await warmupPage.waitForTimeout(FONT_WAIT * 1000);
   await warmupCtx.close();
 
@@ -118,6 +121,12 @@ console.log(`  output: ${MP4_OUT}`);
     },
   });
 
+  // Tell the page it's being recorded — animations.jsx Stage reads this
+  // and forces loop=false so the export ends on the final frame instead of
+  // capturing the start of the next cycle. Hand-written Stage components
+  // should also honor this signal (see animation-pitfalls.md §13).
+  await recordCtx.addInitScript(() => { window.__recording = true; });
+
   // Inject CSS + JS heuristic to hide "chrome" elements.
   // Two layers:
   //   A. CSS selectors for common class-name conventions (cheap)
@@ -183,7 +192,7 @@ console.log(`  output: ${MP4_OUT}`);
   // mount + fonts.ready). That elapsed time = exact trim offset.
   const T0 = Date.now();
   const page = await recordCtx.newPage();
-  await page.goto(url, { waitUntil: 'networkidle' });
+  await page.goto(url, { waitUntil: 'load', timeout: 60000 });
 
   // Wait for animation ready signal. Stage component (animations.jsx) sets
   // window.__ready = true on its first rAF after mount + fonts.ready.
@@ -195,14 +204,38 @@ console.log(`  output: ${MP4_OUT}`);
   ).then(() => true).catch(() => false);
 
   if (hasReady) {
+    // 第二道防线:主动把动画 time 归零——对付 HTML 不严格遵守 starter tick 模板
+    // 的情况(例如 lastTick 用 performance.now() 导致字体加载时间被算进首帧 dt)
+    // 详见 references/animation-pitfalls.md §12
+    const seekCorrected = await page.evaluate(() => {
+      if (typeof window.__seek === 'function') {
+        window.__seek(0);
+        return true;
+      }
+      return false;
+    });
+    if (seekCorrected) {
+      // 等两个 rAF 让 seek 生效并渲染出 t=0 的画面
+      await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
+    }
     animationStartSec = (Date.now() - T0) / 1000;
-    console.log(`▸ Ready at ${animationStartSec.toFixed(2)}s (from window.__ready)`);
+    console.log(`▸ Ready at ${animationStartSec.toFixed(2)}s (from window.__ready${seekCorrected ? ' + __seek(0) correction' : ''})`);
   } else {
     await page.waitForTimeout(FONT_WAIT * 1000);
     animationStartSec = (Date.now() - T0) / 1000;
-    console.log(`▸ No window.__ready signal; using fallback wait (${animationStartSec.toFixed(2)}s)`);
-    console.log(`  tip: in animations.jsx-based HTML this is automatic;`);
-    console.log(`       otherwise set window.__ready=true after your first paint.`);
+    // Fallback offset is unreliable: animation may have started in raf loop
+    // already, so trim could land mid-cycle. Add 0.5s safety margin (see
+    // animation-pitfalls.md §13). Loud warning so user knows to fix the HTML.
+    console.log('');
+    console.log(`  ⚠️  WARNING: window.__ready signal not detected within ${READY_TIMEOUT}s`);
+    console.log(`     Recording will use fallback trim of ${animationStartSec.toFixed(2)}s + 0.5s safety margin.`);
+    console.log(`     This is UNRELIABLE — your video may start mid-animation or skip frames.`);
+    console.log('');
+    console.log(`     FIX: in your HTML's animation tick (or rAF first frame), add:`);
+    console.log(`        window.__ready = true;`);
+    console.log(`     animations.jsx-based HTML does this automatically. If you wrote your`);
+    console.log(`     own Stage, see references/animation-pitfalls.md §12 for the pattern.`);
+    console.log('');
   }
 
   // Now let the animation play out its full duration
@@ -221,12 +254,14 @@ console.log(`  output: ${MP4_OUT}`);
   console.log(`▸ WebM: ${(fs.statSync(webmPath).size / 1024 / 1024).toFixed(1)} MB`);
 
   // Resolve final trim offset:
-  //   - manual --trim=X   → use X (explicit user override)
-  //   - otherwise          → use animationStartSec (measured), with a tiny
-  //                          +0.05s nudge to clear the first Babel-commit frame
+  //   - manual --trim=X       → use X (explicit user override)
+  //   - hasReady              → animationStartSec + 0.05s (Babel-commit nudge)
+  //   - fallback (no __ready) → animationStartSec + 0.5s safety margin (raf
+  //                             loop may have started running already; without
+  //                             this we'd capture mid-cycle frames)
   const resolvedTrim = TRIM_OVERRIDE !== null
     ? parseFloat(TRIM_OVERRIDE)
-    : animationStartSec + 0.05;
+    : animationStartSec + (hasReady ? 0.05 : 0.5);
 
   console.log(`▸ ffmpeg: trim=${resolvedTrim.toFixed(2)}s${TRIM_OVERRIDE !== null ? ' (manual)' : ' (auto)'}, encode H.264…`);
   const ffmpeg = spawnSync('ffmpeg', [