Browse 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 2 tháng trước cách đây
mục cha
commit
cd48c19885
51 tập tin đã thay đổi với 8179 bổ sung254 xóa
  1. 17 32
      .gitignore
  2. 200 99
      README.md
  3. 164 17
      SKILL.md
  4. 11 2
      assets/animations.jsx
  5. 20 5
      assets/deck_stage.js
  6. 1 1
      assets/personal-asset-index.example.json
  7. BIN
      demos/c1-ios-prototype-60fps.mp4
  8. BIN
      demos/c1-ios-prototype.gif
  9. 721 0
      demos/c1-ios-prototype.html
  10. BIN
      demos/c1-ios-prototype.mp4
  11. BIN
      demos/c2-slides-pptx-60fps.mp4
  12. BIN
      demos/c2-slides-pptx.gif
  13. 978 0
      demos/c2-slides-pptx.html
  14. BIN
      demos/c2-slides-pptx.mp4
  15. BIN
      demos/c3-motion-design-60fps.mp4
  16. BIN
      demos/c3-motion-design.gif
  17. 556 0
      demos/c3-motion-design.html
  18. BIN
      demos/c3-motion-design.mp4
  19. BIN
      demos/c4-tweaks-60fps.mp4
  20. BIN
      demos/c4-tweaks.gif
  21. 762 0
      demos/c4-tweaks.html
  22. BIN
      demos/c4-tweaks.mp4
  23. BIN
      demos/c5-infographic-60fps.mp4
  24. BIN
      demos/c5-infographic.gif
  25. 767 0
      demos/c5-infographic.html
  26. BIN
      demos/c5-infographic.mp4
  27. BIN
      demos/c6-expert-review-60fps.mp4
  28. BIN
      demos/c6-expert-review.gif
  29. 652 0
      demos/c6-expert-review.html
  30. BIN
      demos/c6-expert-review.mp4
  31. BIN
      demos/w1-brand-protocol-60fps.mp4
  32. BIN
      demos/w1-brand-protocol.gif
  33. 787 0
      demos/w1-brand-protocol.html
  34. BIN
      demos/w1-brand-protocol.mp4
  35. BIN
      demos/w2-junior-designer-60fps.mp4
  36. BIN
      demos/w2-junior-designer.gif
  37. 856 0
      demos/w2-junior-designer.html
  38. BIN
      demos/w2-junior-designer.mp4
  39. BIN
      demos/w3-fallback-advisor-60fps.mp4
  40. BIN
      demos/w3-fallback-advisor.gif
  41. 747 0
      demos/w3-fallback-advisor.html
  42. BIN
      demos/w3-fallback-advisor.mp4
  43. 153 18
      references/animation-pitfalls.md
  44. 243 0
      references/editable-pptx.md
  45. 200 21
      references/slide-decks.md
  46. 8 2
      references/verification.md
  47. 14 2
      references/video-export.md
  48. 45 13
      scripts/convert-formats.sh
  49. 102 32
      scripts/export_deck_pptx.mjs
  50. 130 0
      scripts/export_deck_stage_pdf.mjs
  51. 45 10
      scripts/render-video.js

+ 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 分发泄露隐私"
   },
 

BIN
demos/c1-ios-prototype-60fps.mp4


BIN
demos/c1-ios-prototype.gif


+ 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>

BIN
demos/c1-ios-prototype.mp4


BIN
demos/c2-slides-pptx-60fps.mp4


BIN
demos/c2-slides-pptx.gif


+ 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>

BIN
demos/c2-slides-pptx.mp4


BIN
demos/c3-motion-design-60fps.mp4


BIN
demos/c3-motion-design.gif


+ 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>

BIN
demos/c3-motion-design.mp4


BIN
demos/c4-tweaks-60fps.mp4


BIN
demos/c4-tweaks.gif


+ 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>

BIN
demos/c4-tweaks.mp4


BIN
demos/c5-infographic-60fps.mp4


BIN
demos/c5-infographic.gif


+ 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>

BIN
demos/c5-infographic.mp4


BIN
demos/c6-expert-review-60fps.mp4


BIN
demos/c6-expert-review.gif


+ 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>

BIN
demos/c6-expert-review.mp4


BIN
demos/w1-brand-protocol-60fps.mp4


BIN
demos/w1-brand-protocol.gif


+ 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>

BIN
demos/w1-brand-protocol.mp4


BIN
demos/w2-junior-designer-60fps.mp4


BIN
demos/w2-junior-designer.gif


+ 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>

BIN
demos/w2-junior-designer.mp4


BIN
demos/w3-fallback-advisor-60fps.mp4


BIN
demos/w3-fallback-advisor.gif


+ 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>

BIN
demos/w3-fallback-advisor.mp4


+ 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', [