`。
+- `kind: "accent"` 用于当前编排者,`kind: "dark"` 用于最终 / 输出节点,`kind: "user"` 用于用户气泡。
diff --git a/skills/feishu-cli-htmlbox/references/gallery.md b/skills/feishu-cli-htmlbox/references/gallery.md
index 894cd3a..0bdfaa1 100644
--- a/skills/feishu-cli-htmlbox/references/gallery.md
+++ b/skills/feishu-cli-htmlbox/references/gallery.md
@@ -11,7 +11,7 @@
- [分布统计](#分布统计):涟漪散点 / 热力 / 日历热力 / 箱线 / K线 / 平行坐标
- [构成流向](#构成流向):漏斗 / 桑基 / 主题河流 / 仪表盘 / 水球 / 词云
- [关系层级](#关系层级):力导向关系图 / 组织树 / 旭日 / 矩形树图
-- [流程时序](#流程时序):状态机 / 看板流动 / 甘特
+- [流程时序](#流程时序):拓扑 / 编排动画 / 状态机 / 看板流动 / 甘特
- [创意动画](#创意动画):CSS 三件套 / Canvas 粒子 / SVG 路径 / 维恩 / 像素柱 / KPI 大屏
---
@@ -300,6 +300,10 @@ OPT = { backgroundColor:'#0f1729', series:[{type:'tree',data:[TREE],top:30,left:
## 流程时序
+| 你要画的图 | 引擎 / 方案 | 配方位置 |
+|---|---|---|
+| 拓扑 / 编排动画 / Agent 协作回放 | `scripts/animate_diagram.py`(结构化 JSON → 自包含 SVG 动画 HTML) | `references/animated-flowchart.md` |
+
时序图 / 状态机 / CI 流水线这类「流程」,ECharts 没有现成 series,两条路:
1. **简单的用 graph**(节点 + 有向边 `edgeSymbol:['none','arrow']`,`layout:'none'` 手摆坐标),见上方力导向改 `layout:'none'`。
2. **要动感的用纯 CSS**(节点高亮沿流程流动),见下例。
diff --git a/skills/feishu-cli-htmlbox/references/pattern-schema.md b/skills/feishu-cli-htmlbox/references/pattern-schema.md
new file mode 100644
index 0000000..905b687
--- /dev/null
+++ b/skills/feishu-cli-htmlbox/references/pattern-schema.md
@@ -0,0 +1,80 @@
+# Animated Flowchart — Pattern Schema
+
+`scripts/animate_diagram.py` 接受如下形状的 JSON 对象:
+
+```json
+{
+ "title": "Supervisor / Manager",
+ "sub": "Agents-as-tools · centralized orchestration",
+ "nodes": [
+ {
+ "id": "manager",
+ "x": 230,
+ "y": 230,
+ "w": 180,
+ "h": 54,
+ "label": "Manager",
+ "sub": "supervisor",
+ "kind": "accent"
+ }
+ ],
+ "edges": {
+ "m-c": {
+ "from": "manager",
+ "to": "coder",
+ "label": "call",
+ "curve": 0,
+ "dashed": false
+ }
+ },
+ "timeline": [
+ {
+ "caption": "Manager calls Coder to write code.",
+ "fire": ["m-c"],
+ "activate": ["manager", "coder"],
+ "dim": [],
+ "duration": 1500
+ }
+ ]
+}
+```
+
+## 必填字段
+
+- `title`:图标题。
+- `nodes`:节点对象数组。
+- `edges`:以 edge ID 为 key 的对象。
+- `timeline`:有序的动画步骤。
+
+## 节点字段
+
+- `id` 必填,唯一。
+- `x`、`y` 必填,`900 x 540` 画布内的左上角坐标。
+- `label` 必填。
+- `w` 可选,默认 `140`。
+- `h` 可选,默认 `54`。
+- `sub` 可选,小号大写副标题。
+- `kind` 可选:`plain`、`accent`、`dark`、`user`、`store`、`bus`。
+
+## 连线字段
+
+- `from` 必填,节点 ID。
+- `to` 必填,节点 ID。
+- `label` 可选。
+- `curve` 可选数字。正负值让连线朝相反方向弯曲。
+- `dashed` 可选布尔值。
+
+## 时间线字段
+
+- `caption` 必填。允许内联 `` 和 ``。
+- `fire` 可选,edge ID 数组。前缀 `!` 表示 token 反向流动。
+- `activate` 可选,要脉冲 / 高亮的节点 ID 数组。
+- `dim` 可选,要淡出的节点 ID 数组。
+- `duration` 可选,毫秒,默认 `1500`。
+
+## 设计默认值
+
+- 固定浅色主题。
+- 默认自动播放。
+- 极简控件:上一步、播放 / 暂停、下一步、进度点。
+- 默认无键盘快捷键、倍速控件、重播按钮、外部资源或运行时依赖。
diff --git a/skills/feishu-cli-htmlbox/scripts/animate_diagram.py b/skills/feishu-cli-htmlbox/scripts/animate_diagram.py
new file mode 100755
index 0000000..b2b6f97
--- /dev/null
+++ b/skills/feishu-cli-htmlbox/scripts/animate_diagram.py
@@ -0,0 +1,608 @@
+#!/usr/bin/env python3
+"""Generate a self-contained animated diagram HTML from pattern JSON."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+
+HTML_TEMPLATE = r"""
+
+
+
+
+__TITLE__
+
+
+
+
+
+ Animated topology
+
+
+
+
+ ·Diagram
+
+
+ Live
+
+
+
+
+ 1 / 1
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+def validate_pattern(pattern: dict) -> None:
+ required = ["title", "nodes", "edges", "timeline"]
+ missing = [key for key in required if key not in pattern]
+ if missing:
+ raise ValueError(f"missing required field(s): {', '.join(missing)}")
+
+ node_ids = set()
+ for i, node in enumerate(pattern["nodes"]):
+ for key in ["id", "x", "y", "label"]:
+ if key not in node:
+ raise ValueError(f"nodes[{i}] missing {key!r}")
+ if node["id"] in node_ids:
+ raise ValueError(f"duplicate node id: {node['id']}")
+ node_ids.add(node["id"])
+
+ for edge_id, edge in pattern["edges"].items():
+ for key in ["from", "to"]:
+ if key not in edge:
+ raise ValueError(f"edge {edge_id!r} missing {key!r}")
+ if edge["from"] not in node_ids:
+ raise ValueError(f"edge {edge_id!r} references missing from node {edge['from']!r}")
+ if edge["to"] not in node_ids:
+ raise ValueError(f"edge {edge_id!r} references missing to node {edge['to']!r}")
+
+ edge_ids = set(pattern["edges"].keys())
+ for i, step in enumerate(pattern["timeline"]):
+ if "caption" not in step:
+ raise ValueError(f"timeline[{i}] missing 'caption'")
+ for raw in step.get("fire", []):
+ edge_id = raw[1:] if raw.startswith("!") else raw
+ if edge_id not in edge_ids:
+ raise ValueError(f"timeline[{i}] references missing edge {edge_id!r}")
+ for field in ["activate", "dim"]:
+ for node_id in step.get(field, []):
+ if node_id not in node_ids:
+ raise ValueError(f"timeline[{i}].{field} references missing node {node_id!r}")
+
+
+def _escape_json_for_inline_script(pattern_json: str) -> str:
+ """Keep JSON literals from accidentally closing the surrounding script tag."""
+ return (
+ pattern_json
+ .replace("&", "\\u0026")
+ .replace("<", "\\u003c")
+ .replace(">", "\\u003e")
+ )
+
+
+def render(pattern: dict) -> str:
+ pattern_json = json.dumps(pattern, ensure_ascii=False, separators=(",", ":"))
+ title = str(pattern.get("title", "Animated Diagram"))
+ return (
+ HTML_TEMPLATE
+ .replace("__TITLE__", title.replace("&", "&").replace("<", "<").replace(">", ">"))
+ .replace("__PATTERN_JSON__", _escape_json_for_inline_script(pattern_json))
+ )
+
+
+def main(argv: list[str]) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--pattern", required=True, help="Path to pattern JSON")
+ parser.add_argument("--out", required=True, help="Output HTML path")
+ parser.add_argument("--no-validate", action="store_true", help="Skip schema validation")
+ args = parser.parse_args(argv)
+
+ pattern_path = Path(args.pattern)
+ out_path = Path(args.out)
+ pattern = json.loads(pattern_path.read_text(encoding="utf-8"))
+ if not args.no_validate:
+ validate_pattern(pattern)
+
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ out_path.write_text(render(pattern), encoding="utf-8")
+ print(f"Wrote {out_path} ({out_path.stat().st_size} bytes)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))