
Panova
- 7 installs
- 6 repo stars
- Updated April 6, 2026
- oil-oil/panova
Helps with ai & agent building tasks.
About
panova is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- panova
- AI & Agent Building
- AI-coding skill
Panova by the numbers
- 7 all-time installs (skills.sh)
- Ranked #12,545 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oil-oil/panova --skill panovaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 7 |
|---|---|
| repo stars | ★ 6 |
| Last updated | April 6, 2026 |
| Repository | oil-oil/panova ↗ |
What it does
Helps with ai & agent building tasks.
Files
Panova — 产品业务拆解
从代码库或 PRD 中提取业务全貌,生成一份交互式报告。
目标读者: 产品经理和新加入的团队成员。他们需要快速理解"这个产品做什么、改了什么会影响谁",不需要看代码细节。
---
首次触发
第一次进入时,简短确认即可:
我来帮你拆解这个产品的业务全貌,生成一份可交互的报告。默认读当前代码库做全量分析。
>
如果你只想看某个模块(比如"只看支付"),或者有 PRD 文档想让我读,告诉我就行。否则我直接开始。
等用户回复后再开始。如果用户说"开始"、"好的"或类似表述,直接用当前工作目录做全量分析。
---
分析流程(3 个阶段)
阶段 1:深度阅读
根据用户选择的来源,逐层阅读:
代码库模式:
- 先读 README、CLAUDE.md、package.json(或对应语言的项目配置),搞清楚项目是干什么的
- 读路由/入口文件,画出系统有哪些用户可触达的功能
- 读数据模型(数据库 schema、类型定义、ORM 模型),提取核心实体和状态流转
- 读业务逻辑层(service、controller、hook),理解实体之间的关系和流转规则
- 读外部集成(API 调用、第三方 SDK),标记系统边界
- 读 UI 层(组件、页面、样式),提取交互模式
PRD 模式:
- 读取指定目录下所有文档
- 按上述相同维度提取信息
这个阶段的目标是在脑子里"用一遍"这个产品:
- 这个产品给谁用?解决什么问题?
- 用户从打开产品到完成核心任务,经过哪些步骤?每一步看到什么、做什么?
- 每一步背后,系统在做什么?涉及哪些实体和规则?
- 哪些地方可能出错?出错了用户看到什么?
- 改了某个规则,用户体验的哪些环节会跟着变?
什么时候可以停止阅读: 当你能用 3-5 句话向 PM 描述"用户从头到尾走一遍是什么体验"时,阅读阶段就够了。不需要读完每一个文件——抓住主线,补充关键分支即可。
阶段 2:写报告
读完 ~/.claude/skills/panova/BLOCKS.md 了解可用的块语法,然后用扩展 Markdown 格式写报告。
核心原则:跟着用户走,不要按系统分类。
报告的主体是一段完整的用户旅程。概念、规则、影响链、异常场景都嵌在旅程里,在它们自然出现的位置讲解——而不是先讲完故事再分门别类列出来。
只有那些 PM 需要独立做决策的东西(比如定价模型、角色配比)才单独成章节。
怎么切分旅程: 找到产品的"主线任务"(用户从进来到达成核心目标的路径),按时间顺序拆成 4-6 个阶段。每个阶段对应用户的一个关键动作或一次等待。比如:打开 → 配置 → 等待 → 操作 → 结果 → 下一轮。如果产品有多条平行主线(比如买家和卖家),选最核心的一条作为主线,其他在相关位置提及。
报告结构(根据项目实际情况调整):
1. 一句话定位 + 指标卡片
2. 谁在用(用户画像、使用场景)
3. 跟着用户走一遍(报告主体,占 60-70% 篇幅)
└ 按用户旅程的阶段组织,每个阶段包含:
- 用户做了什么、看到什么(用 :::demo 画出关键界面示意)
- 背后发生了什么(用 :::mermaid 或 :::sequence 画流程)
- 这一步涉及的业务规则(用 :::callout 高亮)
- 如果出错了会怎样(异常场景)
- 改了这里会牵动什么(用 :::impact 内联)
4. 关键决策点(只提炼 PM 需要独立决策的概念)
5. 外部依赖
6. 交互设计规范(如果项目有 UI 层且有 10+ 个页面或组件,读 INTERACTION-GUIDE.md 了解提炼方法;前端薄的项目跳过)
7. 附录:术语对照表阶段 3:输出报告
第一步:分批写入 Markdown 文件
报告内容通常较长,不要试图一次性生成全部内容——这是卡住的主要原因。按以下顺序分批写入:
1. 先创建文件,写入报告头部(h1、指标卡片、用户画像章节) 2. 逐段追加:每完成旅程中的一个阶段,立即用 Edit 工具追加到文件末尾,不要等全部写完再保存 3. 最后追加收尾章节(关键决策点、外部依赖、术语表)
具体做法:
- 用 Write 工具创建文件并写入前 1-2 个章节
- 后续每个章节用 Edit 工具追加(在文件末尾 append)
- 每次追加后可以告诉用户进度,例如:"已完成旅程第 2 阶段,继续写第 3 阶段..."
为什么要分批: 单次生成超长内容容易触发超时或中途截断,分批写入可以确保每段内容都安全落盘,即使中途出错也不会丢失已完成的部分。
第二步:调用构建脚本生成 HTML
- 运行:
bash ~/.claude/skills/panova/build.sh ./panova-report.md ./panova-report.html - 脚本会自动验证生成的 HTML(JS 语法检查、危险模式扫描)
- 如果构建失败: 看错误信息。常见问题:markdown 中包含未转义的反引号或
${,demo 块中包含</script>。修正 markdown 后重新构建 - 注意: 生成的报告需要联网才能正常显示(依赖 CDN 加载 markdown-it 和 mermaid)
输出后告诉用户:
报告已生成:./panova-report.html,用浏览器打开就行。如果你对某个部分有疑问,直接问我,我可以结合代码给你解释。
---
写作规范
报告标题格式
h1 用 产品名 — 一句话定位 格式(如 Wolfcha — AI 狼人杀)。不要加"产品拆解"、"业务分析"之类后缀。h1 会被自动设为浏览器 tab 标题。
核心心法:让 PM 身临其境
写报告的时候,想象你在给一个 PM 做产品演示。不是念 PPT,是打开产品,一步一步操作给他看,边操作边讲:
- "你点这个按钮"——PM 能想象自己在操作
- "然后等 5 秒"——PM 知道这里有等待
- "如果网断了,这里会卡住"——PM 知道风险在哪
- "这个规则改了的话,那边也会变"——PM 知道牵连在哪
用 demo 块画界面示意
每当描述"用户看到什么"时,用 :::demo 画一个简化的界面示意。不需要像素级还原,但要让 PM 脑子里有画面。
好的 demo:用简单的 HTML/CSS 画出关键元素的布局和状态,用颜色和文字标注含义。 不好的 demo:只有文字描述没有视觉,或者画得太复杂反而分散注意力。
语气和句式
- 用"你"称呼读者,像在跟同事口头讲解
- 每句话只说一件事
- 减少形容词,直接说是什么、做什么
- 技术概念翻译成业务语言。"存储在 Redis" → "临时缓存,重启会清空"
- 技术细节用
:::tooltip补充,不混在正文里
概念怎么写
概念不用单独列成"词典章节"。在旅程中第一次遇到时,用 1-2 句话解释清楚,用 [链接](tip:详细解释) 提供更多信息。
只有当一个概念满足以下条件时,才值得单独成卡片放在"关键决策点"章节:
- PM 可能需要就这个概念做独立决策(比如定价策略、角色配比、权限模型)
- 这个概念有复杂的状态流转,嵌在旅程里讲不清楚
- 改了这个概念会产生广泛的连锁反应
单独成卡片时的格式:
### 概念名
一句话说清楚它是什么。
**怎么产生:** 谁在什么情况下创建它。
**状态变化:** 有哪些状态,什么触发切换。(没有明确状态则省略)
**影响范围:** 改了它,哪些地方跟着变。影响链怎么写
影响链不单独堆在报告末尾。在旅程中讲到某个规则或功能时,紧跟着写它的影响链。每条影响链说的是"对用户或业务有什么影响",不是"需要改哪个文件"。
异常场景怎么写
每个核心流程至少标注 1-2 个主要异常场景。格式简单直接:
如果网断了: 游戏卡在当前阶段,刷新页面后从最近的检查点恢复。
---
报告交互特性
报告 HTML 自动提供这些交互,AI 不需要手动实现:
- 侧边导航 + 搜索:自动从 h2/h3 生成,支持关键词过滤
- 概念卡片:h3 级别的概念(带"怎么产生"/"影响范围"的)自动包裹成卡片
- 概念网格:所有概念卡片自动生成网格概览
- 关联概念 Popover:
[概念名](#anchor)链接悬停时弹出简介 - 折叠块:
:::tooltip默认折叠,:::impact第一个展开其余折叠 - Demo 块:
:::demo内的 HTML 渲染在沙箱 iframe 中,自动适配高度 - 移动端:900px 以下有悬浮导航按钮
---
块语法参考
所有可用的扩展 Markdown 块语法定义在 ~/.claude/skills/panova/BLOCKS.md。写报告前先读一遍。
---
报告生成后
生成报告后进入问答模式。用户可能会问:
- "如果我想加一个 XX 功能,需要动哪些地方?"
- "为什么 XX 要这么设计?"
- "如果我把 XX 的规则从 A 改成 B,会影响到谁?"
回答时结合代码给出具体引用,保持同样的写作规范——面向 PM,用业务语言解释。
扩展 Markdown 块语法
报告使用标准 Markdown,加上以下自定义块。写标记就行,渲染由 HTML 模板自动处理。
:::demo — 界面示意
嵌入一段 HTML,在沙箱 iframe 中渲染。用来画简化的界面示意图、交互流程动画、状态对比等。
每个 demo 自动注入一套黑白灰基础样式系统。 优先用预置 class,只在必要时用 inline style 补充。配色只用黑白灰——不要引入彩色,保持清爽简约。
:::demo
<div class="max-w-md mx-auto text-center">
<div class="text-xl text-bold mb-sm">新游戏</div>
<div class="text-sm text-gray mb-lg">选好设置,开始一局</div>
<div class="label">玩家人数</div>
<div class="wrap mb-md">
<span class="pill pill-outline">8</span>
<span class="pill pill-active">10</span>
<span class="pill pill-outline">12</span>
</div>
<button class="btn btn-primary btn-block">开始游戏</button>
</div>
:::设计规范
- 配色: 只用黑白灰。
--black(#37352f)、--gray-*系列、--white。不要用蓝色、红色等彩色 - 圆角: 卡片
8px(--radius)、小元素6px(--radius-sm)、药丸/头像9999px(--radius-full) - 间距: sm=6-8px、md=12px、lg=16-20px
- 字号: xs=11px、sm=12px、md=14px(默认)、lg=16px、xl=20px
预置 class 速查
布局:
.row.col.wrap.center— flex 布局.gap-sm.gap-lg— 间距.max-w-sm(360px).max-w-md(440px).mx-auto.w-full.text-center.text-right.mt-sm/md/lg.mb-sm/md/lg— 外边距
容器:
.card— 带边框圆角的卡片.card-sm— 紧凑卡片
文字:
.text-xs/sm/md/lg/xl— 字号.text-bold.text-medium— 字重.text-black.text-gray.text-light.text-white— 颜色
药丸(选项标签):
.pill+.pill-outline(灰色边框) /.pill-filled(黑底白字) /.pill-active(黑底白字,表示选中)
按钮:
.btn+.btn-primary(黑底) /.btn-secondary(灰底).btn-block— 占满宽度
头像:
.avatar+.avatar-sm/lg— 尺寸.avatar-dark(黑底) /.avatar-gray(灰底) /.avatar-outline(描边)
聊天气泡:
.bubble+.bubble-gray(灰底) /.bubble-light(白底描边)
徽章/状态:
.badge+.badge-dark/gray/outline.status-dot+.status-active/inactive.status-glow
辅助:
.divider— 分割线.label— 表单标签(12px 灰色).opacity-30/50/70.shadow-sm.bg-black/dark/gray/white
写 demo 的原则
- 优先用 class,减少 inline style
- 画关键元素的布局和状态就行,不需要像素级还原
- 可以用简单的 CSS 动画或 JS 做交互演示
- 标注文字用来解释"这是什么"、"点了会怎样"
- 如果需要 class 没覆盖的样式,用 inline style 补充,但配色仍然只用黑白灰
:::mermaid — 流程图 / ER 图 / 状态机
使用标准 Mermaid 语法。必须是合法的 mermaid 语法,不要用自创的箭头或分支符号。
:::mermaid
graph TD
A[用户触发操作] --> B{条件判断}
B -->|是| C[处理 A]
B -->|否| D[处理 B]
:::所有 Mermaid 图表类型都可以用:flowchart、sequenceDiagram、stateDiagram、erDiagram 等。
:::sequence — 时序图
描述多个参与者之间的交互顺序。
:::sequence
sequenceDiagram
participant 用户
participant 前端
participant 后端
用户->>前端: 点击按钮
前端->>后端: 发起请求
后端-->>前端: 返回结果
前端-->>用户: 显示结果
::::::mindmap — 脑图
功能树、概念层级。用缩进表示层级。
:::mindmap
- 根节点
- 子节点 A
- 子子节点
- 子节点 B
::::::callout — 高亮框
需要特别注意的业务规则或设计意图。
:::callout
这里写需要特别注意的业务规则或设计意图。
::::::tooltip — 技术细节
给想深入了解的读者看的补充信息。默认折叠,点击展开。
:::tooltip
技术补充。用 PM 能理解的语言,解释数据存在哪、有哪些关键约束。
每个字段都要回答"影响什么用户体验"或"为什么需要它"。
:::好的 tooltip:说出数据存在哪、列出核心字段并用业务语言解释、提及关键约束。 不好的 tooltip:只列技术实现、照搬 DDL、用只有开发才懂的缩写。
:::impact — 影响链
产品和业务层面的连锁反应。第一个默认展开,其余折叠。
:::impact
触发点: 修改某条核心规则
- 用户在哪个场景下会感知到这个变化
- 哪个相关功能的行为会跟着变
- 如果上下游没有同步调整,会出现什么不一致
:::判断标准:每一条都是"对用户或业务有什么影响",不是"需要改哪个文件"。
:::tabs — 标签页
并列的、同级别的信息,减少垂直滚动。
:::tabs
::tab[场景 A]
场景 A 的描述...
::tab[场景 B]
场景 B 的描述...
::::::metric — 指标卡片
产品关键数字。用 | 分隔数值和标签。
:::metric
42|活跃用户数
3|核心模块
5|API 接口
:::异常场景提示
在旅程中标注异常/边界情况时,用标准 markdown blockquote 加粗条件:
> **如果网断了:** 游戏卡在当前阶段,刷新页面后从最近的检查点恢复。每个核心流程至少标注 1-2 个主要异常场景,放在相关流程描述之后。
内联语法
术语提示(inline tooltip): [显示文字](tip:鼠标悬停时显示的解释) — 渲染为带虚线下划线的文字,悬停显示提示。
概念交叉链接: [概念名](#anchor) — 渲染为加粗链接,悬停弹出目标概念简介(Popover),点击跳转。
#!/usr/bin/env bash
#
# Panova build script
# Usage: bash ~/.claude/skills/panova/build.sh <input.md> [output.html]
#
# Reads markdown + style.css + render.js, assembles into a self-contained HTML report.
# Includes post-build JS validation.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TEMPLATE="$SCRIPT_DIR/template.html"
STYLE="$SCRIPT_DIR/style.css"
JS="$SCRIPT_DIR/render.js"
if [ $# -lt 1 ]; then
echo "Usage: $0 <input.md> [output.html]"
exit 1
fi
INPUT="$1"
OUTPUT="${2:-./panova-report.html}"
for f in "$INPUT" "$TEMPLATE" "$STYLE" "$JS"; do
if [ ! -f "$f" ]; then echo "Error: file not found: $f"; exit 1; fi
done
python3 - "$INPUT" "$STYLE" "$JS" "$TEMPLATE" "$OUTPUT" <<'PYEOF'
import sys, re
# Read and escape markdown for JS template literal
with open(sys.argv[1], 'r') as f:
md = f.read()
md = md.replace('\\', '\\\\')
md = md.replace('`', '\\`')
md = md.replace('${', '\\${')
# Read CSS
with open(sys.argv[2], 'r') as f:
style = f.read()
# Read JS, inject markdown
with open(sys.argv[3], 'r') as f:
js = f.read()
js = js.replace('__REPORT_CONTENT__', md)
# Read template, inject style and JS
with open(sys.argv[4], 'r') as f:
tpl = f.read()
result = tpl.replace('__STYLES__', style).replace('__SCRIPTS__', js)
with open(sys.argv[5], 'w') as f:
f.write(result)
PYEOF
echo "Report generated: $OUTPUT"
# --- Post-build validation ---
echo "Validating..."
ERRORS=0
# 1. Extract JS from HTML and check syntax with node
python3 - "$OUTPUT" <<'VALIDATE_PYEOF'
import sys, re
with open(sys.argv[1], 'r') as f:
content = f.read()
# Extract all inline <script> blocks (skip ones with src=)
scripts = re.findall(r'<script(?:\s[^>]*)?>(?!<)(.*?)</script>', content, re.DOTALL)
# Filter out empty and src-only scripts
js_blocks = [s for s in scripts if s.strip()]
if not js_blocks:
print("Warning: No inline JS found in HTML")
sys.exit(0)
# Write combined JS for syntax check
combined = '\n'.join(js_blocks)
with open('/tmp/panova-validate.js', 'w') as f:
f.write(combined)
VALIDATE_PYEOF
if [ -f /tmp/panova-validate.js ]; then
if ! node -c /tmp/panova-validate.js 2>/tmp/panova-validate-err.txt; then
echo "ERROR: JS syntax error in generated HTML:"
cat /tmp/panova-validate-err.txt
ERRORS=$((ERRORS + 1))
fi
rm -f /tmp/panova-validate.js /tmp/panova-validate-err.txt
fi
# 2. Check for dangerous patterns: literal </script> inside the JS content
python3 - "$OUTPUT" <<'CHECK_PYEOF'
import sys, re
with open(sys.argv[1], 'r') as f:
content = f.read()
# Find <script>...</script> blocks and check for premature </script> inside
scripts = list(re.finditer(r'<script(?:\s[^>]*)?>(?!<)(.*?)</script>', content, re.DOTALL))
for i, m in enumerate(scripts):
body = m.group(1)
# Check if body contains </script (case insensitive) which would break parsing
inner_matches = list(re.finditer(r'</script', body, re.IGNORECASE))
if inner_matches:
for im in inner_matches:
line = content[:m.start() + im.start()].count('\n') + 1
print(f"ERROR: Found literal </script inside <script> block at line {line}")
sys.exit(1)
# Check for unescaped backticks in template literal
raw_match = re.search(r'const RAW = `(.*?)(?<!\\)`', content, re.DOTALL)
if raw_match:
raw_content = raw_match.group(1)
# Check for unescaped backticks (backtick not preceded by backslash)
for i, ch in enumerate(raw_content):
if ch == '`' and (i == 0 or raw_content[i-1] != '\\'):
line = content[:raw_match.start() + i].count('\n') + 1
print(f"ERROR: Unescaped backtick in template literal at line {line}")
sys.exit(1)
# Check for unescaped ${
for m2 in re.finditer(r'(?<!\\)\$\{', raw_content):
line = content[:raw_match.start() + m2.start()].count('\n') + 1
print(f"ERROR: Unescaped ${{ in template literal at line {line}")
sys.exit(1)
print("OK: No dangerous patterns found")
CHECK_PYEOF
if [ $? -ne 0 ]; then
ERRORS=$((ERRORS + 1))
fi
if [ $ERRORS -gt 0 ]; then
echo "Build completed with $ERRORS validation error(s). Report may not work correctly."
exit 1
else
echo "Validation passed."
fi
交互设计规范写作指导
报告中的"交互设计规范"章节,从代码或 PRD 中提炼产品已有的 UI/UX 模式。这不是发明新规范,而是把已经存在但没有文档化的规律记录下来,帮 PM 在后续需求评审时判断"新功能应该长什么样"。
提炼哪些维度
根据项目实际情况选择,不必每个都有:
| 维度 | 看什么 | 输出什么 |
|---|---|---|
| 信息展示方式 | 列表 / 卡片 / 表格 / 时间轴 | 场景→组件对照 |
| 弹出层选择 | Modal / Drawer / Popover | 使用规律总结 |
| 文案风格 | 按钮文字、空状态、错误信息 | 文案规律和语气标准 |
| 反馈方式 | Toast / 行内提示 / 页面级错误 | 反馈方式对照 |
| 表单模式 | 必填标记、校验时机、分步/单页 | 表单设计规律 |
| 空状态和加载 | 空列表、加载中、首屏策略 | 各状态处理方式 |
| 导航结构 | 主导航层级、面包屑、返回逻辑 | 导航模式总结 |
| 操作确认 | 二次确认方式 | 确认规则 |
写作格式
用 :::tabs 组织不同维度,每个 tab 一个维度。每条规律一句话 + 一个具体例子。
:::tabs
::tab[弹出层]
- **编辑表单** 用抽屉(Drawer),从右侧滑入,保留当前页面上下文
- **确认操作** 用弹窗(Modal),居中显示,强制用户做出选择
- **辅助信息** 用弹出框(Popover),悬停或点击触发,不打断操作流
::tab[文案风格]
- 按钮用动词开头:"创建项目"、"保存更改"、"确认删除"
- 空状态说"还没有 XX",不说"暂无数据"
- 错误提示说明原因和下一步:"网络连接失败,请检查网络后重试"
:::注意事项
- 只写代码或 PRD 中已经存在的模式,不要臆造
- 如果产品在某个维度上不一致(比如同一类操作有的用弹窗有的用抽屉),指出不一致本身
- 如果项目太小或 UI 层较薄,报告中可以省略这个章节
const RAW = `__REPORT_CONTENT__`;
// --- Icons ---
const ICONS = {
create: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6"/><path d="M8 5v6M5 8h6"/></svg>',
impact: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 8h10M10 5l3 3-3 3"/></svg>',
chevron: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 4l4 4-4 4"/></svg>',
};
// --- Mindmap branch colors ---
const BRANCH_COLORS = [
{ bg: '#fff7ed', text: '#9a3412', border: '#fed7aa' },
{ bg: '#eff6ff', text: '#1e40af', border: '#bfdbfe' },
{ bg: '#f0fdfa', text: '#115e59', border: '#99f6e4' },
{ bg: '#faf5ff', text: '#6b21a8', border: '#d8b4fe' },
{ bg: '#fffbeb', text: '#92400e', border: '#fde68a' },
{ bg: '#fef2f2', text: '#991b1b', border: '#fecaca' },
];
// --- Escape helpers ---
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
function escAttr(s) { return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); }
// --- Block parser ---
function parseBlocks(src) {
const lines = src.split('\n');
const segments = [];
let current = { type: 'md', lines: [] };
for (const line of lines) {
const openMatch = line.match(/^:::(\w+)\s*$/);
const closeMatch = line.match(/^:::\s*$/);
if (openMatch && current.type === 'md') {
if (current.lines.length) segments.push({ ...current, text: current.lines.join('\n') });
current = { type: openMatch[1], lines: [] };
} else if (closeMatch && current.type !== 'md') {
segments.push({ ...current, text: current.lines.join('\n') });
current = { type: 'md', lines: [] };
} else {
current.lines.push(line);
}
}
if (current.lines.length) segments.push({ ...current, text: current.lines.join('\n') });
return segments;
}
// --- markdown-it setup ---
const md = markdownit({ html: true, linkify: true, typographer: true });
// Inline tooltip: [term](tip:explanation)
let _inTipLink = false;
const _origLinkOpen = md.renderer.rules.link_open || null;
md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
const hrefIdx = tokens[idx].attrIndex('href');
if (hrefIdx >= 0) {
const href = tokens[idx].attrs[hrefIdx][1];
if (href.startsWith('tip:')) {
_inTipLink = true;
return '<span class="tip-word" data-tip="' + escAttr(decodeURIComponent(href.slice(4))) + '">';
}
if (href.startsWith('#')) {
if (!tokens[idx].attrGet('class')) tokens[idx].attrSet('class', 'concept-link');
}
}
_inTipLink = false;
return _origLinkOpen ? _origLinkOpen(tokens, idx, options, env, self) : self.renderToken(tokens, idx, options);
};
md.renderer.rules.link_close = function(tokens, idx, options, env, self) {
if (_inTipLink) { _inTipLink = false; return '</span>'; }
return self.renderToken(tokens, idx, options);
};
// --- Concept card post-processing ---
function postProcessConceptCards(html) {
html = html.replace(
/<p><strong>怎么产生[::]\s*<\/strong>\s*([\s\S]*?)<\/p>/g,
'<div class="concept-attr attr-create"><div class="concept-attr-icon">' + ICONS.create + '</div><div class="concept-attr-body"><strong>怎么产生</strong><p>$1</p></div></div>'
);
html = html.replace(
/<p><strong>影响范围[::]\s*<\/strong>\s*([\s\S]*?)<\/p>/g,
'<div class="concept-attr attr-impact"><div class="concept-attr-icon">' + ICONS.impact + '</div><div class="concept-attr-body"><strong>影响范围</strong><p>$1</p></div></div>'
);
return html;
}
// --- Wrap concept sections into cards (DOM-based) ---
function generateConceptGrid() {
const cards = document.querySelectorAll('.concept-card');
if (!cards.length) return;
// Find the first concept card's parent
const firstCard = cards[0];
const parent = firstCard.parentNode;
// Build grid data
const gridItems = [];
cards.forEach((card, i) => {
const h3 = card.querySelector('h3');
if (!h3) return;
const id = h3.id;
const text = h3.textContent || '';
// Parse "中文名 (EnglishName)"
const m = text.match(/^(.+?)\s*[((](.+?)[))]\s*$/);
const cn = m ? m[1].trim() : text.trim();
const en = m ? m[2].trim() : '';
// Get first paragraph as description
const firstP = card.querySelector('p');
const desc = firstP ? firstP.textContent.trim() : '';
gridItems.push({ id, cn, en, desc });
});
if (!gridItems.length) return;
// Build grid HTML — clean list with dividers
let html = '<div class="concept-grid" id="concept-grid-overview">';
gridItems.forEach(item => {
html += '<a class="concept-grid-item" href="#' + item.id + '">';
html += '<div class="concept-grid-name">' + escHtml(item.cn);
if (item.en) html += ' <span class="concept-grid-en">' + escHtml(item.en) + '</span>';
html += '</div>';
if (item.desc) html += '<div class="concept-grid-desc">' + escHtml(item.desc) + '</div>';
html += '</a>';
});
html += '</div>';
// Insert before first concept card
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
parent.insertBefore(wrapper.firstElementChild, firstCard);
}
function wrapConceptCards() {
const attrs = document.querySelectorAll('.concept-attr');
attrs.forEach(attr => {
if (attr.closest('.concept-card')) return;
// Find preceding h3
let h3 = attr.previousElementSibling;
while (h3 && h3.tagName !== 'H3') h3 = h3.previousElementSibling;
if (!h3) return;
if (h3.closest('.concept-card')) return;
// Collect elements from h3 to next h2/h3
const wrapper = document.createElement('div');
wrapper.className = 'concept-card';
h3.parentNode.insertBefore(wrapper, h3);
const toMove = [];
let el = h3;
while (el) {
if (el !== h3 && el.nodeType === 1 && (el.tagName === 'H2' || el.tagName === 'H3')) break;
toMove.push(el);
el = el.nextSibling;
}
toMove.forEach(e => wrapper.appendChild(e));
// Append back-to-overview link
const backLink = document.createElement('a');
backLink.className = 'concept-back-link';
backLink.href = '#concept-grid-overview';
backLink.textContent = '\u2191 \u8FD4\u56DE\u6982\u5FF5\u603B\u89C8';
wrapper.appendChild(backLink);
});
}
// --- Mindmap ---
function parseMindmapTree(text) {
const lines = text.split('\n').filter(l => /^\s*-/.test(l));
const root = [];
const stack = [{ level: -1, node: { children: root } }];
for (const line of lines) {
const indent = line.search(/\S/);
const level = Math.floor(indent / 2);
const nodeText = line.trim().replace(/^-\s*/, '');
const node = { text: nodeText, children: [] };
while (stack.length > 1 && stack[stack.length - 1].level >= level) stack.pop();
stack[stack.length - 1].node.children.push(node);
stack.push({ level, node });
}
return root;
}
function renderMindmapNode(node, depth, branchIndex) {
const hasChildren = node.children.length > 0;
const toggle = hasChildren ? '<span class="mm-toggle">\u25BE</span>' : '';
const clickAttr = hasChildren ? ' onclick="toggleMM(this)"' : '';
let styleAttr = '';
if (depth === 1 && branchIndex >= 0) {
const c = BRANCH_COLORS[branchIndex % BRANCH_COLORS.length];
styleAttr = ' style="background:' + c.bg + ';color:' + c.text + ';border:1px solid ' + c.border + '"';
}
let html = '<div class="mm-node mm-l' + depth + '">';
html += '<div class="mm-label' + (hasChildren ? ' mm-expandable' : '') + '"' + clickAttr + styleAttr + '>' + toggle + escHtml(node.text) + '</div>';
if (hasChildren) {
html += '<div class="mm-children">';
node.children.forEach((c, i) => {
html += renderMindmapNode(c, depth + 1, depth === 0 ? i : branchIndex);
});
html += '</div>';
}
html += '</div>';
return html;
}
function renderMindmap(text) {
const nodes = parseMindmapTree(text);
let html = '<div class="block-mindmap-tree">';
nodes.forEach((n, i) => { html += renderMindmapNode(n, 0, i); });
html += '</div>';
return html;
}
function toggleMM(label) {
label.closest('.mm-node').classList.toggle('mm-collapsed');
}
// --- Sequence diagram ---
function renderSequence(text) {
const lines = text.split('\n');
const participants = [];
const messages = [];
for (const line of lines) {
const t = line.trim();
if (!t || t === 'sequenceDiagram') continue;
if (t.startsWith('participant ')) {
participants.push(t.slice('participant '.length).trim());
} else {
const m = t.match(/^(.+?)\s*(->>|-->>)\s*(.+?):\s*(.+)/);
if (m) messages.push({ from: m[1].trim(), dashed: m[2] === '-->>', to: m[3].trim(), msg: m[4].trim() });
}
}
let html = '<div class="seq-wrap">';
if (participants.length) {
html += '<div class="seq-actors">';
participants.forEach(p => { html += '<span class="seq-actor">' + escHtml(p) + '</span>'; });
html += '</div>';
}
html += '<div class="seq-timeline">';
messages.forEach(msg => {
const self = msg.from === msg.to;
const arrow = self ? '\u21BB' : (msg.dashed ? '\u21E2' : '\u2192');
html += '<div class="seq-row' + (msg.dashed ? ' seq-row-dashed' : '') + '">';
html += '<div class="seq-dir"><span class="seq-from">' + escHtml(msg.from) + '</span>';
html += '<span class="seq-arrow">' + arrow + '</span>';
if (!self) html += '<span class="seq-to">' + escHtml(msg.to) + '</span>';
html += '</div><div class="seq-text">' + escHtml(msg.msg) + '</div></div>';
});
html += '</div></div>';
return html;
}
// --- Block renderers ---
function wrapDiagram(inner, label) {
return '<div class="diagram-outer">' + inner + '<div class="diagram-outer-label">' + label + '</div></div>';
}
function renderMetric(text) {
const items = text.trim().split('\n').map(line => {
const parts = line.split('|');
return parts.length >= 2 ? { value: parts[0].trim(), label: parts[1].trim() } : null;
}).filter(Boolean);
if (!items.length) return '';
return '<div class="metric-row">' + items.map(it =>
'<div class="metric-card"><div class="metric-value">' + escHtml(it.value) + '</div><div class="metric-label">' + escHtml(it.label) + '</div></div>'
).join('') + '</div>';
}
function renderImpact(text) {
const lines = text.trim().split('\n');
let trigger = '';
const items = [];
for (const line of lines) {
if (/^\u89E6\u53D1\u70B9[::]/.test(line)) {
trigger = line.replace(/^\u89E6\u53D1\u70B9[::]\s*/, '');
} else if (line.startsWith('- ')) {
items.push(line.slice(2));
}
}
return '<div class="block-impact" onclick="this.classList.toggle(\'open\')">' +
'<div class="impact-trigger">' + escHtml(trigger) + '<span class="impact-toggle">\u25B8</span></div>' +
'<div class="impact-items"><ul>' + items.map(it => '<li>' + escHtml(it) + '</li>').join('') + '</ul></div></div>';
}
function renderTabs(text) {
const tabs = [];
let current = null;
for (const line of text.split('\n')) {
const m = line.match(/^::tab\[(.+)\]\s*$/);
if (m) { if (current) tabs.push(current); current = { label: m[1], lines: [] }; }
else if (current) current.lines.push(line);
}
if (current) tabs.push(current);
if (!tabs.length) return '';
const id = 'tabs-' + Math.random().toString(36).slice(2, 8);
const bar = tabs.map((t, i) =>
'<button class="' + (i === 0 ? 'active' : '') + '" onclick="switchTab(\'' + id + '\',' + i + ')">' + escHtml(t.label) + '</button>'
).join('');
const panels = tabs.map((t, i) =>
'<div class="tab-panel ' + (i === 0 ? 'active' : '') + '" data-tab-group="' + id + '" data-tab-index="' + i + '">' + md.render(t.lines.join('\n')) + '</div>'
).join('');
return '<div class="block-tabs"><div class="tab-bar">' + bar + '</div>' + panels + '</div>';
}
function switchTab(group, index) {
document.querySelectorAll('[data-tab-group="' + group + '"]').forEach(el => el.classList.remove('active'));
document.querySelector('[data-tab-group="' + group + '"][data-tab-index="' + index + '"]').classList.add('active');
const bar = document.querySelector('[data-tab-group="' + group + '"]').closest('.block-tabs').querySelector('.tab-bar');
bar.querySelectorAll('button').forEach((b, i) => b.classList.toggle('active', i === index));
}
// --- Block renderers ---
function renderSegments(segments) {
return segments.map(seg => {
switch (seg.type) {
case 'md': return postProcessConceptCards(md.render(seg.text));
case 'mermaid':
return '<div class="diagram-outer"><pre class="mermaid">' + escHtml(seg.text.trim()) + '</pre><div class="diagram-outer-label">FLOW</div></div>';
case 'sequence': return wrapDiagram(renderSequence(seg.text.trim()), 'SEQUENCE');
case 'mindmap': return renderMindmap(seg.text.trim());
case 'tooltip':
return '<div class="block-tooltip" onclick="this.classList.toggle(\'open\')">' +
'<div class="block-tooltip-header">' + ICONS.chevron + ' 技术细节</div>' +
'<div class="tooltip-body">' + md.render(seg.text) + '</div></div>';
case 'callout': return '<div class="block-callout">' + md.render(seg.text) + '</div>';
case 'impact': return renderImpact(seg.text);
case 'tabs': return renderTabs(seg.text);
case 'metric': return renderMetric(seg.text);
case 'demo':
var demoId = 'demo-' + Math.random().toString(36).slice(2, 8);
var demoBody = seg.text.trim().replace(/"/g, '"').replace(/<\//g, '</');
var demoCSS = [
'*{margin:0;padding:0;box-sizing:border-box}',
'body{font-family:-apple-system,"Helvetica Neue","PingFang SC","Noto Sans SC",sans-serif;padding:20px;background:transparent;color:#1a1a1a;line-height:1.6;overflow:hidden;font-size:14px}',
':root{--black:#37352f;--gray-900:#1a1a1a;--gray-700:#555;--gray-500:#888;--gray-400:#aaa;--gray-300:#ccc;--gray-200:#e5e5e5;--gray-100:#f5f5f5;--gray-50:#fafafa;--white:#fff;--radius:8px;--radius-sm:6px;--radius-full:9999px}',
'.card{background:var(--white);border:1px solid var(--gray-200);border-radius:var(--radius);padding:16px}',
'.card-sm{background:var(--white);border:1px solid var(--gray-200);border-radius:var(--radius-sm);padding:10px 14px}',
'.row{display:flex;gap:12px;align-items:center}',
'.col{display:flex;flex-direction:column;gap:8px}',
'.wrap{display:flex;flex-wrap:wrap;gap:8px}',
'.center{display:flex;align-items:center;justify-content:center}',
'.gap-sm{gap:6px}.gap-lg{gap:16px}',
'.text-xs{font-size:11px}.text-sm{font-size:12px}.text-md{font-size:14px}.text-lg{font-size:16px}.text-xl{font-size:20px}',
'.text-bold{font-weight:700}.text-medium{font-weight:500}',
'.text-black{color:var(--black)}.text-gray{color:var(--gray-500)}.text-light{color:var(--gray-400)}.text-white{color:var(--white)}',
'.bg-black{background:var(--black)}.bg-dark{background:var(--gray-900)}.bg-gray{background:var(--gray-100)}.bg-white{background:var(--white)}',
'.pill{display:inline-flex;align-items:center;padding:6px 14px;border-radius:var(--radius-full);font-size:13px;white-space:nowrap}',
'.pill-outline{border:1px solid var(--gray-200);background:var(--white)}',
'.pill-filled{background:var(--black);color:var(--white)}',
'.pill-active{background:var(--black);color:var(--white)}',
'.btn{display:inline-flex;align-items:center;justify-content:center;padding:10px 20px;border-radius:var(--radius);border:none;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit}',
'.btn-primary{background:var(--black);color:var(--white)}.btn-secondary{background:var(--gray-100);color:var(--black);border:1px solid var(--gray-200)}',
'.btn-block{width:100%}',
'.avatar{width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;flex-shrink:0}',
'.avatar-sm{width:32px;height:32px;font-size:11px}.avatar-lg{width:48px;height:48px;font-size:14px}',
'.avatar-dark{background:var(--black);color:var(--white)}.avatar-gray{background:var(--gray-100);color:var(--gray-700)}.avatar-outline{background:var(--white);border:2px solid var(--gray-200);color:var(--gray-700)}',
'.bubble{padding:10px 14px;border-radius:2px 12px 12px 12px;line-height:1.6}.bubble-gray{background:var(--gray-100)}.bubble-light{background:var(--gray-50);border:1px solid var(--gray-200)}',
'.divider{height:1px;background:var(--gray-200);margin:12px 0}',
'.label{font-size:12px;color:var(--gray-500);margin-bottom:6px}',
'.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:var(--radius-full);font-size:11px;font-weight:600}',
'.badge-dark{background:var(--black);color:var(--white)}.badge-gray{background:var(--gray-100);color:var(--gray-700)}.badge-outline{border:1px solid var(--gray-300);color:var(--gray-700)}',
'.status-dot{width:8px;height:8px;border-radius:50%;display:inline-block}.status-active{background:var(--black)}.status-inactive{background:var(--gray-300)}.status-glow{box-shadow:0 0 8px rgba(0,0,0,0.3)}',
'.mt-sm{margin-top:8px}.mt-md{margin-top:12px}.mt-lg{margin-top:16px}.mb-sm{margin-bottom:8px}.mb-md{margin-bottom:12px}.mb-lg{margin-bottom:16px}',
'.text-center{text-align:center}.text-right{text-align:right}',
'.w-full{width:100%}.max-w-sm{max-width:360px}.max-w-md{max-width:440px}.mx-auto{margin-left:auto;margin-right:auto}',
'.opacity-30{opacity:0.3}.opacity-50{opacity:0.5}.opacity-70{opacity:0.7}',
'.shadow-sm{box-shadow:0 1px 3px rgba(0,0,0,0.06)}'
].join('');
var demoHead = '<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>' + demoCSS + '</style></head><body>';
var demoTail = '<script>new ResizeObserver(function(){parent.postMessage({type:"demo-resize",id:"' + demoId + '",h:document.body.scrollHeight},"*")}).observe(document.body)</script></body></html>';
return '<div class="block-demo">' +
'<div class="demo-label">DEMO</div>' +
'<iframe id="' + demoId + '" class="demo-frame" sandbox="allow-scripts" srcdoc="' + demoHead + demoBody + demoTail + '"></iframe></div>';
default: return md.render(seg.text);
}
}).join('');
}
// --- Nav & scroll spy ---
function slugify(text) {
return text.trim().toLowerCase()
.replace(/[\s\u3000()()【】\[\]「」《》、,。!?::]/g, '-')
.replace(/[^\w\u4e00-\u9fff-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || 'sec';
}
const slugCounts = {};
function uniqueSlug(base) {
const count = (slugCounts[base] || 0) + 1;
slugCounts[base] = count;
return count === 1 ? base : base + '-' + count;
}
function buildNav(html) {
const tmp = document.createElement('div');
tmp.innerHTML = html;
const links = [];
tmp.querySelectorAll('h2, h3').forEach(el => {
const id = uniqueSlug(slugify(el.textContent));
el.id = id;
links.push({ id, text: el.textContent, level: el.tagName });
});
document.getElementById('content').innerHTML = tmp.innerHTML;
document.getElementById('nav-links').innerHTML = links.map(l =>
'<a href="#' + l.id + '" class="' + (l.level === 'H3' ? 'sub' : '') + '">' + l.text + '</a>'
).join('');
}
function setupScrollSpy() {
const headings = document.querySelectorAll('h2[id], h3[id]');
const navLinks = document.querySelectorAll('#nav-links a');
if (!headings.length) return;
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
navLinks.forEach(a => a.classList.remove('active'));
const link = document.querySelector('#nav-links a[href="#' + entry.target.id + '"]');
if (link) link.classList.add('active');
}
});
}, { rootMargin: '-20% 0px -70% 0px' });
headings.forEach(h => observer.observe(h));
}
// --- Link validation ---
function validateLinks() {
const links = document.querySelectorAll('#content a[href^="#"]');
const ids = new Set();
document.querySelectorAll('[id]').forEach(el => ids.add(el.id));
let broken = 0;
links.forEach(link => {
const target = link.getAttribute('href').slice(1);
if (target && !ids.has(target)) {
console.warn('Broken link:', link.textContent, '\u2192 #' + target);
broken++;
}
});
if (broken) console.warn('\u26A0 Found ' + broken + ' broken anchor link(s). Check console for details.');
return broken;
}
// --- Concept Link Popover ---
function setupConceptPopovers() {
var popover = document.createElement('div');
popover.id = 'concept-popover';
document.body.appendChild(popover);
var conceptMap = new Map();
document.querySelectorAll('.concept-card').forEach(function(card) {
var h3 = card.querySelector('h3');
if (!h3 || !h3.id) return;
var firstP = card.querySelector('p');
var desc = firstP ? firstP.textContent.trim() : '';
conceptMap.set(h3.id, {
name: h3.textContent,
desc: desc.length > 150 ? desc.substring(0, 150) + '...' : desc
});
});
document.querySelectorAll('#content a[href^="#"]').forEach(function(link) {
var targetId = link.getAttribute('href').slice(1);
var data = conceptMap.get(targetId);
if (!data) return;
link.addEventListener('mouseenter', function() {
popover.innerHTML = '<div class="cpop-name">' + escHtml(data.name) + '</div>' +
'<div class="cpop-desc">' + escHtml(data.desc) + '</div>';
var rect = link.getBoundingClientRect();
popover.style.left = rect.left + rect.width / 2 + 'px';
popover.style.top = (rect.top - 8) + 'px';
popover.classList.add('visible');
});
link.addEventListener('mouseleave', function() {
popover.classList.remove('visible');
});
});
}
// --- Mobile Nav ---
function setupMobileNav() {
var nav = document.getElementById('nav');
if (!nav) return;
// Create hamburger button
var btn = document.createElement('button');
btn.className = 'mobile-nav-toggle';
btn.textContent = '\u2630';
btn.setAttribute('aria-label', 'Toggle navigation');
document.body.appendChild(btn);
// Create overlay
var overlay = document.createElement('div');
overlay.className = 'nav-overlay';
document.body.appendChild(overlay);
btn.addEventListener('click', function() {
nav.classList.toggle('nav-open');
overlay.classList.toggle('visible');
});
overlay.addEventListener('click', function() {
nav.classList.remove('nav-open');
overlay.classList.remove('visible');
});
// Close nav when a link is clicked (mobile)
nav.addEventListener('click', function(e) {
if (e.target.tagName === 'A') {
nav.classList.remove('nav-open');
overlay.classList.remove('visible');
}
});
}
// --- Nav Search ---
function setupSearch() {
var navTitle = document.querySelector('.nav-title');
if (!navTitle) return;
var input = document.createElement('input');
input.type = 'text';
input.className = 'nav-search';
input.placeholder = '\u641C\u7D22\u7AE0\u8282...';
navTitle.parentNode.insertBefore(input, navTitle.nextSibling);
input.addEventListener('input', function() {
var query = input.value.trim().toLowerCase();
var links = document.querySelectorAll('#nav-links a');
links.forEach(function(link) {
if (!query) {
link.classList.remove('search-dimmed');
} else {
var text = link.textContent.toLowerCase();
link.classList.toggle('search-dimmed', text.indexOf(query) === -1);
}
});
});
}
// --- Init ---
function init() {
const segments = parseBlocks(RAW);
const html = renderSegments(segments);
buildNav(html);
wrapConceptCards();
generateConceptGrid();
setupScrollSpy();
setupConceptPopovers();
// Initialize mermaid
if (typeof mermaid !== 'undefined') {
mermaid.initialize({
startOnLoad: false,
theme: 'base',
themeVariables: {
primaryColor: '#e3e2de',
primaryTextColor: '#37352f',
primaryBorderColor: '#d4d4d0',
lineColor: '#b4b4b0',
secondaryColor: '#f7f6f3',
tertiaryColor: '#f7f6f3',
noteBkgColor: '#fffbeb',
noteTextColor: '#92400e',
fontSize: '14px',
fontFamily: 'inherit'
},
flowchart: {
htmlLabels: true,
curve: 'basis',
padding: 16,
nodeSpacing: 40,
rankSpacing: 60,
useMaxWidth: true,
wrappingWidth: 180
}
});
mermaid.run({ querySelector: 'pre.mermaid' });
}
// Demo iframe auto-resize
window.addEventListener('message', function(e) {
if (e.data && e.data.type === 'demo-resize') {
var frame = document.getElementById(e.data.id);
if (frame) frame.style.height = e.data.h + 'px';
}
});
validateLinks();
setupMobileNav();
setupSearch();
// Expand first impact chain by default
const firstImpact = document.querySelector('.block-impact');
if (firstImpact) firstImpact.classList.add('open');
const h1 = document.querySelector('h1');
if (h1) document.title = h1.textContent + ' \u2014 Panova';
}
init();
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--black: #37352f;
--gray-900: #1a1a1a;
--gray-800: #37352f;
--gray-700: #555;
--gray-600: #6b6b6b;
--gray-500: #9b9a97;
--gray-400: #b4b4b0;
--gray-300: #d4d4d0;
--gray-200: #e8e8e4;
--gray-150: #f0f0ec;
--gray-100: #f7f6f3;
--gray-50: #fbfbfa;
--white: #fff;
--orange: #f97316;
--orange-light: #fff7ed;
--blue: #3b82f6;
--blue-light: #eff6ff;
--teal: #14b8a6;
--teal-light: #f0fdfa;
--coral: #f87171;
--coral-light: #fef2f2;
--amber: #f59e0b;
--purple: #a855f7;
--purple-light: #faf5ff;
--accent: #111;
--font-sans: -apple-system, "Helvetica Neue", "PingFang SC", "Noto Sans SC", sans-serif;
--font-mono: "SF Mono", "Fira Code", "Noto Sans Mono CJK SC", monospace;
--radius: 8px;
--radius-lg: 14px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.03);
--shadow-md: 0 4px 14px rgba(0,0,0,0.07), 0 2px 6px rgba(0,0,0,0.04);
--shadow-lg: 0 8px 28px rgba(0,0,0,0.09), 0 4px 10px rgba(0,0,0,0.05);
}
html { font-size: 16px; scroll-behavior: smooth; }
body {
font-family: var(--font-sans);
color: var(--black);
background: var(--white);
line-height: 1.75;
-webkit-font-smoothing: antialiased;
}
/* --- Layout --- */
.page { display: flex; min-height: 100vh; }
nav {
position: fixed;
top: 0; left: 0;
width: 260px; height: 100vh;
overflow-y: auto;
border-right: 1px solid var(--gray-200);
padding: 32px 16px 64px;
font-size: 14px;
background: var(--white);
z-index: 10;
}
nav::-webkit-scrollbar { width: 3px; }
nav::-webkit-scrollbar-thumb { background: var(--gray-300); border-radius: 2px; }
nav .nav-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--gray-400);
margin-bottom: 24px;
padding-left: 12px;
}
nav a {
display: block;
color: var(--gray-500);
text-decoration: none;
padding: 6px 12px;
border-radius: 6px;
transition: all 0.15s;
font-size: 13px;
line-height: 1.5;
}
nav a:hover { color: var(--black); background: var(--gray-50); }
nav a.active {
color: var(--black);
font-weight: 600;
background: var(--orange-light);
}
nav a.sub { padding-left: 24px; font-size: 12px; }
.nav-footer {
position: fixed;
bottom: 24px; left: 20px;
font-size: 11px;
color: var(--gray-400);
}
.nav-footer span { color: var(--orange); font-weight: 600; }
main {
flex: none;
max-width: 960px;
padding: 56px 64px 120px;
margin-left: max(260px, calc((100vw - 960px) / 2));
margin-right: auto;
}
/* --- Scroll offset --- */
h2[id], h3[id], h4[id] { scroll-margin-top: 24px; }
/* --- Typography --- */
h1 {
font-size: 36px;
font-weight: 800;
margin-bottom: 8px;
letter-spacing: -0.03em;
line-height: 1.2;
}
p.subtitle {
font-size: 16px;
color: var(--gray-500);
margin-bottom: 48px;
line-height: 1.6;
}
h2 {
font-size: 22px;
font-weight: 800;
margin-top: 72px;
margin-bottom: 24px;
padding-bottom: 0;
letter-spacing: -0.01em;
}
h3 {
font-size: 18px;
font-weight: 700;
margin-top: 40px;
margin-bottom: 12px;
}
h4 {
font-size: 13px;
font-weight: 700;
margin-top: 28px;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--gray-500);
}
p { margin-bottom: 16px; color: var(--gray-800); }
strong { font-weight: 700; color: var(--black); }
code {
font-family: var(--font-mono);
font-size: 13.5px;
background: var(--gray-100);
padding: 2px 5px;
border-radius: 3px;
color: #eb5757;
}
pre {
background: var(--gray-100);
color: var(--gray-800);
padding: 16px 20px;
overflow-x: auto;
margin-bottom: 16px;
border-radius: var(--radius);
font-size: 13px;
line-height: 1.6;
border: 1px solid var(--gray-200);
}
pre code { background: none; padding: 0; color: inherit; }
ul, ol { padding-left: 20px; margin-bottom: 16px; }
li { margin-bottom: 6px; color: var(--gray-800); }
li::marker { color: var(--gray-400); }
/* --- Tables --- */
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin-bottom: 24px;
font-size: 14px;
border: 1px solid var(--gray-200);
border-radius: var(--radius);
overflow: hidden;
}
th {
text-align: left;
padding: 12px 16px;
font-weight: 700;
font-size: 11px;
color: var(--gray-500);
text-transform: uppercase;
letter-spacing: 0.06em;
background: var(--gray-50);
border-bottom: 1px solid var(--gray-200);
}
td {
text-align: left;
padding: 12px 16px;
border-bottom: 1px solid var(--gray-150);
}
tr:nth-child(even) td { background: var(--gray-50); }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--orange-light); }
/* --- Concept Grid Overview --- */
.concept-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0;
margin: 24px 0 40px;
border: 1px solid var(--gray-200);
border-radius: var(--radius-lg);
overflow: hidden;
}
.concept-grid-item {
display: block;
text-decoration: none;
padding: 16px;
border-bottom: 1px solid var(--gray-200);
border-right: 1px solid var(--gray-200);
transition: background 0.15s;
cursor: pointer;
}
.concept-grid-item:hover {
background: var(--gray-50);
}
.concept-grid-name {
font-weight: 700;
font-size: 15px;
color: var(--black);
margin-bottom: 2px;
line-height: 1.4;
}
.concept-grid-en {
font-weight: 400;
font-size: 12px;
color: var(--gray-400);
margin-left: 6px;
}
.concept-grid-desc {
font-size: 12px;
color: var(--gray-600);
line-height: 1.5;
}
/* --- Concept Card --- */
.concept-card {
border: 1px solid var(--gray-200);
border-radius: var(--radius-lg);
padding: 28px;
margin: 28px 0;
background: var(--white);
box-shadow: var(--shadow-sm);
transition: box-shadow 0.25s, transform 0.25s;
}
.concept-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.concept-card h3 {
margin-top: 0;
margin-bottom: 6px;
font-size: 19px;
}
.concept-card > p:first-of-type {
color: var(--gray-600);
margin-bottom: 20px;
font-size: 15px;
}
.concept-attr {
display: flex;
gap: 10px;
margin-bottom: 16px;
align-items: flex-start;
}
.concept-attr-icon {
flex-shrink: 0;
width: 28px;
height: 28px;
margin-top: 2px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
}
.concept-attr-icon svg { width: 16px; height: 16px; }
.attr-create .concept-attr-icon { background: var(--teal-light); }
.attr-create .concept-attr-icon svg { color: var(--teal); }
.attr-impact .concept-attr-icon { background: var(--coral-light); }
.attr-impact .concept-attr-icon svg { color: var(--coral); }
.concept-attr-body strong {
display: inline-block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 10px;
border-radius: 4px;
margin-bottom: 4px;
font-weight: 700;
}
.attr-create .concept-attr-body strong { background: var(--teal-light); color: #115e59; }
.attr-impact .concept-attr-body strong { background: var(--coral-light); color: #991b1b; }
.concept-attr-body p { margin: 0; font-size: 14px; color: var(--gray-700); }
/* --- Mindmap Tree --- */
.block-mindmap-tree { padding: 4px 0 8px; font-size: 13px; }
.mm-node { margin: 3px 0; }
.mm-label {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
border-radius: 20px;
line-height: 1.4;
user-select: none;
transition: transform 0.15s, box-shadow 0.15s;
}
.mm-expandable { cursor: pointer; }
.mm-expandable:hover { transform: scale(1.02); box-shadow: var(--shadow-sm); }
.mm-l0 > .mm-label {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: var(--white);
font-weight: 700;
font-size: 15px;
border-radius: 10px;
padding: 8px 20px;
box-shadow: var(--shadow-sm);
}
.mm-l1 > .mm-label { font-weight: 600; }
.mm-l2 > .mm-label { background: var(--white); border: 1px solid var(--gray-200); }
.mm-l3 > .mm-label { background: transparent; color: var(--gray-500); font-size: 12px; padding-left: 8px; }
.mm-toggle { font-size: 10px; transition: transform 0.2s; display: inline-block; }
.mm-children { margin-left: 26px; padding-left: 12px; border-left: 2px dashed var(--gray-200); padding-top: 4px; margin-top: 2px; }
.mm-node.mm-collapsed > .mm-children { display: none; }
.mm-node.mm-collapsed > .mm-label .mm-toggle { transform: rotate(-90deg); }
/* --- Flow Chain (horizontal) --- */
.flow-chain {
display: flex; flex-wrap: wrap; align-items: center;
gap: 6px; padding: 4px 0 8px;
}
.flow-chip {
background: var(--gray-100); border: 1px solid var(--gray-200);
border-radius: 20px; padding: 5px 14px; font-size: 13px; white-space: nowrap;
}
.flow-chip-back { background: #fff0f0; border-color: #f5c6c6; color: #b03030; }
.flow-sep { color: var(--gray-300); font-size: 15px; }
/* --- Flow Steps (vertical branching) --- */
.flow-steps { padding: 4px 0 8px; font-size: 13px; }
.flow-step {
display: inline-flex; align-items: center;
background: var(--gray-100); border: 1px solid var(--gray-200);
border-radius: 8px; padding: 7px 16px; margin: 3px 0; max-width: 520px;
}
.flow-decision {
display: inline-flex; align-items: center; gap: 8px;
background: #fffaec; border: 1.5px solid #f0d050;
border-radius: 8px; padding: 6px 16px; margin: 3px 0;
font-weight: 500; max-width: 520px;
}
.flow-dec-icon { color: #c49a00; font-size: 12px; }
.flow-branches { display: flex; flex-wrap: wrap; gap: 8px; margin: 5px 0 3px 20px; }
.flow-branch {
background: var(--white); border: 1px solid var(--gray-200);
border-radius: 6px; padding: 4px 12px; font-size: 12px;
}
.flow-branch b { color: var(--gray-600); margin-right: 4px; }
.flow-vconn { padding: 1px 0 1px 16px; color: var(--gray-300); font-size: 16px; line-height: 1; }
/* --- Sequence Diagram --- */
.seq-wrap { padding: 4px 0 8px; font-size: 13px; }
.seq-actors { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
.seq-actor { background: var(--black); color: var(--white); border-radius: 6px; padding: 4px 12px; font-size: 12px; font-weight: 600; }
.seq-timeline { border-left: 2px solid var(--gray-150); padding-left: 14px; }
.seq-row {
display: flex; align-items: baseline; gap: 10px;
padding: 7px 0; border-bottom: 1px solid var(--gray-100);
}
.seq-row:last-child { border-bottom: none; }
.seq-dir { display: flex; align-items: center; gap: 4px; white-space: nowrap; flex-shrink: 0; min-width: 140px; }
.seq-from, .seq-to { font-weight: 600; color: var(--gray-700); font-size: 12px; }
.seq-arrow { color: var(--gray-300); font-size: 13px; margin: 0 1px; }
.seq-text {
background: var(--gray-50); border: 1px solid var(--gray-150);
border-radius: 6px; padding: 3px 10px; font-size: 12px; color: var(--gray-700); flex: 1;
}
.seq-row-dashed .seq-text { background: transparent; border-style: dashed; color: var(--gray-500); }
/* --- Diagram outer wrapper --- */
.diagram-outer {
border: 1px solid var(--gray-200);
border-radius: var(--radius-lg);
background: var(--white);
margin: 28px 0;
padding: 24px 28px 16px;
box-shadow: var(--shadow-sm);
overflow-x: auto;
}
.diagram-outer-label {
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.08em; color: var(--gray-300); margin-top: 12px;
}
.diagram-outer .mermaid { margin: 0; background: transparent; color: inherit; padding: 0; font-size: inherit; border: none; }
.diagram-outer .mermaid svg { max-width: 100%; }
.diagram-outer .nodeLabel {
white-space: normal !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
line-height: 1.4 !important;
}
/* --- Custom blocks --- */
.block-tooltip {
font-size: 13px;
color: var(--gray-600);
border: 1px solid var(--gray-200);
border-radius: var(--radius);
padding: 14px 18px;
margin: 16px 0 20px;
cursor: pointer;
background: var(--gray-50);
transition: border-color 0.2s;
}
.block-tooltip:hover { border-color: var(--gray-300); }
.block-tooltip-header {
display: flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--gray-400); user-select: none;
}
.block-tooltip-header svg { width: 14px; height: 14px; transition: transform 0.2s; }
.block-tooltip.open .block-tooltip-header svg { transform: rotate(90deg); }
.block-tooltip .tooltip-body { max-height: 0; overflow: hidden; transition: max-height 0.35s ease; }
.block-tooltip.open .tooltip-body { max-height: 600px; margin-top: 12px; }
.block-tooltip .tooltip-body p { font-size: 13px; color: var(--gray-600); margin-bottom: 8px; }
.block-callout {
background: var(--gray-100);
border: 1px solid var(--gray-200);
color: var(--gray-800);
padding: 22px 26px;
margin: 24px 0;
border-radius: var(--radius-lg);
font-size: 14px;
line-height: 1.7;
box-shadow: var(--shadow-sm);
}
.block-callout p { color: var(--gray-800); margin-bottom: 8px; }
.block-callout p:last-child { margin-bottom: 0; }
.block-callout strong { color: var(--black); }
.block-impact {
background: linear-gradient(135deg, #fef2f2 0%, #fff5f5 100%);
border: 1px solid #fecaca;
border-radius: var(--radius-lg);
padding: 18px 24px;
margin: 16px 0;
cursor: pointer;
transition: box-shadow 0.2s;
}
.block-impact:hover { box-shadow: var(--shadow-sm); }
.block-impact .impact-trigger {
font-weight: 700; font-size: 14px;
display: flex; align-items: center; gap: 8px;
color: #991b1b;
user-select: none;
}
.block-impact .impact-trigger::before {
content: ""; display: inline-block; width: 8px; height: 8px;
border-radius: 50%; background: var(--coral); flex-shrink: 0;
}
.block-impact .impact-toggle {
margin-left: auto; font-size: 12px; color: #f87171;
transition: transform 0.2s;
}
.block-impact.open .impact-toggle { transform: rotate(90deg); }
.block-impact .impact-items {
max-height: 0; overflow: hidden;
transition: max-height 0.35s ease;
}
.block-impact.open .impact-items { max-height: 500px; margin-top: 14px; }
.block-impact ul { list-style: none; padding: 0; margin: 0; }
.block-impact li {
padding: 8px 0 8px 28px; position: relative;
font-size: 13px; color: #7f1d1d;
}
.block-impact li::before {
content: ""; position: absolute; left: 0; top: 16px;
width: 16px; height: 1px;
background: #fca5a5;
}
.block-impact li::after {
content: ""; position: absolute; left: 0; top: 12px;
width: 6px; height: 6px; border-radius: 50%;
border: 2px solid #f87171; background: var(--white);
}
.block-impact li:last-child::before { display: none; }
.block-tabs { margin: 24px 0; }
.tab-bar {
display: flex; gap: 0; margin-bottom: 0;
background: var(--gray-100);
border-radius: var(--radius) var(--radius) 0 0;
padding: 4px 4px 0;
}
.tab-bar button {
background: none; border: none; padding: 10px 20px;
font-size: 13px; font-weight: 600; color: var(--gray-500);
cursor: pointer; border-radius: var(--radius) var(--radius) 0 0;
font-family: var(--font-sans); transition: all 0.2s;
}
.tab-bar button.active { color: var(--black); background: var(--white); }
.tab-bar button:hover:not(.active) { color: var(--gray-700); background: var(--gray-50); }
.tab-panel {
display: none; padding: 22px 26px; background: var(--white);
border: 1px solid var(--gray-200); border-top: none;
border-radius: 0 0 var(--radius) var(--radius); font-size: 14px;
}
.tab-panel.active { display: block; }
.tab-panel p:last-child { margin-bottom: 0; }
/* --- Metric Cards --- */
.metric-row { display: flex; gap: 12px; margin: 24px 0; }
.metric-card {
flex: 1; border: 1px solid var(--gray-200); border-radius: var(--radius);
padding: 20px 18px; background: var(--white); text-align: center;
box-shadow: var(--shadow-sm);
transition: transform 0.2s, box-shadow 0.2s;
}
.metric-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.metric-value { font-size: 32px; font-weight: 800; letter-spacing: -0.02em; line-height: 1; margin-bottom: 6px; color: var(--orange); }
.metric-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--gray-400); }
/* --- Inline tooltip --- */
.tip-word {
border-bottom: 1px dashed var(--blue);
color: var(--blue);
cursor: help;
position: relative;
}
.tip-word::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--gray-900);
color: var(--gray-100);
padding: 8px 14px;
border-radius: var(--radius);
font-size: 12px;
font-weight: 400;
white-space: normal;
word-break: normal;
overflow-wrap: break-word;
width: 260px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
z-index: 200;
line-height: 1.6;
text-align: left;
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
}
.tip-word:hover::after { opacity: 1; }
/* --- Concept cross-link --- */
.concept-link {
color: var(--black);
font-weight: 600;
border-bottom: 1px solid var(--gray-400);
text-decoration: none;
transition: border-color 0.15s;
}
.concept-link:hover { border-bottom-color: var(--orange); color: var(--orange); }
/* --- Concept Popover --- */
#concept-popover {
position: fixed;
transform: translateX(-50%) translateY(-100%);
background: var(--gray-900);
color: var(--gray-100);
padding: 12px 16px;
border-radius: var(--radius);
max-width: 320px;
min-width: 200px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
z-index: 300;
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
line-height: 1.5;
}
#concept-popover.visible { opacity: 1; }
.cpop-name { font-weight: 600; font-size: 13px; margin-bottom: 4px; color: var(--white); }
.cpop-desc { font-size: 12px; color: var(--gray-300); line-height: 1.5; }
/* --- Demo Block --- */
.block-demo {
border: 1px solid var(--gray-200);
border-radius: var(--radius-lg);
overflow: hidden;
margin: 24px 0;
background: var(--white);
box-shadow: var(--shadow-sm);
}
.demo-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--gray-300);
padding: 8px 16px 0;
}
.demo-frame {
width: 100%;
min-height: 60px;
border: none;
display: block;
background: transparent;
}
/* --- Animations --- */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.concept-card { animation: fadeInUp 0.35s ease-out both; }
.diagram-outer { animation: fadeInUp 0.4s ease-out both; }
.block-callout { animation: fadeInUp 0.4s ease-out 0.05s both; }
.block-impact { animation: fadeInUp 0.4s ease-out 0.05s both; }
/* Mobile nav toggle */
.mobile-nav-toggle {
display: none;
position: fixed;
bottom: 24px;
right: 24px;
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--gray-900);
color: var(--white);
border: none;
font-size: 20px;
cursor: pointer;
z-index: 100;
box-shadow: var(--shadow-lg);
align-items: center;
justify-content: center;
}
/* Nav search */
.nav-search {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--gray-200);
border-radius: 6px;
font-size: 13px;
font-family: var(--font-sans);
background: var(--gray-50);
color: var(--black);
margin-bottom: 16px;
outline: none;
transition: border-color 0.15s;
}
.nav-search:focus { border-color: var(--gray-400); }
.nav-search::placeholder { color: var(--gray-400); }
nav a.search-dimmed { opacity: 0.25; }
/* Concept back link */
.concept-back-link {
display: block;
text-align: right;
font-size: 12px;
color: var(--gray-400);
text-decoration: none;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--gray-150);
}
.concept-back-link:hover { color: var(--orange); }
/* --- Responsive --- */
@media (max-width: 900px) {
.mobile-nav-toggle { display: flex; }
nav { display: none; }
nav.nav-open {
display: block;
position: fixed;
top: 0; left: 0;
width: 280px; height: 100vh;
z-index: 200;
box-shadow: var(--shadow-lg);
}
.nav-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.3);
z-index: 150;
}
.nav-overlay.visible { display: block; }
main { margin-left: 0; max-width: 100%; padding: 24px 20px 80px; }
.metric-row { flex-wrap: wrap; }
.metric-card { min-width: calc(50% - 8px); }
}
@media print {
nav { display: none; }
main { margin-left: 0; max-width: 100%; }
.block-tooltip .tooltip-body { max-height: none; }
.diagram-outer-label { display: none; }
.concept-card { box-shadow: none; break-inside: avoid; }
}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Panova Report</title>
<style>__STYLES__</style>
</head>
<body>
<div class="page">
<nav id="nav">
<div class="nav-title">Panova</div>
<div id="nav-links"></div>
<div class="nav-footer">made by <span>oil-oil</span></div>
</nav>
<main id="content"></main>
</div>
<script src="https://cdn.jsdelivr.net/npm/markdown-it@14.1.0/dist/markdown-it.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script>__SCRIPTS__</script>
</body>
</html>