关于 docs-site-macos
docs-site-macos 是用于生成 macOS 风格文档站点的技能,面向需要展示指南、教程或产品文档的场景。它生成零外部请求的自包含 HTML,并要求通过浏览器审计后再交付。
主要功能
- 01生成单文件或多文件 HTML 文档站
- 02提供侧边栏、思维导图和主题切换
- 03支持可拖动 SVG 图表与交互原型
- 04启动本地预览服务并执行 Playwright 审计
- 05输出审计报告和 Wiki 草稿
适用场景
- 01将 Markdown 内容制作成文档站点
- 02为产品功能或作品集创建展示页
- 03按主题将 Wiki 拆分为多个 HTML 页面
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ rheinmir/ setup --skill docs-site-macos安装后在 Claude Code 中输入/docs-site-macos确认已加载。
附带文件vendor/README.md
详细介绍
macOS 文档站点构建器
构建一个采用 macOS 风格设计系统的单文件 HTML 文档站点。
输出为独立的 .html 文件(无 JS 库,无需构建步骤)。
内容
目的与背景
- 目的: 构建一个自包含的 HTML 文档站点(单个文件,或 index + N 个页面),采用 macOS liquid-glass 设计系统——玻璃侧边栏、思维导图、可拖放的动态 SVG 图表、浅色/深色主题——写入
llmwiki/html/,自行托管以供预览,并且只有在 Playwright 审计通过后才报告“完成”。 - 触发条件(何时使用):
- 用户需要文档站点、落地页、展示页、作品集、指南、教程站点、功能概览或产品文档——尤其是提到“简洁”“现代”“Apple 风格”“macOS 风格”“玻璃”“磨砂”“动态图表”“单个 HTML 文件”时。
- 用户要求为文档/展示页面添加“liquid glass”“磨砂玻璃”或“半透明 UI”。
- 用户有多个 Markdown 文件,希望将其渲染为带分节的可视化 HTML 页面。
- 用户提到“6 个文件”/希望每个主题各有单独页面 → 多文件模式(每个 wiki 文件对应一个 HTML +
DDMMYY-index.html),不要合并成一个页面。
- 非目标: 不要自行重写 CSS/JS 主题切换(唯一来源是
dark-mode-maker技能);不要将 archify 引擎生成的图表 SVG 复制到外部(使用<iframe>嵌入);不要加载外部 CDN/Web 字体/脚本;不要将文件写入llmwiki/html/以外的位置;不要保留/提交脚本或审计截图。
心智模型
源内容(markdown · wiki · 描述)→ N 个分节(#sec-{i},accent i % 6)→ 页面架构(skip-link · nav sidebar · main · hero · mind map · section · footer)→ 自包含 HTML llmwiki/html/DDMMYY-<slug>.html → 自动托管 :8765 → Playwright 审计 → 输出报告草稿。
输入与输出约定
| 字段 | 必填? | 含义 | |
|---|---|---|---|
| 输入 | 内容 | 是 | Markdown 文件 / wiki 页面 / 主题描述——每个主题对应一个分节 |
| 输入 | 页面模式 | 否(默认为单个文件) | “6 个文件”/每个主题单独成页 → 多文件模式 |
| 输入 | 现有 archify 图表 | 否 | 由 /diagram → archify 生成的 viewer 页面 *.html,使用 <iframe> 嵌入 |
| 输入 | 交互式原型需求 | 否 | “创建可交互的示例表格” → 交互式原型 / 可编辑数据网格 |
| 输出 | HTML 页面 | 是 | llmwiki/html/DDMMYY-<slug>.html(多文件:DDMMYY-index.html + DDMMYY-<slug>.html),0 个外部请求 |
| 输出 | 预览链接 | 是 | http://localhost:8765/llmwiki/html/DDMMYY-<file>.html |
| 输出 | 审计结果 | 是 | Playwright 脚本返回的 AUDIT PASS——“完成”意味着审计通过 |
| 输出 | 输出报告 | 是(无产物时除外) | llmwiki/wiki/sources/draft/DDMMYY-<ten>.md + 索引行 + 日志 |
规则与能力
- RULE-01 (MUST):**自包含 — 严重:**输出的 HTML 绝不发起外部请求:不使用字体/CSS/JS CDN 链接、远程图像、
@import或<script src>(允许使用指向外部的超链接<a href>)。 - RULE-02 (MUST):**输出路径 — 严重:**始终将 HTML 文件写入当前项目根目录下的
llmwiki/html/;文件名必须以当天日期DDMMYY-为前缀;绝不写入项目根目录或其他目录。 - RULE-03 (MUST):导航 — 仅限侧边栏(绝不使用顶部栏),侧边栏必须有真实的玻璃效果,并包含
.nav-toggle/.nav-close。 - RULE-04 (MUST):默认生成思维导图 — 始终绘制一份可折叠思维导图,内容取自文档本身。
- RULE-05 (MUST):必须提供亮/暗主题切换,不得强制使用某一种模式——加载 skill
dark-mode-maker,切换按钮放在<nav>中。 - RULE-06 (MUST):必须满足无障碍要求并设置文档头部——设置 viewport/meta/favicon inline、
:focus-visible焦点环、跳转链接 +<main id="main">、全局 reduced-motion,以及 SVG 文本替代内容。 - RULE-07 (MUST):所有
.diagram-box都必须支持节点拖动;所有pre.code-block都必须有复制按钮;所有交互控件都必须有水波纹效果。 - RULE-08 (MUST):自动托管——写入 HTML 文件后,始终启动本地 HTTP 服务。
- RULE-09 (MUST):交付前必须进行 Playwright 审查;若 FAIL,先修复再重新审查,不得交付红色页面。
- RULE-10 (MUST):针对 13″ 屏幕采用紧凑字号——字号要调小,不能调大。
- RULE-11 (MUST):archify 图表通过
<iframe>嵌入(框架贴合内容 + 链接 "Mở sơ đồ riêng ↗"),meta.visual_preset留空(规则 R20)。 - RULE-12 (MUST):禁止渐变文字,禁止单侧条纹边框——
background-clip:text(渐变填充文字)和卡片/按钮/提示框上的border-left/right: ≥3px solid <màu>是会被静态检查硬性拦截的两种 AI 生成内容标志。使用字重或纯色突出文字;通过彩色圆点、标签或整张卡片的浅色背景区分提示框(边框须四边一致)。 - RULE-13 (MUST):交付前运行两道检查——
python3 fdk/tools/frontend-antipattern.py <trang>(静态检查)和NODE_PATH=$(npm root -g) node fdk/tools/html-visual-gate.mjs <trang>(实际运行检查:文字对比度 < 4,5:1、区块间距 < 8px、图标遮挡文字、切换按钮、两种模式下的玻璃效果)。若检查未通过,先修复再重新运行;可通过python3 fdk/tools/html-slop-fix.py <trang>自动修复机器可处理的问题。检查仍未通过时不得报告完成。 - 能力:读取源内容;将 HTML 文件写入项目的输出目录;运行本地 HTTP 服务;控制无头浏览器以测量 DOM、控制台和截图;写入 wiki 的 draft、索引和日志。
失败边界
- 没有内容/无法确定哪些主题应成为 section → 向用户澄清,暂不生成页面。
- Playwright 审查 FAIL(控制台报错、缺少
.nav-toggle/.nav-close、主题切换按钮不在.theme-row中)→ 阻止交付:修复后重新审查;页面检查未通过时不得报告完成。 - 尚未安装
@playwright/test→ 按照/playwright-verify安装后再审查;不得跳过审查。 - 端口 8765 已被占用 → 视为服务正在运行,跳过启动步骤(不算错误)。
- 通过
file://打开 archify iframe 时无法测量高度 → 可接受部分完成:框架保持 1000px,并以链接 "Mở sơ đồ riêng ↗" 作为备用方案;若要精确贴合,请通过 Auto-Host 打开。 - 静态检查或实际运行检查仍未通过 → 阻止交付:对机器可修复的问题运行
html-slop-fix.py,其余问题手动修复,然后重新运行两项检查;不得交付仍有检查项未通过的页面。 - 浏览器没有
DecompressionStream→ Mermaid 引擎无法渲染(__bmReadyreject)——参见 Mermaid Diagram Engine 一节。
HOW
默认字体 — 嵌入两种字体(MUST,用户于 20/09/2026 确认 · 21/09 修改内容 · 22/09/2026 修改标题)
标题使用 Newsreader 600(报纸风格衬线字体,--font-display、--fw-heading:600、--ls-heading:-.01em;一个静态字体文件,裁切为 wght 600 / opsz 24——用户在同一份越南语样例上比较六种字体后选定)。正文使用 Be Vietnam Pro:400,粗体 600;三个静态字体文件 400/600/800,请求 500 时使用 400,请求 700 时使用 800——保持真实字重;图表/图形中的文字(svg · .diagram-box · .mm · .graph)使用 Lexend Deca:默认 Light,粗体 = Regular(用户于 22/09/2026 确认——两个静态字体文件按字重范围划分,--font-chart);代码的 --font-mono 保持不变。字体以 base64 嵌入页面本身(约 175 KB),这样离线打开 file:// 时仍能正确显示字体——不要使用 Google Fonts 的 <link>,也不要手动粘贴 base64 字符串。你只需完成以下两步:
- 在页面的 CSS 中设置:
body{font-family:var(--font-text);font-weight:var(--fw-text)};标题和页面名称使用font-family:var(--font-display);font-weight:var(--fw-heading)(不要单独声明系统字体栈,也不要写死 800)。 - 写完文件后,运行一条命令(幂等,打印
✓ … nhúng Be Vietnam Pro):
python3 fdk/tools/html_font.py --apply <trang.html> [trang-khác.html …] # máy khách: python3 ~/.claude/harness/fdk/tools/html_font.py --apply …
未执行第 2 步 = 页面回退到系统字体 → 尚未完成。快速检查:grep -c 'id="ovs-font"' <trang.html> 必须返回 1。
第 2 步还会自动添加页面框架(PLAN 220926),适用于带有侧边栏 .logo 和 ≥4 个锚点 #… 的页面:为所有尚未包含 .ic 的 nav a 添加图标磁贴(根据条目名称关键词选择图标,将序号“01 ·”放入 title),活动项 = 背景胶囊 + 彩色圆点、阅读进度条、跳过链接、<main id="main">、内联 favicon、滚动间谍、波纹效果,以及根据 h2/h3 生成的思维导图(页面尚无 .mm 时);为 .diagram-box 添加拖放 JS。来源:fdk/tools/html_shell.py;思维导图和拖放的 CSS/JS 是此技能的原文(html_shell.py --sync)。只有 .nav-toggle/.nav-close 仍需手动创建。R20 规则会拦截缺少框架的页面,并提供准确的 --apply 命令。
间距与字级系统(MUST,PLAN 220926-spacing-system — 来源 fdk/wiki/sources/220926-spacing-standards.md)
- 唯一的刻度用于 padding/margin/gap:2 · 4 · 8 · 12 · 16 · 20 · 24 · 32 · 40 · 48 · 64 · 80 · 96 px(IBM Carbon + Tailwind),使用基础层的 token
--sp-1…--sp-11。不使用 5、6、9、10、13、14px。html_font.py --apply会自动将偏离的值调整到最近的档位;静态检查spacing-off-scale会拦截未经过此步骤的页面。 - 行高: 正文为 1,55–1,6(
--lh-body),所有段落/列表项统一使用一个值;标题为 1,1–1,3(--lh-heading)。多行段落低于 1,5 属于错误(line-height-body)。 - 行长: 段落文字 ≤ 80 个字符(WCAG 1.4.8),目标为
max-width:var(--measure)= 34em(Baymard;实际约 68 个字符——70ch约为 89 个字符,因为ch是数字“0”的宽度,比平均字符更宽)。 - 邻近性: 标题上方的间距 ≥ 下方间距的 1,5 倍(USWDS)——标题应归属于其后的文字部分。测量过的优秀范例:上方 26px / 下方 12px。
- 侧边栏标签层级: 页面名称 · 分组标签 · 条目必须在 4 项属性(字号、粗细、颜色、大小写)中至少有 2 项不同——规则
hierarchy-flat。导航条目使用 13–14px、粗细 600、主文字颜色;分组标签使用小号大写字母并增加字间距。 - 点击区域 ≥ 24×24px(WCAG 2.5.8)。
- 标题大小层级由大到小(MUST):h1 > h2 > h3 > h4(基础字体层默认值:32 · 24 · 20 · 17px),且不得小于正文文字;页面名称 ≥ 导航/标签条目字号的 1,2 倍(侧边栏:徽标 18px,条目 13px)——规则
heading-scale、title-scale。 - 标题、标签、按钮、标签页、导航条目必须首字母大写(MUST)(
sentence-case;基础层会在--apply时自动修正)。想保留小写的专有名称:data-case="keep"。 - 看板统一样式,卡片尺寸固定(MUST):看板上的所有卡片使用相同样式和相同宽高;标题使用
line-clamp:2,副行使用ellipsis,点击卡片打开详情——规则kanban-uniform。 - 侧边栏、网格、看板、列表、动效、图表等示例代码:页面
skills/hallmark/references/design-showcase.html(客户端:~/.claude/skills/hallmark/references/design-showcase.html);使用python3 fdk/tools/build-design-showcase.py --get <id>获取代码块(客户端:python3 ~/.claude/harness/fdk/tools/build-design-showcase.py --get <id>;--list会输出索引)。 - 视觉休息空间(MUST):首屏只显示摘要,点击后才显示详情(
<details>、弹窗、摘要按钮);不要将几十个标签/芯片/按钮并排放置——规则eye-rest。如因特殊要求而偏离标准 →<meta name="overstack-exempt" content="…" data-reason="…">。
主工作流
| 步骤 | 类型 | 输入 | 操作 | 输出/退出 | 失败/后续 |
|---|---|---|---|---|---|
| W01 | 判断 | 内容、用户的话 | 将内容拆分为 N 个 section(每个主题/文件一个 section);选择单文件或 Multi-File Mode(B01) | section 列表 + 模式 | 缺少内容 → 澄清 |
| W02 | 判断 | section 列表 | 按 Page Architecture 搭建框架:skip-link、nav sidebar(真实玻璃效果,.nav-toggle/.nav-close)、<main id="main">、hero、mind map、section-bg s-bg{i}、footer | HTML 框架 | — |
| W03 | 确定性 | N 个 section | 按 Design System + CSS Generator Pattern 生成 CSS(#sec-{i}、accent i % 6、.tag 的 dark-mode 变体)、overlay scrollbar、系统字体栈 | 内联 CSS | — |
| W04 | 判断 | 每个 section 的内容 | 绘制示意图:内联 SVG 可拖动节点(≤~5 个节点、线性)或 Mermaid engine(≥6 个节点/存在分支)(B02);通过 <iframe> 嵌入 archify 绘制的 artifact(B03) | .diagram-box | — |
| W05 | 确定性 | 框架 + CSS | 添加必需组件:mind map、copy button、water-ripple、collapse、scroll spy、来自 dark-mode-maker 的 theme toggle、Accessibility & Document Head | 组件齐全的页面 | — |
| W06 | 执行 | 页面 | 写入 llmwiki/html/DDMMYY-<slug>.html(如目录不存在则创建) | HTML 文件 | — |
| W07 | 执行 | HTML 文件 | Auto-Host:从 project root 运行 npx serve -p 8765 | 链接 http://localhost:8765/llmwiki/html/... | 端口占用 → 服务已在运行,继续 |
| W08 | 确定性 | 正在托管的页面 | 使用 .mjs 脚本运行 node 执行 Playwright Audit:0 条控制台错误、sidebar 往返切换、在 .theme-row 中切换主题、截取 3 种状态 | AUDIT PASS | FAIL → B04 |
| W09 | 执行 | 结果 | 向用户报告链接 + 编写 Output Report 草稿 + index + log | 草稿 + index + log | 0 个 artifact → 跳过报告 |
各步骤的详细说明(W01–W09 的事实来源):下方的 Design System、Page Architecture、Mind Map、Section-Bg Pattern、Animated SVG Diagrams、Accessibility & Document Head、Output Path、Auto-Host、Playwright Audit、Multi-File Mode 和 Output Report 条目——从迁移前的版本原样复制。
分支
| ID | 类型 | 条件 | 行为 | 跳过/失败 | 汇合 |
|---|---|---|---|---|---|
| B01 | 用户可选 | 用户说“6 个文件”/想让每个主题单独成页 | Multi-File Mode:DDMMYY-index.html(card grid)+ 每个 wiki 文件对应一个 DDMMYY-{slug}.html,共用同一套 design system,nav 指向所有页面 | 无此要求 → 单文件 | W06 |
| B02 | 条件必需 | 示意图 ≥6 个节点,或存在分支/合并 | 使用 Mermaid Diagram Engine(嵌入 gzip+base64 的 beautiful-mermaid,进行 glassmorphism 后处理)替代手动设置坐标的 SVG | ≤~5 个线性节点 → 手绘 SVG;缺少 DecompressionStream → 不渲染 | W05 |
| B03 | 条件必需 | 存在由 archify engine 绘制的示意图 | 嵌入 <iframe class="archify-embed"> + 自动调整高度 + “单独打开示意图 ↗”链接;留空 visual_preset | 打开 file:// → 画框高度为 1000px,使用链接单独打开 | W05 |
| B04 | 恢复 | Playwright audit FAIL | 修复 audit 报告的具体错误,然后重新运行 W08 | 仍有错误 → 不报告完成,继续修复 | W08 |
| B05 | 用户可选 | 用户想“看看 UI 会是什么样”/“试着做一个交互表格” | Interactive Prototype / Editable Data-Grid(vanilla JS,仍为 Self-Contained) | 无此要求 → 跳过 | W06 |
验证与停止
通过代码检查的部分:W08 中的 Playwright 脚本(console/pageerror、DOM .nav-toggle/.nav-close、nav-collapsed 往返切换、data-theme 变化 + 控件位于 .theme-row 中)——退出码为 1 即为失败。需要人工检查的部分:明亮主题/暗色主题/sidebar 收起状态的截图。audit 通过后停止;原始版本未设置固定的修复轮数上限——但只要仍有失败项,就绝不报告“完成”。脚本和 audit 截图仅用于验证,用后即丢弃,不提交。
示例
- 正向:“为
llmwiki/wiki/concepts/中的 4 个文件制作 macOS 文档站点” → 一个带侧边栏、4 个分支的思维导图、4 个 section#sec-0..3(accent 按i % 6变化)的文件llmwiki/html/190926-overstack-concepts.html,服务器:8765,Playwright 脚本输出AUDIT PASS→ 报告链接http://localhost:8765/llmwiki/html/190926-overstack-concepts.html+ 草稿报告。 - **边界/失败:**生成的页面中,主题按钮是位于右上角的
position:fixedchip,侧边栏缺少.nav-close→ audit 输出AUDIT FAIL: ['缺少 .nav-toggle/.nav-close …', '… 不在 .theme-row 中 …'],退出码为 1 → B04 修复后重新 audit;不要向用户报告页面已完成。 - **边界:**用户说“6 个文件” → B01:
DDMMYY-index.html+ 6 个页面DDMMYY-<slug>.html,不能合并为一个页面。
设计系统
配色方案
基底:白色玻璃表面,置于柔和的浅蓝渐变背景场之上(见下文“背景平面”)
文本:#0f0f12 / #4a4a55
边框:rgba(30,90,170,.14)(冷调蓝灰色——绝不能白上加白)
模式(表面、背景场、导航、hero、链接)采用液态玻璃浅蓝 + 白色。内容区块的 accent 按 Apple 次要配色循环(见下文“配色理念”)——仅用于标签、h4 和项目符号,这样颜色丰富也不会显得杂乱。不要使用纯黑 accent,也不要用高饱和度颜色作为标题。
背景平面(玻璃需要有可供采样的背景)
准确命名:这个设计系统是实用型 CSS 玻璃效果(透明度 + 背景模糊),并非真正的折射。要让模糊效果呈现出材质感,body 背景不能是纯色填充——应使用克制的单色渐变背景场:
body{
background:
radial-gradient(900px 500px at 12% -10%, rgba(10,132,255,.10), transparent 60%),
radial-gradient(700px 420px at 95% 15%, rgba(90,162,232,.08), transparent 55%),
linear-gradient(180deg, #f7fbff 0%, #eaf2fd 100%);
}
保持低调——只用蓝色系色调,不要使用刺眼的发光图层(“光污染”)。每个区块的 .s-bgN::before 叠加层会补充其余局部变化。
**基础折射平面(必须——镜面下方必须能看到内容):**平面渐变不足以让模糊效果产生“融合”感——需在所有内容下方添加 2 个 z-index:-1 的 fixed 图层:(1) ORBS——5-6 个大型径向色块(以蓝色为主 + 1-2 种 Apple 次要色调,alpha .06-.22),极慢速漂移(约 46s ease alternate,translate ≤2.5% + scale ≤1.05);(2) DOT-GRID——细小的 1px/22px 网格,alpha 约 .11,带垂直渐隐 mask,提供高频细节,让 backdrop-filter 转化为真实的玻璃纹理。至少在 viewport 左侧边缘放置 1-2 个 orb(位于侧边栏背后)——侧边栏是页面上最大的玻璃面板,背后没有颜色,无论模糊程度多高,最终都会像一块白板。添加 @media (prefers-reduced-motion:reduce){animation:none}:
body::before{content:'';position:fixed;inset:-10%;z-index:-1;pointer-events:none;
background:
radial-gradient(640px 440px at 10% 14%,rgba(10,132,255,.22),transparent 65%),
radial-gradient(380px 460px at 4% 52%,rgba(48,176,199,.18),transparent 65%),
radial-gradient(540px 400px at 88% 10%,rgba(88,86,214,.13),transparent 60%),
radial-gradient(720px 500px at 74% 76%,rgba(48,176,199,.13),transparent 65%),
radial-gradient(480px 380px at 16% 86%,rgba(255,149,0,.12),transparent 60%);
animation:orbDrift 46s ease-in-out infinite alternate}
@keyframes orbDrift{100%{transform:translate(2.2%,1.6%) scale(1.045)}}
body::after{content:'';position:fixed;inset:0;z-index:-1;pointer-events:none;
background-image:radial-gradient(rgba(30,90,170,.11) 1px,transparent 1.3px);
background-size:22px 22px;
mask-image:linear-gradient(180deg,rgba(0,0,0,.55),rgba(0,0,0,.22))}
液态玻璃表面系统(透明度阶梯 + 模糊强度)
玻璃是一套深度系统,而非单一 class。分为三个表面层级,每层都有各自的 alpha 和模糊强度——不要让所有地方重复使用相同的 alpha:
:root{
--glass-1: rgba(255,255,255,.55); /* tier 1 — chrome: nav, floating panels */
--glass-2: rgba(255,255,255,.7); /* tier 2 — cards, diagram-box, repo-card */
--glass-3: rgba(255,255,255,.88); /* tier 3 — data: tables, long text (calmer, near-solid) */
--blur-1: 24px; --blur-2: 8px; --blur-3: 4px;
--edge-hi: inset 0 1px 0 rgba(255,255,255,.85); /* top inner highlight — REQUIRED on every glass surface */
--border: rgba(30,90,170,.14);
}
层级分配:nav = 层级 1 · .card / .diagram-box / .repo-card = 层级 2 · table / 密集数据区 = 层级 3。最强的玻璃效果留给界面框架;内容密度越高,材质强度越低。
配色理念:蓝色 = 模式,Apple 次要色 = 内容
蓝色仅用于界面框架/模式——侧边栏导航、hero 渐变、链接、结构性 accent。采用现代 iOS 色调 #0a84ff,起始色绝不能是暗色/海军蓝。内容区块按 Apple 次要配色循环(青绿/靛蓝/绿色/橙色/粉色)——颜色丰富也可以,只要不显杂乱:accent 仅用于 .tag、.card h4 和 li::before 项目符号。
标题用深色,绝不使用高饱和度颜色:所有区块的 .section-header h2 { color: #1d1d1f }。高饱和度的蓝色标题显得过时(“土气”)——Apple 会用近黑色标题,并在上方放置彩色的 eyebrow 标签。
区块颜色循环(Apple 次级色)
#sec-0 .tag { background: rgba(10,132,255,.10); color: #0a84ff; } /* blue */
#sec-0 .card h4 { color: #0a84ff; } #sec-0 .card li::before { color: #0a84ff; }
#sec-0 .section-header h2 { color: #1d1d1f; } /* h2 luôn tối, mọi section */
.s-bg0::before { background: linear-gradient(180deg, rgba(10,132,255,.05) 0%, transparent 60%); }
/* then sec-1..5 per the table; repeat #sec-6 = #sec-0, ... */
完整强调色表:
| 索引 | 强调色 | h4 色调 | 渐变叠加 |
|---|---|---|---|
| 0 | #0a84ff 蓝色 | #0a84ff | rgba(10,132,255,.05) |
| 1 | #30b0c7 青绿色 | #30b0c7 | rgba(48,176,199,.05) |
| 2 | #5856d6 靛蓝色 | #5856d6 | rgba(88,86,214,.05) |
| 3 | #34c759 绿色 | #28a745 | rgba(52,199,89,.05) |
| 4 | #ff9500 橙色 | #f08c00 | rgba(255,149,0,.06) |
| 5 | #ff2d55 粉色 | #e0264b | rgba(255,45,85,.05) |
每个区块使用强调色设置:
.tag背景(不透明度为 10-12%)和文字.card h4颜色(橙色、绿色和粉色使用较深的 h4 色调,以确保文字清晰易读).card li::before颜色(›项目符号)- ⛔ 不用于
.section-header h2——h2 始终为#1d1d1f
毛玻璃卡片
.card {
background: var(--glass-2);
backdrop-filter: blur(var(--blur-2)) saturate(1.1);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--edge-hi), 0 4px 20px rgba(20,40,90,.08); /* top highlight + lower BLUE-TINTED shadow = directional light; never pure black on a blue field */
padding: 20px;
}
.diagram-box 和 .repo-card 共用这套第 2 层配方。table(以及任何密集数据区域)降至第 3 层:background: var(--glass-3); backdrop-filter: blur(var(--blur-3))——边框和边缘高光保持一致,材质更柔和,让各行内容清晰易读。
液态玻璃规则(根据 liquid-glass-design skill 提炼):
- 必须有边缘高光——缺少
--edge-hi的玻璃看起来像一张褪色的卡片。 - 定向光照:每个面板都要有微弱的顶部内侧高光和柔和的底部外侧阴影。
- 文字不能只依赖强模糊背景;所有长篇阅读内容和表格都应使用第 3 层(接近实色)作为底色。
- 叠加的玻璃至少要相差一层(透明度和模糊级别都要不同),否则各层会糊成一团。
- 光晕保持微弱,且仅使用蓝色系——深度来自层级、边缘和阴影,而不是氛围效果。
- 大型镀铬面板(侧边栏、全高面板)绝不能只使用一种纯色透明度——纯白填充看起来像一堵乳白色的墙(“纯色看起来像堵墙”)。它们需要渐变透明玻璃、
::before镜面高光,以及直接位于其后的彩色光球。标准配方见 Navigation 部分。
macOS 窗口元素
repo 卡片和转换器模型使用 macOS 窗口标题栏:
<div class="chrome">
<span style="width:12px;height:12px;border-radius:50%;background:#ff5f57"></span>
<span style="width:12px;height:12px;border-radius:50%;background:#ffbd2e"></span>
<span style="width:12px;height:12px;border-radius:50%;background:#28c840"></span>
<div class="url-bar">...</div>
</div>
⚠️ 带边框的全宽元素(repo-card、转换器模型,以及任何带边框的面板)必须有与 hero/section 相同的水平留白(2026/06/13 的经验教训——用户嫌 repo-card“紧贴屏幕边缘,左右应该有 padding”):hero/section 通过 max-width:1100px 容器内的 padding:…24px 留出边距,但仅设置 max-width:1100px;margin:auto 的带边框元素在视口窄于 1100px 时会紧贴视口两侧(因为它没有内部 padding,整个盒子本身就是内容)。修复方法:让盒子与 hero 内容的左右边缘对齐,使用 max-width:1052px(= 1100 − 48 gutter),并且设置 width:calc(100% - 48px):
.repo-card{ max-width:1052px; width:calc(100% - 48px); margin:8px auto 0; /* …glass tier-2… */ }
宽屏时 → 最大宽度为 1052px 并居中,与 hero 内文字对齐(1100−24−24);窄屏时 → 两侧始终各留出 24px。适用于所有直接放在 <body> 下方(不在 section 流程中)的带边框元素:repo-card、转换器模型、全宽横幅/提示框。
⚠️ 带边框元素的底部内容需要比顶部有更大的 padding(2026/06/13 的经验教训——用户嫌“过渡处被截断,不够平滑”):最后一个内容块(例如 .rc-body)直接连接到下一个 section;如果底部 padding 与顶部相同(16px),文字就会贴近盒子边缘,看起来像被截断。底部应留出更多空间:padding:16px 18px 22px(底部至少比顶部多 6px)。规则:以文字结尾的面板 → 底部 padding ≥ 顶部 padding。
导航 — 仅限侧边栏(绝不使用顶部栏)
守卫(R20,PostToolUse hook):*/html/*.html 页面若包含超过 3 个条目(<section id> 或 <h2>),却没有包含至少 3 个 #anchor 链接的 <nav>,就会被拦截,无论页面由哪个 skill 生成。若页面有意采用单列布局,则声明 <meta name="overstack-nav" content="none"> 并附上原因。
所有屏幕尺寸都使用左侧边栏 + 折叠按钮。⛔ 不得使用顶部栏模式——顶部栏中的链接会挤成难看的多行,在窄屏上尤其如此。窄屏(<640px):侧边栏以覆盖层形式盖在内容上(body 保持 padding-left:0),默认收起,用户通过切换按钮打开:
⚠️ 侧边栏必须是真玻璃,不能是乳白色面板(2026/06/12 的经验——用户嫌“纯色看起来有点挡”):在浅色背景上,平铺的 --glass-1、alpha 为 .55 会显得像不透明的“牛奶”,不像镜面。大型面板 chrome(侧边栏、全屏高面板)必须包含 3 项:(1) 渐变 alpha 玻璃——沿玻璃表面纵向变化的 alpha,而不是固定值;(2) 镜面光泽 ::before——角落的 radial 高光 + 斜向光泽带;(3) 面板背后紧贴着彩色 orb(见 Background Plane)——24px 的模糊必须能模糊出真实颜色。--glass-1 仅用于小型浮动面板:
:root{--nav-pad-y:18px} /* nguồn chân lý duy nhất cho khoảng đệm dọc của nav — .theme-row (§Theme Toggle) đọc lại biến này, KHÔNG hard-code số riêng, để 2 chỗ không thể lệch nhau (bài học 200826: bug thật, xem lịch sử) */
nav{position:fixed;top:0;left:0;bottom:0;width:200px;z-index:100;
display:flex;flex-direction:column;align-items:stretch;gap:2px;padding:var(--nav-pad-y) 12px;
background:linear-gradient(165deg,rgba(255,255,255,.46) 0%,rgba(255,255,255,.22) 48%,rgba(240,248,255,.34) 100%);
backdrop-filter:blur(var(--blur-1)) saturate(1.7) brightness(1.04);
-webkit-backdrop-filter:blur(var(--blur-1)) saturate(1.7) brightness(1.04);
border-right:1px solid rgba(255,255,255,.55);
box-shadow:inset 0 1px 0 rgba(255,255,255,.9),inset 1px 0 0 rgba(255,255,255,.5),
inset -1px 0 0 rgba(30,90,170,.10),4px 0 24px rgba(30,90,170,.08)}
/* specular sheen — vệt sáng chéo trên mặt kính; con của nav cần position:relative để nổi trên sheen */
nav::before{content:'';position:absolute;inset:0;pointer-events:none;
background:
radial-gradient(220px 160px at 18% 4%,rgba(255,255,255,.55),transparent 70%),
linear-gradient(115deg,rgba(255,255,255,.28) 0%,transparent 28%,transparent 72%,rgba(255,255,255,.14) 100%)}
nav>*{position:relative}
nav .logo{margin:0 0 12px;padding:8px 12px;
background:linear-gradient(135deg,#0a84ff,#64b5f7);-webkit-background-clip:text;background-clip:text;color:transparent}
nav a{padding:8px 12px;border-radius:10px;font-size:13px;position:relative;overflow:hidden}
nav a.active{color:#0a84ff;background:rgba(10,132,255,.08);font-weight:600}
body{padding-left:192px}
@media(max-width:640px){
body{padding-left:0} /* sidebar overlay, không chiếm column */
nav{box-shadow:0 8px 30px rgba(0,0,0,.14)} /* nổi trên content khi mở */
}
涟漪效果(侧边栏按钮 + 切换按钮上必须实现): 点击时,圆形从点击的精确坐标向外扩散,逐渐覆盖整个按钮(半径 = 到 4 个角中最远一角的距离),然后淡出。父元素必须有 position(relative/fixed/absolute 均可容纳 ink)+ overflow:hidden。⚠️ 不要编写通用规则强制 .nav-toggle 使用 position:relative——这会覆盖 position:fixed(specificity 相同,后面的规则优先),导致按钮掉到页面底部:
涟漪 ink 是液态玻璃,不是平面色痕:镜面高光偏离中心(circle at 35% 30%),半透明白色主体、浅蓝色边缘,使用 backdrop-filter:blur(2px) 让水滴自行折射下方内容,并添加内侧边缘高光:
.ripple-ink{position:absolute;border-radius:50%;pointer-events:none;
background:radial-gradient(circle at 35% 30%,rgba(255,255,255,.70) 0%,rgba(255,255,255,.28) 38%,rgba(10,132,255,.20) 72%,transparent 100%);
box-shadow:inset 0 1px 0 rgba(255,255,255,.9),inset 0 -10px 20px rgba(10,132,255,.12),0 0 14px rgba(10,132,255,.10);
backdrop-filter:blur(2px) saturate(1.25);-webkit-backdrop-filter:blur(2px) saturate(1.25);
transform:scale(0);opacity:1;animation:rippleGrow .6s cubic-bezier(.25,.46,.45,.94) forwards}
@keyframes rippleGrow{55%{transform:scale(1);opacity:.75}100%{transform:scale(1.04);opacity:0}}
(function(){
function attach(el){
el.addEventListener('pointerdown', function(e){
const r = el.getBoundingClientRect();
const x = e.clientX - r.left, y = e.clientY - r.top;
const rad = Math.hypot(Math.max(x, r.width - x), Math.max(y, r.height - y));
const ink = document.createElement('span'); ink.className = 'ripple-ink';
ink.style.width = ink.style.height = rad * 2 + 'px';
ink.style.left = (x - rad) + 'px'; ink.style.top = (y - rad) + 'px';
el.appendChild(ink);
ink.addEventListener('animationend', () => ink.remove());
});
}
document.querySelectorAll('nav a, .nav-toggle, .nav-close').forEach(attach);
})();
在创建 .nav-toggle 的脚本之后运行,这样切换按钮也会有涟漪效果。
相关内容:§浅色/深色主题切换(下方约第 952 行): .theme-row 是同一个 nav 的直接子元素,使用上方 nav CSS 中声明的共享变量 --nav-pad-y——修改 nav 的 padding 后,.theme-row 会自动跟随,无需改两处。
- 两种形式都使用 Tier-1 玻璃
- 通过 IntersectionObserver 监听
section[id]实现滚动监测(选择器nav a保持不变)
折叠(侧边栏必须实现)——两个独立按钮: 关闭按钮 ✕ 位于侧边栏内部(右上角,26×26,轻微半透明背景)——点击后 → 侧边栏 translateX(-100%),body{padding-left:0}(恢复完整列宽)。打开按钮 ☰ 为 32×32 的玻璃按钮,悬浮在左上角,仅在侧边栏关闭时显示(body:not(.nav-collapsed) .nav-toggle{opacity:0;pointer-events:none})。两个按钮都必须有液态玻璃涟漪效果。状态记忆在 localStorage('navCollapsed') 中;窄屏默认折叠(matchMedia 640px)。JS 会自动创建两个按钮——无需修改 markup:
(function(){
const nav = document.querySelector('nav'); if (!nav) return;
const btn = document.createElement('button'); btn.className = 'nav-toggle'; btn.textContent = '☰';
document.body.appendChild(btn);
const close = document.createElement('button'); close.className = 'nav-close'; close.textContent = '✕';
nav.appendChild(close);
const apply = c => { document.body.classList.toggle('nav-collapsed', c);
try { localStorage.setItem('navCollapsed', c ? '1' : '0'); } catch(e){} };
btn.addEventListener('click', () => apply(false));
close.addEventListener('click', () => apply(true));
try { const s = localStorage.getItem('navCollapsed');
if (s === '1' || (s !== '0' && matchMedia('(max-width:640px)').matches)) apply(true);
} catch(e){}
})();
nav{transition:transform .28s cubic-bezier(.4,0,.2,1)}
body{transition:padding-left .28s cubic-bezier(.4,0,.2,1)}
body.nav-collapsed nav{transform:translateX(-100%)}
body.nav-collapsed{padding-left:0}
.nav-toggle{position:fixed;top:12px;left:12px;z-index:120;width:32px;height:32px;border-radius:10px;
display:flex;align-items:center;justify-content:center;font-size:14px;color:#4a4a55;cursor:pointer;
background:linear-gradient(165deg,rgba(255,255,255,.5),rgba(255,255,255,.24));
backdrop-filter:blur(var(--blur-1)) saturate(1.7) brightness(1.04);
-webkit-backdrop-filter:blur(var(--blur-1)) saturate(1.7) brightness(1.04);
border:1px solid transparent;
box-shadow:inset 0 1px 0 rgba(255,255,255,.75),0 0 0 1px rgba(30,90,170,.08),0 2px 10px rgba(30,90,170,.12);
transition:opacity .2s,transform .28s cubic-bezier(.4,0,.2,1)}
.nav-toggle:hover{color:#0a84ff}
body:not(.nav-collapsed) .nav-toggle{opacity:0;pointer-events:none;transform:translateX(-6px)}
.nav-close{position:absolute;top:10px;right:10px;width:26px;height:26px;border-radius:8px;
display:flex;align-items:center;justify-content:center;font-size:12px;color:#4a4a55;cursor:pointer;
background:rgba(0,0,0,.04);border:none;overflow:hidden}
.nav-close:hover{color:#0a84ff;background:rgba(10,132,255,.10)}
/* .nav-toggle hiện ở mọi cỡ màn */
⚠️ 浅色背景上的悬浮玻璃 chip 不得使用纯白描边(2026/06/13 的经验——用户嫌 .nav-toggle 的描边“看起来很奇怪”):切换按钮(以及所有悬浮在左上角的玻璃 chip)位于近乎纯白蓝色的页面背景上时,backdrop-filter:blur 后方没有深色内容可供模糊,因此最显眼的反而是 rgba(255,255,255,.55) 描边——一圈锐利的 1px 线,看起来像“贴上去的贴纸边框”,不像玻璃边缘。解决方法:border:1px solid transparent,让边缘由内侧顶部高光 + 极细冷色 ring + 冷色 drop-shadow 形成:
border:1px solid transparent;
box-shadow:inset 0 1px 0 rgba(255,255,255,.75),0 0 0 1px rgba(30,90,170,.08),0 2px 10px rgba(30,90,170,.12);
规则:只有面板后方有深色/多彩内容可供模糊采样时,才适合使用纯白描边(例如侧边栏覆盖在 section 上)。浅色背景上的悬浮 chip → 用冷色 shadow 形成边缘,不用白色描边。
Scrollbar — 自动隐藏的覆盖层(主题替换默认滚动条)
将浏览器默认滚动条替换为覆盖层,完全隐藏——轨道/背景/角落/按钮全部透明,不再留有 gutter 条带。只有 胶囊形滑块(thumb) 会淡入(透明度从 0 变为蓝色调),并在滚动时或鼠标移到滚动条区域时(悬停滑块)随内容移动。适用于视口和侧边栏(nav 带有 overflow-y:auto)。滑块使用 background-clip:content-box + border:3px solid transparent,让胶囊更纤细、圆润,并在 gutter 中凸显。JS 在滚动时添加 .scrolling 类,并在空闲约 900ms 后移除:
⚠️ **2026/06/13 的教训(用户:“彻底隐藏,包括背景栏”):**只设置 track{background:transparent} 还不够——track-piece、corner、button 仍可能绘制默认背景/边框,形成一条模糊的灰色条带。必须强制将所有非滑块部分设为 background:transparent;border:0;box-shadow:none。默认滑块的透明度也设为 0(不可见);仅在 .scrolling / :hover 时通过 background-color 淡入(过渡效果平滑如同 opacity)。不要直接对滚动条伪元素使用 opacity(webkit 支持不稳定)——使用 background-color 的透明度。
⚠️ 滚动条透明后,gutter 会露出 CANVAS,因此必须给 html 设置背景:width:11px 的 gutter 仍然占据空间;轨道和滑块透明,所以它会露出后方的 canvas。渐变背景通常设在 body 上(已扣除滚动条)→ 不会覆盖 gutter → html 的 canvas 默认为白色 → 右侧边缘会出现贯穿全高的白色竖条。修复方法:给 html 设置与 field 相配的蓝色调背景(html{background:#e9f0fb})。
/* Chromium / Safari */
::-webkit-scrollbar{width:11px;height:11px;background:transparent}
::-webkit-scrollbar-track,
::-webkit-scrollbar-track-piece,
::-webkit-scrollbar-corner,
::-webkit-scrollbar-button{background:transparent;border:0;box-shadow:none}
::-webkit-scrollbar-thumb{background:transparent;border-radius:8px;
border:3px solid transparent;background-clip:content-box;transition:background-color .25s ease}
/* hiện pill khi đang cuộn (JS toggle .scrolling) HOẶC khi trỏ vào dải scrollbar (hover thumb) */
html.scrolling::-webkit-scrollbar-thumb,
nav.scrolling::-webkit-scrollbar-thumb,
::-webkit-scrollbar-thumb:hover{background-color:rgba(10,132,255,.32)}
::-webkit-scrollbar-thumb:active{background-color:rgba(10,132,255,.6)}
/* set nền html để gutter trong suốt không lộ canvas trắng */
html{background:#e9f0fb;scrollbar-width:thin;scrollbar-color:transparent transparent}
html.scrolling{scrollbar-color:rgba(10,132,255,.32) transparent}
/* hiện khi cuộn, ẩn sau ~900ms idle — gắn cho cả trang lẫn sidebar */
(function(){
const flash = (el) => { let t; return () => { el.classList.add('scrolling'); clearTimeout(t);
t = setTimeout(() => el.classList.remove('scrolling'), 900); }; };
const root = document.documentElement;
window.addEventListener('scroll', flash(root), { passive:true });
const nav = document.querySelector('nav');
if (nav) nav.addEventListener('scroll', flash(nav), { passive:true });
})();
注意:蓝色调 #0a84ff 与 pattern 相配;滑块会随 hover→active 逐渐加深。Firefox 无法完全隐藏滚动条(会保留宽度),因此回退方案是使用会变色的 thin 滚动条——可以接受。
页面架构
<a class="skip-link"> — first focusable, jumps to #main (see Accessibility section)
<nav> — fixed LEFT sidebar with section links (never a top bar)
<main id="main"> — wraps everything below for the landmark + skip-link target
<hero> — gradient title + subtitle
<div class="mm"> — MẶC ĐỊNH: một mind map collapsible (xem § Mind Map) — luôn vẽ
<repo-card> — optional: link to source repo with chrome + collapse
<section id="sec-{i}"> — repeat for each topic (i = 0..N-1):
class="section-bg s-bg{i}"
.section-header — .tag badge ("0{i+1} · Title") + h2 + p
.diagram-box — animated SVG (see below)
.content-grid — 2-column cards (Workflow + Use cases)
.table-wrap — optional comparison table
footer link — "Chi tiết: file.md"
<footer>
思维导图(默认——始终绘制一份)
所有 docs site 必须附带一份 NotebookLM 风格的可折叠思维导图,用于概括页面结构(各个 section/实体/主要条目)——放在 hero 后面,或放在“概览”section 中。特点:按分支着色的弯曲连接线(贝塞尔曲线) + 玻璃质感节点 chip + chevron;默认折叠,点击打开/关闭。自包含(使用纯 CSS + JS 绘制 SVG,不使用库)。内容由文档本身生成(每个 section = 一个分支,子条目 = 叶节点),将叶节点描述截短至 ≤~54 个字符以保持简洁。分支过长(超过约 8 个叶节点就已成问题)→ 不要保持扁平结构,应按功能继续拆分为子分支(root → 分支 → 分组 → 叶节点):每个分组都是嵌套的 .cat has-children 节点,带有独立的 .g0..g5 颜色类(JS colorOf 返回相应的曲线颜色);尽量让每组不超过约 8 个叶节点。过度堆叠的示例:拆分整个 dev-loop(12 → 修代码/搭建 onboard/eval)、orchestrate(10 → 协调/评估/运维)、utils(37 → 6 组)——参见 fdk/tools/build-overstack-docs.py(LOOP_GROUPS)。
结构(水平树:root → .cat 分支 → .leaf 叶节点;包含 .mm-canvas + <svg class="mm-links"> 用于绘制曲线):
<div class="mm"><div class="mm-canvas"><svg class="mm-links"></svg><div class="tree"><div class="row">
<div class="node root has-children"><span class="nm">Chủ đề</span><span class="ds">phụ đề</span><span class="ct">N</span></div>
<div class="children">
<div class="row">
<div class="node b-0 cat has-children"><span class="nm">Nhánh</span><span class="ds">mô tả</span><span class="ct">3</span></div>
<div class="children">
<div class="row"><div class="node b-0 leaf"><span class="nm">mục</span><span class="ds">mô tả ngắn</span></div></div>
</div>
</div>
</div>
</div></div></div></div>
CSS(位于 <style> 中;--ink2/--border 来自设计系统;.b-0..b-4 为 .nm+.ct+border 循环使用 Apple 次级色——JS 为曲线使用相同颜色):
.mm{overflow-x:auto;padding:16px 4px 8px}
.mm-canvas{position:relative;width:max-content}
.mm-links{position:absolute;top:0;left:0;pointer-events:none;overflow:visible;z-index:0}
.mm-links path{fill:none;stroke-width:2.2;opacity:.55;stroke-linecap:round}
.mm .tree{position:relative;z-index:1}
.mm .tree,.mm .children{display:flex;flex-direction:column;gap:8px;justify-content:center}
.mm .row{display:flex;align-items:center;gap:48px;position:relative}
.mm .children{position:relative}.mm .children.collapsed{display:none}
.mm .node{position:relative;display:inline-flex;flex-direction:column;gap:1px;padding:8px 12px;border-radius:13px;cursor:default;white-space:nowrap;background:rgba(255,255,255,.72);backdrop-filter:blur(7px) saturate(1.1);border:1px solid var(--border);box-shadow:inset 0 1px 0 rgba(255,255,255,.85),0 3px 14px rgba(20,40,90,.07);transition:transform .12s}
.mm .node.has-children{cursor:pointer}.mm .node:hover{transform:translateY(-1px)}
.mm .node .nm{font-size:13px;font-weight:700;letter-spacing:-.01em}.mm .node .ds{font-size:10.5px;color:var(--ink2)}
.mm .node .ct{font-size:10px;color:#fff;font-weight:700;padding:1px 8px;border-radius:999px;position:absolute;top:-8px;right:-8px;background:#0058d0}
.mm .node.has-children::after{content:'';position:absolute;right:-7px;top:50%;width:6px;height:6px;border-right:2px solid var(--ink2);border-bottom:2px solid var(--ink2);transform:translateY(-50%) rotate(-45deg);opacity:.5}
.mm .node.collapsed-parent::after{transform:translateY(-50%) rotate(45deg)}
.mm .node.root{background:linear-gradient(135deg,rgba(10,132,255,.16),rgba(88,86,214,.14));border-color:rgba(10,132,255,.4)}
html[data-theme=dark] .mm .node{background:rgba(30,42,64,.72);border-color:rgba(120,160,220,.28);box-shadow:none}
html[data-theme=dark] .mm .node .nm{color:#e6e9f0}
@media (prefers-color-scheme:dark){html:not([data-theme=light]) .mm .node{background:rgba(30,42,64,.72);border-color:rgba(120,160,220,.28);box-shadow:none}html:not([data-theme=light]) .mm .node .nm{color:#e6e9f0}}
Playwright 于 2026/09/22 测量:旧版 .ct 在 #0a84ff 上为白色时,10px 下对比度为 3.67:1,且节点没有深色变体(文字在深色背景上的对比度为 1.56:1)→ 现在使用 #0058d0 + 上方的深色块。
(不再使用直线连接器 .row::before / .children::before——连接线由 JS 绘制为 <svg class="mm-links"> 中的贝塞尔曲线。)
JS(绘制按分支着色的贝塞尔曲线 + .cat 分支默认折叠 + 点击切换 + 重绘——位于 <script> 中):
(function(){var mm=document.querySelector('.mm');if(!mm)return;var NS='http://www.w3.org/2000/svg';
function colorOf(n){return n.classList.contains('b-0')?'#30b0c7':n.classList.contains('b-1')?'#5856d6':n.classList.contains('b-2')?'#ff9500':n.classList.contains('b-3')?'#34c759':n.classList.contains('b-4')?'#ff2d55':'#9aa4b2';}
function draw(){var canvas=mm.querySelector('.mm-canvas'),svg=mm.querySelector('.mm-links');if(!canvas||!svg)return;var w=canvas.offsetWidth,h=canvas.offsetHeight;svg.setAttribute('width',w);svg.setAttribute('height',h);svg.setAttribute('viewBox','0 0 '+w+' '+h);while(svg.firstChild)svg.removeChild(svg.firstChild);var cR=canvas.getBoundingClientRect();[].slice.call(canvas.querySelectorAll('.node.has-children')).forEach(function(p){var row=p.parentElement,kids=null,ch=row.children;for(var i=0;i<ch.length;i++){if(ch[i].classList.contains('children'))kids=ch[i];}if(!kids||kids.classList.contains('collapsed'))return;var pr=p.getBoundingClientRect(),px=pr.right-cR.left,py=pr.top+pr.height/2-cR.top;[].slice.call(kids.children).forEach(function(crow){var cn=crow.querySelector(':scope > .node');if(!cn)return;var rr=cn.getBoundingClientRect(),cx=rr.left-cR.left,cy=rr.top+rr.height/2-cR.top,dx=Math.max(22,(cx-px)*0.55);var pa=document.createElementNS(NS,'path');pa.setAttribute('d','M'+px+' '+py+' C'+(px+dx)+' '+py+' '+(cx-dx)+' '+cy+' '+cx+' '+cy);pa.setAttribute('stroke',colorOf(cn));svg.appendChild(pa);});});}
[].slice.call(mm.querySelectorAll('.node.has-children')).forEach(function(n){var row=n.parentElement,kids=null,c=row.children;for(var i=0;i<c.length;i++){if(c[i].classList.contains('children'))kids=c[i];}if(!kids)return;if(n.classList.contains('cat')){kids.classList.add('collapsed');n.classList.add('collapsed-parent');}n.addEventListener('click',function(e){e.stopPropagation();var open=kids.classList.toggle('collapsed');n.classList.toggle('collapsed-parent',open);draw();});});
draw();addEventListener('load',function(){setTimeout(draw,60);});addEventListener('resize',function(){clearTimeout(window.__mmt);window.__mmt=setTimeout(draw,120);},{passive:true});})();
root(.has-children 且不带 .cat)默认展开 → 显示分支;.cat 分支默认折叠 → 点击展开叶节点;每次切换/调整大小时都会重新绘制曲线。colorOf 返回与 .b-N 类相同的颜色。实际运行版本:llmwiki/html/overstack.html 的“参照”选项卡。
Section 背景样式
每个 <section> 添加两个类:section-bg s-bgN(N = section 索引 mod 6)。
渐变叠加层是一个 ::before 伪元素:
.section-bg { position: relative; overflow: visible; }
/* FULL-BLEED: dải màu tràn hết viewport (100vw), content vẫn max-width — KHÔNG đóng khung
dải tint trong box section, nhìn như panel rời, mất liền mạch */
.section-bg::before { content: ''; position: absolute; top: 0; bottom: 0; left: 50%;
width: 100vw; transform: translateX(-50%); pointer-events: none; }
/* Generate one .s-bgN::before per section index, cycling through 6 colors */
Section 内边距:padding: 64px 24px 80px; max-width: 1100px; margin: 0 auto;
CSS 生成模式
动态生成每个分区的 CSS。对于 N 个分区,使用 #sec-{i} ID 生成 N 组规则:
For i in 0..N-1:
accent = palette[i % 6]
dark = dark[i % 6]
gradient = overlay[i % 6]
#sec-{i} .tag { background: accent at 12%; color: accent; }
#sec-{i} .card h4 { color: accent; }
#sec-{i} .card li::before { color: accent; }
#sec-{i} .section-header h2 { color: dark; }
.s-bg{i}::before { background: linear-gradient(180deg, gradient 0%, transparent 60%); }
⚠️ .tag 必须有单独的深色模式变体(这是已验证的真实 bug 160926,不是假设),而且必须同时更改文字颜色,而不只是背景颜色。 .tag{background:accent at 12%;color:accent} 在浅色背景上对比度足够,但在近黑色背景(#0c0f16)上使用同一公式,会产生几乎看不见的胶囊标签和模糊的文字(使用 Playwright 实测——通过 getComputedStyle + WCAG 对比度比率计算,不只是看截图)。两轮错误修复已有实测结果,请勿重蹈覆辙:
- **第一次错误:**只提高背景透明度(
.22)并添加边框,文字仍保留color:accent→ 实测对比度为 1.0–1.4:1(文字实际上无法辨认;截图看起来“似乎还行”,是因为眼睛注意到了胶囊标签边框,而不是模糊的文字)。 - **第二次错误:**将背景透明度提高到
.26,并将文字改为白色 → 对深色强调色(蓝色/靛蓝/粉色)测试通过,但对亮色强调色(蓝绿色/绿色/橙色)仍 不通过,只有 2.0–2.3:1 — 背景色调越浓,就越会“吞掉”白色文字所需的对比度空间,尤其是 G/R 通道值高的强调色。 - 正确做法:保持背景浅薄/深色(透明度
.12,接近实际页面背景),使用更深的边框(.55)勾勒胶囊标签轮廓,文字使用中性白色(#f2f4f8,而不是强调色),置于真正较暗的背景上——不依赖任何强调色通道。Playwright 实测确认,全部 6 种强调色的对比度均为 11–16:1:
@media (prefers-color-scheme: dark){
html:not([data-theme=light]) #sec-{i} .tag{background:accent tại 12%;border:1px solid accent tại 55%;color:#f2f4f8}
}
html[data-theme=dark] #sec-{i} .tag{background:accent tại 12%;border:1px solid accent tại 55%;color:#f2f4f8}
.card h4/li::before 不需要更改(它们在深色背景上的文字对比度仍然足够——这是经过实测的,不是假设)。如果通过脚本生成页面,请遵循与 _theme_css() 相同的“一个来源输出两个代码块”原则——参见 fdk/tools/build-overstack-docs.py 中的 accent_css()。
动画 SVG 图表
每个图表都是内联 SVG(不是外部文件),并使用 viewBox 实现响应式布局。
关键动画(在 <defs><style> 中可复用的 CSS)
@keyframes flowArrow { 0% { stroke-dashoffset: 20; } 100% { stroke-dashoffset: 0; } }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .4; } }
@keyframes float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
@keyframes glow { 0%,100% { filter: drop-shadow(0 0 4px rgba(...)); } 50% { filter: drop-shadow(0 0 12px rgba(...)); } }
@keyframes bounce { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
@keyframes blink { 0%,100% { opacity:1; } 50% { opacity:0; } }
@keyframes orbit { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
@keyframes scanLine { 0% { transform: translateX(-40px); } 100% { transform: translateX(320px); } }
@keyframes stack { 0%,100% { opacity:.15; } 50% { opacity:.7; } }
将以下类应用于 SVG 元素:.flow(虚线箭头)、.pulse(节点)、.float(输出徽标)、.glow(手机图标)、.drone(弹跳)、.blink(无人机指示灯)、.orbit-ring(旋转产品)、.cameraFlash(闪光)、.scan(扫描线)、.stackAnim(DICOM 切片)、.center-pulse(轨道核心)。
SVG 框样式
- 节点:使用
rx="6"或rx="8"的圆角矩形,设置fill="rgba(255,255,255,.7)"并使用彩色描边 - 箭头:使用带有
<marker>定义的<line>,设置marker-end="url(#arrowN)"、stroke-width="2"和.flow类 - 文字:标签设置
text-anchor="middle"、font-size="9-11"和font-weight="600" - 使用页面中的
font-family(var(--font-text)— 优先适配 macOS 的字体栈,参见字体部分) - 始终在
<svg>上包含xmlns="http://www.w3.org/2000/svg"
可拖动节点图(每个 .diagram-box 都必须实现)
每个 .diagram-box 都必须是可交互的节点图,而非静态图片或仅可平移的图片:
- 可单独拖动每个框(节点)——移动一个表格/步骤不会带动其他节点。连接线会自动重新布线以跟随节点。
- 拖动空白背景 = 平移整个画布。
- 滚轮 = 朝光标位置缩放。双击 / ⟲ 重置 = 恢复节点位置和画布。
.diagram-box可垂直调整大小(CSSresize),容器会平滑扩展。
此功能会根据编写的 SVG 自动检测,无需更改 SVG 标记。JS 会将每个 ≥ 70×30 的 <rect> 视为节点,接管坐标落在该 <rect> 内的 <text>/小型 <rect> 子元素,将它们包在 <g class="dnode"> 中,并将每个 <line> 端点绑定到最近的节点,使连接线随节点移动。坐标转换使用 getScreenCTM().inverse(),确保在缩放/平移时拖动依然准确。
CSS——用以下内容替换旧的静态 .diagram-box 规则:
.diagram-box{background:var(--glass-2);backdrop-filter:blur(var(--blur-2)) saturate(1.1);
border:1px solid var(--border);border-radius:16px;box-shadow:var(--edge-hi),0 4px 20px rgba(20,40,90,.08);
padding:24px;margin:24px 0;
position:relative;overflow:hidden;display:flex;flex-direction:column;
resize:vertical;min-height:160px;transition:box-shadow .2s ease}
.diagram-box:hover{box-shadow:var(--edge-hi),0 6px 28px rgba(20,40,90,.14)}
.diagram-viewport{position:relative;flex:1 1 auto;width:100%;overflow:hidden;cursor:grab;touch-action:none}
.diagram-viewport.grabbing{cursor:grabbing}
.diagram-viewport svg{width:100%;height:auto;display:block;overflow:visible;transform-origin:0 0;
will-change:transform;user-select:none;-webkit-user-select:none}
.dnode{cursor:move}
.dnode>rect{transition:filter .15s ease}
.dnode:hover>rect:first-of-type{filter:drop-shadow(0 3px 8px rgba(0,0,0,.18))}
.diagram-hint{position:absolute;top:8px;right:12px;z-index:5;font-size:10px;color:#4a4a55;
background:rgba(255,255,255,.75);border:1px solid rgba(0,0,0,.05);border-radius:20px;
padding:4px 12px;white-space:nowrap;opacity:0;transition:opacity .2s;pointer-events:none}
.diagram-box:hover .diagram-hint{opacity:.9}
.diagram-reset{position:absolute;bottom:8px;right:10px;z-index:5;font-size:11px;
background:rgba(255,255,255,.85);border:1px solid rgba(0,0,0,.08);border-radius:8px;
padding:4px 8px;cursor:pointer;color:#4a4a55;opacity:0;transition:opacity .2s}
.diagram-box:hover .diagram-reset{opacity:1}
.diagram-reset:hover{background:#fff;color:#0f0f12}
/* resize grip kiểu macOS: 3 vạch chéo trong tam giác góc — ẨN mặc định, hover mới hiện.
⛔ KHÔNG dùng góc L 2 cạnh đậm luôn-hiện (bài học 12/06/2026 — user chê "luôn hiện mà còn xấu").
Kèm ::-webkit-resizer{display:none} để giấu grip mặc định của browser. */
.diagram-box::after{content:'';position:absolute;right:5px;bottom:5px;width:16px;height:16px;
pointer-events:none;opacity:0;transition:opacity .25s ease;
clip-path:polygon(100% 0,100% 100%,0 100%);
background:repeating-linear-gradient(135deg,transparent 0 3.5px,rgba(10,132,255,.45) 3.5px 5px)}
.diagram-box:hover::after{opacity:.85}
.diagram-box::-webkit-resizer{display:none}
JS——添加一次,并在 DOM 解析后调用(将脚本放在 <body> 末尾):
function initDraggableDiagrams() {
const NS = 'http://www.w3.org/2000/svg';
const nearest = (nodes, x, y) => {
let best = null, bd = 1e9;
nodes.forEach(n => {
const cx = Math.max(n.x, Math.min(x, n.x + n.w)), cy = Math.max(n.y, Math.min(y, n.y + n.h));
const d = Math.hypot(x - cx, y - cy);
if (d < bd) { bd = d; best = n; }
});
return bd <= 42 ? best : null;
};
document.querySelectorAll('.diagram-box').forEach(box => {
const svg = box.querySelector('svg');
if (!svg || box.dataset.draggable) return;
box.dataset.draggable = '1';
const vp = document.createElement('div'); vp.className = 'diagram-viewport';
box.insertBefore(vp, svg); vp.appendChild(svg);
const hint = document.createElement('div'); hint.className = 'diagram-hint';
hint.textContent = '✥ kéo từng ô · kéo nền để pan · cuộn để zoom · kéo mép dưới để mở rộng';
box.appendChild(hint);
const reset = document.createElement('button'); reset.className = 'diagram-reset'; reset.textContent = '⟲ reset';
box.appendChild(reset);
// detect node rects + adopt children
const allRects = [...svg.querySelectorAll('rect')];
const nodes = allRects.filter(r => (+r.getAttribute('width')) >= 70 && (+r.getAttribute('height')) >= 30)
.map(r => ({ rect: r, x: +r.getAttribute('x'), y: +r.getAttribute('y'),
w: +r.getAttribute('width'), h: +r.getAttribute('height'), els: [r], tx: 0, ty: 0 }));
const inNode = (px, py) => nodes.find(n => px >= n.x-1 && px <= n.x+n.w+1 && py >= n.y-1 && py <= n.y+n.h+1);
[...svg.querySelectorAll('text')].forEach(t => {
const x = parseFloat(t.getAttribute('x')), y = parseFloat(t.getAttribute('y'));
if (isNaN(x) || isNaN(y)) return; const n = inNode(x, y); if (n) n.els.push(t);
});
allRects.forEach(r => { if (nodes.some(n => n.rect === r)) return;
const x = +r.getAttribute('x'), y = +r.getAttribute('y'), w = +r.getAttribute('width')||0, h = +r.getAttribute('height')||0;
const n = inNode(x+w/2, y+h/2); if (n) n.els.push(r); });
nodes.forEach(n => { const g = document.createElementNS(NS, 'g'); g.setAttribute('class', 'dnode');
n.rect.parentNode.insertBefore(g, n.rect); n.els.forEach(el => g.appendChild(el)); n.g = g; });
// bind connector lines
const links = [...svg.querySelectorAll('line')].filter(l => l.hasAttribute('x1') && l.hasAttribute('x2'));
links.forEach(l => { l._x1=+l.getAttribute('x1'); l._y1=+l.getAttribute('y1'); l._x2=+l.getAttribute('x2'); l._y2=+l.getAttribute('y2');
l._n1 = nearest(nodes, l._x1, l._y1); l._n2 = nearest(nodes, l._x2, l._y2); });
const reroute = () => links.forEach(l => {
if (l._n1) { l.setAttribute('x1', l._x1+l._n1.tx); l.setAttribute('y1', l._y1+l._n1.ty); }
if (l._n2) { l.setAttribute('x2', l._x2+l._n2.tx); l.setAttribute('y2', l._y2+l._n2.ty); } });
// auto-fit: viewBox (→ svg height → box) grows to contain dragged nodes ("sizing cùng")
const vbBase = (svg.getAttribute('viewBox') || '0 0 900 200').split(/\s+/).map(Number);
const fitViewBox = () => {
const pad = 16;
let minX = vbBase[0], minY = vbBase[1], maxX = vbBase[0]+vbBase[2], maxY = vbBase[1]+vbBase[3];
nodes.forEach(n => { minX = Math.min(minX, n.x+n.tx-pad); minY = Math.min(minY, n.y+n.ty-pad);
maxX = Math.max(maxX, n.x+n.w+n.tx+pad); maxY = Math.max(maxY, n.y+n.h+n.ty+pad); });
svg.setAttribute('viewBox', `${minX} ${minY} ${maxX-minX} ${maxY-minY}`);
};
// pan + zoom + per-node drag
let ptx=0, pty=0, scale=1;
const applyCanvas = () => { svg.style.transform = `translate(${ptx}px,${pty}px) scale(${scale})`; };
const toSvg = (cx, cy) => { const p = svg.createSVGPoint(); p.x=cx; p.y=cy; return p.matrixTransform(svg.getScreenCTM().inverse()); };
let mode=null, node=null, start=null, t0=null, p0=null;
vp.addEventListener('pointerdown', e => { vp.setPointerCapture(e.pointerId);
const g = e.target.closest && e.target.closest('g.dnode');
if (g) { mode='node'; node = nodes.find(n => n.g === g); start = toSvg(e.clientX, e.clientY); t0 = { x: node.tx, y: node.ty }; }
else { mode='pan'; p0 = { x: e.clientX-ptx, y: e.clientY-pty }; vp.classList.add('grabbing'); } });
vp.addEventListener('pointermove', e => {
if (mode==='node') { const c = toSvg(e.clientX, e.clientY); node.tx = t0.x+(c.x-start.x); node.ty = t0.y+(c.y-start.y);
node.g.setAttribute('transform', `translate(${node.tx},${node.ty})`); reroute(); }
else if (mode==='pan') { ptx = e.clientX-p0.x; pty = e.clientY-p0.y; applyCanvas(); } });
const end = () => { if (mode==='node') fitViewBox(); mode=null; node=null; vp.classList.remove('grabbing'); };
vp.addEventListener('pointerup', end); vp.addEventListener('pointercancel', end);
vp.addEventListener('wheel', e => { e.preventDefault();
const r = vp.getBoundingClientRect(), mx = e.clientX-r.left, my = e.clientY-r.top;
const ns = Math.min(4, Math.max(0.5, scale*(e.deltaY<0?1.1:0.9)));
ptx = mx-(mx-ptx)*(ns/scale); pty = my-(my-pty)*(ns/scale); scale = ns; applyCanvas(); }, { passive:false });
const doReset = () => { ptx=0; pty=0; scale=1; svg.style.transition='transform .3s cubic-bezier(.4,0,.2,1)'; applyCanvas();
setTimeout(() => { svg.style.transition=''; }, 320);
nodes.forEach(n => { n.tx=0; n.ty=0; n.g.setAttribute('transform', 'translate(0,0)'); }); reroute(); fitViewBox(); };
reset.addEventListener('click', doReset); vp.addEventListener('dblclick', doReset);
});
}
initDraggableDiagrams();
注意:
- SVG 按照以往方式编写(固定的
viewBox、扁平的<rect>/<text>/<line>)。节点分组和线条绑定会在运行时推断——请确保节点标签的x/y位于其矩形边界内,以便正确接管。 - 连接线必须使用
<line>(带有x1/y1/x2/y2)才能自动跟随。<path>连接线保持静态——任何需要跟随节点的线都应使用<line>。 - 自动适配:拖动结束时,SVG 的
viewBox会扩展以容纳被拖动的节点,因此 SVG 高度(以及容器)会随内容调整——松开后节点不会被裁切。svg{overflow:visible}也能确保拖动过程中节点保持可见。fitViewBox()会在pointerup和重置时运行。 - 支持幂等(由
dataset.draggable守卫);resize:vertical允许用户扩展容器;弹性视口会填满新增高度。
Mermaid 图表引擎(自动布局,适用于复杂图表)
上面的手动编写路径之所以可行,是因为 LLM 会手动指定每个节点的 x/y——适用于小型图表(≤~5 个节点,大多为线性结构),但图表一旦出现实际分支,坐标就会变得不均匀,因为背后没有布局算法,只有人工判断。当图表有 ≥6 个节点,或包含分支/合并时,请改用此 Mermaid 路径——也就是值得使用真正自动布局引擎的场景。低于此阈值时,继续使用手动 SVG 路径(阶梯原则:YAGNI——不要为了一个 3 框流程引入 1.5MB 的引擎)。
此方案通过 beautiful-mermaid(MIT,github.com/lukilabs/beautiful-mermaid)渲染真正的 Mermaid DSL。它是基于 ELK.js 构建的同步渲染器;相比官方 Mermaid.js,选择它是因为体积更小、不会出现异步闪烁,而且它的双色主题(bg/fg,以及可选的 line/accent/muted/surface/border)通过 <svg> 根元素上的 CSS 自定义属性实现,因此可在现有深色/浅色切换时即时重新着色,无需重新渲染。随附的 vendored bundle 已经过 gzip 压缩,位于 vendor/beautiful-mermaid.min.js.gz.b64(来源信息和准确的重建命令见 vendor/README.md)——离线构建一次;生成页面时不会运行打包器,也不会访问网络。
**为何压缩:**原始 bundle 为 1.56MB——若原样嵌入,无论生成页面包含多少张图表,都会给每个页面增加这部分体积。经 gzip 压缩并进行 base64 编码后为 625KB(实测:gzip -9 → 原始大小 468,777 字节,→ 用于内联嵌入的 base64 编码后为 625,037 字节,即原大小的 40%)。客户端通过 DecompressionStream('gzip') 解压;这是浏览器原生 API(Chrome/Edge 80+(2020 年)、Firefox 113+(2021 年)、Safari 16.4+(2023 年)——全球覆盖率约 95%),因此不会增加任何额外的库体积。Brotli 压缩后更小(原始大小为 364KB),但我们未采用:目前尚无法可靠确认各浏览器对 DecompressionStream("brotli") 的支持情况(MDN 来源说法不一)——对于必须在所有地方都能渲染的技能来说,gzip 是成熟且没有歧义的选择。
1. 组装 — 嵌入压缩后的供应商代码块 + 解压并执行的引导代码。 写入输出 HTML 文件时,将 vendor/beautiful-mermaid.min.js.gz.b64 的 base64 文本拼接到一个 JS 字符串常量中,后面接上这段引导代码(文件拼接 + 一段固定代码,不要在不同页面上重新输入不同版本):
<script>
var __BM_GZ_B64 = "PASTE vendor/beautiful-mermaid.min.js.gz.b64 CONTENTS HERE, verbatim, no line breaks inside the string";
window.__bmReady = (typeof DecompressionStream === 'undefined')
? Promise.reject(new Error('DecompressionStream unsupported'))
: (async function(){
var bin = atob(__BM_GZ_B64);
var bytes = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
var stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
var code = await new Response(stream).text();
(0, eval)(code); // indirect eval -> runs in global scope, defines window.BeautifulMermaid
})();
</script>
这会让供应商代码以异步方式加载(解压需要一个 tick)——下面的 renderMermaidDiagram 会在第一行等待 window.__bmReady,因此所有调用点都必须使用 await renderMermaidDiagram(...)(或 .then()),绝不能调用它并期待同步返回。
2. 主题令牌 — 将这些令牌添加到 §Theme Toggle sáng/tối 已要求的同一组 :root / 暗色覆盖代码块中(映射到此技能的实际配色:文本 #0f0f12/#4a4a55、边框 rgba(30,90,170,.14)、强调色 #0a84ff — 第 28-29、103 行):
使用 §Theme Toggle 中定义的 4 条规则模板(@media 内的 2 条用于“系统处于暗色模式,用户尚未选择”,外部的 2 条用于“用户明确选择暗色模式”)——如果缺少 @media 内的一对规则,系统主题为暗色时,整个页面会变暗而图表仍保持亮色。这正是第一次编写这段内容时发现的那个小问题:
:root{
--mm-bg:#f7fbff; --mm-fg:#0f0f12; --mm-line:rgba(30,90,170,.45);
--mm-accent:#0a84ff; --mm-muted:#4a4a55; --mm-surface:rgba(255,255,255,.7);
--mm-border:rgba(30,90,170,.28);
}
@media (prefers-color-scheme: dark){
html:not([data-theme=light]){
--mm-bg:#0d1a2c; --mm-fg:#e8f0fb; --mm-line:rgba(120,160,220,.4);
--mm-accent:#5aa8ff; --mm-muted:#9fb4d1; --mm-surface:rgba(30,46,72,.7);
--mm-border:rgba(120,160,220,.32);
}
}
html[data-theme=dark]{ /* CÙNG token như trong @media ở trên */
--mm-bg:#0d1a2c; --mm-fg:#e8f0fb; --mm-line:rgba(120,160,220,.4);
--mm-accent:#5aa8ff; --mm-muted:#9fb4d1; --mm-surface:rgba(30,46,72,.7);
--mm-border:rgba(120,160,220,.32);
}
3. .diagram-box/.diagram-viewport 标记保持不变,与手写 SVG 路径相同(CSS 相同,第 457-491 行)——Mermaid 路径会渲染到相同的容器中。只需增加一项:接收 innerHTML 的包装 div 必须有明确的高度,否则 SVG 的 height:100% 会静默地解析为 auto(PoC 期间遇到的真实 bug — CSS 百分比高度需要一个具有确定高度的祖先元素):
.mm-out{height:100%;display:block}
4. 渲染、主题映射和 Self-Contained 修复 — 对每个达到复杂度阈值的 .diagram-box 调用一次:
async function renderMermaidDiagram(container, dsl){
try { await window.__bmReady; }
catch (e){
// Graceful degradation (no DecompressionStream — pre-2020 Chrome/Edge, pre-2021
// Firefox, pre-16.4 Safari): explain instead of a silent blank box or a crash.
container.textContent = 'Sơ đồ này cần trình duyệt mới hơn (Chrome/Edge 80+, Firefox 113+, Safari 16.4+).';
return null;
}
function cssVar(name){ return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
var svg = BeautifulMermaid.renderMermaidSVG(dsl, {
bg: cssVar('--mm-bg'), fg: cssVar('--mm-fg'), accent: cssVar('--mm-accent'),
line: cssVar('--mm-line'), muted: cssVar('--mm-muted'),
surface: cssVar('--mm-surface'), border: cssVar('--mm-border')
});
// Self-Contained fix (CRITICAL, not optional): beautiful-mermaid hard-codes a
// Google Fonts @import into every SVG it returns, with no API to disable it.
// Strip it before inserting — font falls back to the system stack already
// declared right after 'Inter' in the same <style> block, so nothing breaks.
svg = svg.replace(/@import url\('https:\/\/fonts\.googleapis\.com[^;]+;/g, '');
container.innerHTML = svg;
var svgEl = container.querySelector('svg');
applyMermaidGlassStyling(svgEl);
initMermaidNodeDrag(svgEl);
return svgEl;
}
每个调用点都要改为 await renderMermaidDiagram(container, dsl) — 将页面的图表初始化代码包装在 async function 中(或顶层 (async()=>{...})() 中),绝不能调用此函数并期待同步返回 SVG 元素。
5. 玻璃拟态后处理(必需) — beautiful-mermaid 的默认渲染效果较为平面;此处理可使其与页面其余部分的风格一致(圆角、柔和的蓝色投影、高光效果——与 .diagram-box/.card 使用相同方案;绝不使用伪造的 backdrop-filter,因为 SVG 对它的支持并不可靠),并为边缘添加微妙的流动虚线效果,增添活力。重用上文已定义的 flowArrow 关键帧(§Key Animations),不要另造一套命名。绝不触碰节点/边缘坐标 — ELK.js 已经计算了这些坐标;此处只在其上添加视觉层:
function applyMermaidGlassStyling(svg){
var NS = 'http://www.w3.org/2000/svg';
var defs = svg.querySelector('defs');
if (!defs){ defs = document.createElementNS(NS,'defs'); svg.insertBefore(defs, svg.firstChild); }
var filter = document.createElementNS(NS,'filter');
filter.setAttribute('id','glass-shadow'); filter.setAttribute('x','-40%'); filter.setAttribute('y','-40%');
filter.setAttribute('width','180%'); filter.setAttribute('height','180%');
filter.innerHTML = '<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="#0a2a5c" flood-opacity="0.18"/>';
defs.appendChild(filter);
var sheen = document.createElementNS(NS,'linearGradient');
sheen.setAttribute('id','glass-sheen'); sheen.setAttribute('x1','0'); sheen.setAttribute('y1','0');
sheen.setAttribute('x2','1'); sheen.setAttribute('y2','1');
sheen.innerHTML = '<stop offset="0%" stop-color="#ffffff" stop-opacity="0.55"/>'
+ '<stop offset="45%" stop-color="#ffffff" stop-opacity="0.06"/>'
+ '<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>';
defs.appendChild(sheen);
var style = document.createElementNS(NS,'style');
style.textContent = [
'.edge{stroke-dasharray:9 5;stroke-linecap:round;animation:flowArrow 1.9s linear infinite}',
'@media (prefers-reduced-motion:reduce){.edge{animation:none}}',
'g.node rect,g.node polygon{filter:url(#glass-shadow);transition:filter .18s ease}',
'g.node:hover rect,g.node:hover polygon{filter:url(#glass-shadow) brightness(1.04)}',
'.glass-sheen-ov{pointer-events:none}'
].join('\n');
svg.appendChild(style);
svg.querySelectorAll('g.node').forEach(function(g){
var shape = g.querySelector('rect') || g.querySelector('polygon');
if (!shape) return;
var ov;
if (shape.tagName === 'rect'){
shape.setAttribute('rx','8'); shape.setAttribute('ry','8');
ov = document.createElementNS(NS,'rect');
['x','y','width','height'].forEach(function(a){ ov.setAttribute(a, shape.getAttribute(a)); });
ov.setAttribute('rx','8'); ov.setAttribute('ry','8');
} else if (shape.tagName === 'polygon'){
ov = document.createElementNS(NS,'polygon'); ov.setAttribute('points', shape.getAttribute('points'));
} else { return; }
ov.setAttribute('fill','url(#glass-sheen)'); ov.setAttribute('class','glass-sheen-ov');
shape.insertAdjacentElement('afterend', ov);
});
}
6. 交互层 — 按节点拖动并重新路由连接线、滚轮缩放、禁止平移背景。 beautiful-mermaid 会在每个 g.node 上原生生成 data-id,并在每个 polyline.edge 上生成 data-from/data-to — 根据实际 ID 绑定,绝不要使用尺寸启发式。用户决定(200826):拖动空白画布时不做任何操作 — 只能拖动单个节点,滚轮仍可缩放。(上文的手写 SVG 路径仍保留其自身的拖动平移背景行为不变;此决定仅适用于 Mermaid 路径。)
function initMermaidNodeDrag(svg){
var NS = 'http://www.w3.org/2000/svg';
var nodeState = {}, camera = { ptx:0, pty:0, scale:1 };
var nodeGroups = [].slice.call(svg.querySelectorAll('g.node'));
var edges = [].slice.call(svg.querySelectorAll('polyline.edge'));
nodeGroups.forEach(function(g){ nodeState[g.getAttribute('data-id')] = { tx:0, ty:0 }; g.style.cursor = 'move'; });
edges.forEach(function(e){
e._basePts = e.getAttribute('points').trim().split(/\s+/).map(function(pair){
var xy = pair.split(',').map(Number); return { x:xy[0], y:xy[1] };
});
});
function reroute(){
edges.forEach(function(e){
var pts = e._basePts.map(function(p){ return { x:p.x, y:p.y }; });
var from = nodeState[e.getAttribute('data-from')], to = nodeState[e.getAttribute('data-to')];
if (from){ pts[0].x += from.tx; pts[0].y += from.ty; }
if (to){ var last = pts[pts.length-1]; last.x += to.tx; last.y += to.ty; }
e.setAttribute('points', pts.map(function(p){ return p.x+','+p.y; }).join(' '));
});
}
function toSvgPoint(cx, cy){ var p = svg.createSVGPoint(); p.x = cx; p.y = cy; return p.matrixTransform(svg.getScreenCTM().inverse()); }
function applyCamera(){ svg.style.transform = 'translate('+camera.ptx+'px,'+camera.pty+'px) scale('+camera.scale+')'; }
var vp = svg.closest('.diagram-viewport') || svg.parentElement;
var mode = null, dragId = null, start = null, t0 = null;
vp.onpointerdown = function(e){
var g = e.target.closest && e.target.closest('g.node');
if (!g) return; // nền trống: KHÔNG làm gì (yêu cầu user 200826) — chỉ node mới bắt sự kiện
vp.setPointerCapture(e.pointerId);
mode = 'node'; dragId = g.getAttribute('data-id');
start = toSvgPoint(e.clientX, e.clientY); t0 = { x: nodeState[dragId].tx, y: nodeState[dragId].ty };
};
vp.onpointermove = function(e){
if (mode !== 'node') return;
var p = toSvgPoint(e.clientX, e.clientY), st = nodeState[dragId];
st.tx = t0.x + (p.x - start.x); st.ty = t0.y + (p.y - start.y);
svg.querySelector('g.node[data-id="'+dragId+'"]').setAttribute('transform', 'translate('+st.tx+','+st.ty+')');
reroute();
};
var endDrag = function(){ mode = null; dragId = null; };
vp.onpointerup = endDrag; vp.onpointercancel = endDrag;
vp.onwheel = function(e){
e.preventDefault();
var r = vp.getBoundingClientRect(), mx = e.clientX-r.left, my = e.clientY-r.top;
var ns = Math.min(4, Math.max(0.3, camera.scale*(e.deltaY<0?1.1:0.9)));
camera.ptx = mx-(mx-camera.ptx)*(ns/camera.scale); camera.pty = my-(my-camera.pty)*(ns/camera.scale);
camera.scale = ns; applyCamera();
};
}
已进行端到端验证(Playwright/Chromium,不仅仅依靠截图):9 节点分支图表正确渲染,布局由 ELK.js 生成;拖动单个节点时只有该节点移动,绑定边缘最近的端点也会精确跟随(测得变换增量 == 鼠标增量),同级节点保持 transform:null;拖动背景不会改变 SVG 的 transform;滚轮缩放仍然有效;切换主题会通过 CSS 自定义属性实时重新着色图表,无需重新渲染。
代码面板中的复制按钮(每个 pre.code-block 均必需)
每个代码面板都必须有一个悬停时显示的复制按钮。注入按钮前先捕获 textContent(以免复制按钮标签),将 <pre> 包装在相对定位的 .code-wrap 中,并通过 Clipboard API 复制,无法使用时回退到 execCommand。
.code-wrap{position:relative}
.code-copy{position:absolute;top:7px;right:7px;z-index:2;width:28px;height:26px;display:inline-flex;align-items:center;justify-content:center;
background:rgba(255,255,255,.1);color:#cbd5e1;border:1px solid rgba(255,255,255,.15);
border-radius:7px;cursor:pointer;opacity:0;transition:opacity .15s,background .15s,color .15s}
.code-copy svg{width:14px;height:14px;display:block}
.code-wrap:hover .code-copy{opacity:1}
.code-copy:hover{background:rgba(255,255,255,.2);color:#fff}
.code-copy.copied{background:rgba(16,185,129,.25);color:#6ee7b7;border-color:rgba(16,185,129,.45);opacity:1}
@media(max-width:640px){.code-copy{opacity:1}}
function initCodeCopy() {
const COPY = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
const CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>';
document.querySelectorAll('pre.code-block').forEach(pre => {
if (pre.dataset.copy) return; pre.dataset.copy = '1';
const code = pre.textContent;
const wrap = document.createElement('div'); wrap.className = 'code-wrap';
pre.parentNode.insertBefore(wrap, pre); wrap.appendChild(pre);
const btn = document.createElement('button'); btn.className = 'code-copy';
btn.setAttribute('aria-label', 'Copy'); btn.title = 'Copy'; btn.innerHTML = COPY;
wrap.appendChild(btn);
btn.addEventListener('click', async () => {
try { await navigator.clipboard.writeText(code); }
catch { const ta = document.createElement('textarea'); ta.value = code;
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
btn.innerHTML = CHECK; btn.classList.add('copied');
setTimeout(() => { btn.innerHTML = COPY; btn.classList.remove('copied'); }, 1500);
});
});
}
initCodeCopy();
水波点击效果(每个交互控件都必须实现)
每个可点击控件(任何 <button>、.nav-link、.collapse-toggle、.code-copy、.diagram-reset、清单标签)在指针按下时都会产生液态玻璃水波:柔和的白色飞溅带有淡淡的蓝色调,从点击位置扩散成水波环,然后逐渐消失。使用一个全局监听器——无需逐个控件绑定。
.ripple{position:absolute;border-radius:50%;pointer-events:none;transform:scale(0);opacity:.9;
background:radial-gradient(circle, rgba(255,255,255,.6) 0%, rgba(10,132,255,.22) 35%, transparent 70%);
box-shadow:0 0 0 1px rgba(255,255,255,.45);
animation:rippleWave .65s cubic-bezier(.2,.6,.3,1) forwards}
@keyframes rippleWave{to{transform:scale(2.8);opacity:0}}
function initRipple() {
document.addEventListener('pointerdown', e => {
const el = e.target.closest('button, .nav-link, .collapse-toggle, .checklist label');
if (!el || el.dataset.noRipple) return;
const r = el.getBoundingClientRect();
const d = Math.max(r.width, r.height) * 1.2;
const s = document.createElement('span');
s.className = 'ripple';
s.style.cssText = `width:${d}px;height:${d}px;left:${e.clientX - r.left - d/2}px;top:${e.clientY - r.top - d/2}px`;
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
el.style.overflow = 'hidden';
el.appendChild(s);
s.addEventListener('animationend', () => s.remove());
});
}
initRipple();
注意:
- 水波的起点是实际点击位置(相对于控件的
clientX/Y),而不是中心——这样才会呈现水波效果,而不是闪光。 - 强制在宿主元素上使用
overflow:hidden,让水波环裁剪在控件的圆角形状内。 - 对于裁剪会破坏布局的控件,可使用
data-no-ripple退出此效果(例如图表视口本身——平移/拖动时不应产生飞溅)。 - 按照调色板使用蓝色系色调(
rgba(10,132,255,…));在深色表面(代码面板)上,白色核心部分足以呈现效果。
折叠 / 展开更多
带动画的展开/折叠区域:
<button class="collapse-toggle" onclick="toggleX()">
<span class="arrow" id="arrowX">▶</span> Label
</button>
<div class="collapse-body" id="bodyX">
<div class="collapse-body-inner">...</div>
</div>
CSS:
.collapse-body {
max-height: 0; overflow: hidden;
transition: max-height .35s cubic-bezier(.4,0,.2,1), opacity .25s ease;
opacity: 0;
}
.collapse-body.open { opacity: 1; } /* open max-height is set by JS = scrollHeight, NOT a magic cap */
.collapse-toggle .arrow { transition: transform .25s cubic-bezier(.4,0,.2,1); }
.collapse-toggle .arrow.open { transform: rotate(90deg); }
⛔ 切勿硬编码 .collapse-body.open{max-height:800px}——任何高于此上限的内容都会被悄悄裁切。测量 scrollHeight 并以内联方式设置 max-height,确保面板始终适应其内容;关闭时将其清回 0,以便动画正常过渡:
JS:
function toggleX() {
const body = document.getElementById('bodyX');
const open = body.classList.toggle('open');
document.getElementById('arrowX').classList.toggle('open');
body.style.maxHeight = open ? body.scrollHeight + 'px' : '0';
}
/* if a panel contains images/late-laid-out content, re-measure on resize for any .open panel */
addEventListener('resize', () => document.querySelectorAll('.collapse-body.open')
.forEach(b => { b.style.maxHeight = b.scrollHeight + 'px'; }));
主从式点击查看(左侧列表 → 右侧详情,位于同一滚动页面)
当一个 section 包含一个项目列表,且每个项目都有较长的详细内容(导览步骤、 endpoints、modules、规则列表……)时,不要一次性展示所有详情,也不要拆分成单独的页面/标签页。 使用主从式布局:左列 = 项目列表(玻璃质感的可点击芯片);右列 = 一个详情面板, 点击时在原处切换——不跳转、不隐藏其他 section(仍保留在连续滚动页面中)。这种方式可实现“顺序阅读 + 点击深入”,又不会破坏 scroll-spy。
<div class="md-wrap"><ul class="md-list" role="listbox"></ul><div class="card md-detail"></div></div>
.md-wrap{display:grid;grid-template-columns:268px 1fr;gap:20px}
.md-list li{padding:12px 12px;border-radius:13px;cursor:pointer;margin-bottom:8px;background:var(--glass-2);
backdrop-filter:blur(var(--blur-2));border:1px solid var(--border);box-shadow:var(--edge-hi);transition:.16s}
.md-list li:hover{transform:translateX(3px)}
.md-list li[aria-selected=true]{background:linear-gradient(120deg,rgba(255,255,255,.92),rgba(244,242,255,.85));border-color:#cdc4ff}
@media(max-width:760px){.md-wrap{grid-template-columns:1fr}}
- 无障碍:使用
role="listbox"/role="option",并在当前选中的项目上添加aria-selected;默认选中第 0 项。 - 水波效果:如果需要,只在列表控件上添加——不要让飞溅效果扩散到详情面板。
- 实际运行版本:skill
orca-onboard的“Guided Tour”标签页(skeleton v2)。
侧边栏图标磁贴(macOS SF Symbols 风格)
每个 nav a 项都是一个带圆角的渐变磁贴(macOS 系统设置风格),其中包含用内联 SVG 绘制的线条图标(白色描边,约 14px,圆角端点)。⛔ 不要使用 Unicode 字符(◫ ▸ ▤ —
基线不齐,外观不佳)。应用 Logo = 渐变磁贴中的“显示器/监视器”SVG 图标。SF Symbols 是
Apple 的专有字体 → 重新绘制路径以模拟 SVG,不要嵌入字体(保持自包含)。
nav a .ic{width:24px;height:24px;border-radius:7px;display:grid;place-items:center}
nav a .ic svg{width:14px;height:14px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
/* mỗi tab 1 màu tile (theo section accent) */
建议映射:overview→info.circle · architecture→square.stack.3d · guided-tour→mappin.and.ellipse
· modules→cube.box · run/docker→terminal。实际运行版本:orca-onboard skeleton v2。
响应式
@media (max-width: 700px) {
.content-grid { grid-template-columns: 1fr; }
}
滚动侦测
const observer = new IntersectionObserver(entries => {
let active = '';
for (const entry of entries) {
if (entry.isIntersecting) active = entry.target.id;
}
if (active) links.forEach(a => a.classList.toggle('active', a.getAttribute('href') === '#' + active));
}, { rootMargin: '-40% 0px -55% 0px' });
sections.forEach(s => observer.observe(s));
字体
仅使用系统字体——不要使用 Google Fonts <link>,不要使用 @import,不要下载 Web 字体。优先使用 macOS 字体系列(San Francisco);如果设备没有 SF,则回退到 Roboto / Segoe UI——所有回退字体都必须优雅,不要回退到 Arial/Times:
:root{
--font-text: 'Be Vietnam Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', sans-serif; /* font mặc định của overstack: Be Vietnam Pro 400 — file font được NHÚNG ở bước cuối, xem 'Font mặc định' */
--font-display: var(--font-text); /* tiêu đề cùng họ Be Vietnam Pro; đậm nhạt chỉ dùng 400 / 600 / 800 (weight đã nhúng) */
--font-mono: 'SF Mono', ui-monospace, 'SFMono-Regular', Menlo, 'Roboto Mono', Consolas, monospace;
}
body{font-family:var(--font-text)}
h1,h2,h3,.logo{font-family:var(--font-display);letter-spacing:-.02em} /* SF Pro Display cho cỡ ≥20px */
pre.code-block,.foot-tree{font-family:var(--font-mono)}
- 在 macOS 上,
-apple-system/BlinkMacSystemFont会解析为 San Francisco——'SF Pro Text/Display' 只是为单独安装了这些字体的设备提供的明确名称。 - Roboto/Segoe UI 是系统回退字体(Android/Linux/Windows 上均已预装)——不要下载 Web 字体,以保持自包含。
- 等宽字体始终先使用
ui-monospace,再回退到 Menlo,以便在较新的 macOS 上使用 SF Mono。
亮/暗主题切换(必需 — 用户反馈 2026-07-06,不得强制使用某一种模式)
单独拆分为一个技能 dark-mode-maker(反馈 160926:“把整个暗/亮模式效果做成独立技能”)。 生成的所有页面都必须允许用户通过固定在侧边栏/导航底部的切换开关自行选择亮/暗模式 — prefers-color-scheme 仅作为初始默认值。**加载技能 dark-mode-maker(Skill 工具)以获取准确的实现:**切换开关标记 + 防止 FOUC + 中性色调(不偏藏青)的暗色模式调色板 + 从光标向外扩散的圆形揭示效果(限制在按钮边界内)+ crest-glow 液态玻璃效果 + Playwright 验收。不要在此处复制 CSS/JS — 保持单一来源,只需在一处修改,避免再次出现 SKILL.md 与 fdk/tools/build-overstack-docs.py 之间的漂移问题。
如果使用脚本生成页面(fdk/tools/build-overstack-docs.py)?仍然遵循“单一来源输出 2 个 CSS 块”的原则(_theme_css()/_DARK_RULES)— 不同之处只是现在的 token 内容应遵循 dark-mode-maker § 调色板,而不是在此处单独手动复制。
无障碍与文档头部(必需)
这些内容很容易被遗忘,而且出问题时不会明显报错 — 每个页面都要包含它们。
<head> — meta + favicon(缺少 viewport 时,整个响应式部分在移动设备上都会失效):
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Page Title — concise, sentence case</title>
<meta name="description" content="One specific sentence about the page.">
<meta name="theme-color" content="#eaf2fd">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%230a84ff'/%3E%3C/svg%3E">
仅当页面需要在外部分享/链接时才添加 Open Graph(og:title/og:description/og:image)— 纯本地 file:// 文档无需添加。
焦点环(无障碍,必不可少) — 每个自定义控件(nav a、button、.code-copy、.diagram-reset、.collapse-toggle、复选框)都会隐藏原生轮廓;请恢复可见的键盘焦点环。使用 :focus-visible,使其仅在键盘操作时显示,而非鼠标操作时显示:
:focus-visible{outline:2px solid #0a84ff;outline-offset:2px;border-radius:8px}
:focus:not(:focus-visible){outline:none}
跳过链接 + <main> 地标 — 将内容(<nav> 之后的所有内容)放入 <main id="main">,并添加一个隐藏的跳过链接作为第一个可聚焦元素,以便键盘用户跳过侧边栏:
<a class="skip-link" href="#main">Skip to content</a>
.skip-link{position:fixed;top:8px;left:8px;z-index:200;padding:8px 16px;border-radius:10px;
background:var(--glass-1);backdrop-filter:blur(var(--blur-1));border:1px solid var(--border);
transform:translateY(-150%);transition:transform .2s}
.skip-link:focus-visible{transform:translateY(0)}
平滑滚动 + 锚点偏移 — 点击锚点时应平滑滚动,并确保章节不会被顶部边缘遮住:
html{scroll-behavior:smooth}
section[id]{scroll-margin-top:24px}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
prefers-reduced-motion — 覆盖所有动效,而不只是主体光球 — 一个全局保护规则可关闭装饰性动画(光球、涟漪、浮动/脉冲/发光/流动箭头、思维导图绘制),以满足用户的减少动态效果偏好:
@media(prefers-reduced-motion:reduce){
*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;
transition-duration:.001ms!important;scroll-behavior:auto!important}
}
信息性 SVG 图表需要文本替代内容 — 可拖动的节点图具有信息含义,因此要为每个 .diagram-box SVG 添加 role="img" 和 <title>(作为第一个子元素),描述图表所示内容;纯装饰性 SVG(已有配套文本标签的图标)应标记为 aria-hidden="true"。
数据图表使用等宽数字 — 表格和图表中的数字标签不得导致列宽抖动:
table, .diagram-box text, .mm .node .ct{font-variant-numeric:tabular-nums}
标题孤行 — 对 hero/h2 添加 text-wrap:balance,对正文段落添加 text-wrap:pretty,避免单个词孤零零地落在一行。
嵌入外部 artifact — 通过 <iframe> 嵌入 archify 图表
§Best Practices 中的“内联 SVG”规则适用于页面中手绘的图表。由 ENGINE 绘制的图表(/diagram → archify — 例如 /propose 生成的 *-seq.html 中的每个任务)是自包含的 viewer 页面,因此应使用 <iframe> 嵌入,而不是将 SVG 复制到外部(复制会丢失 archify 的几何防护)。以下两点为强制要求:
1. 框架高度恰好适配内容 — 查看者绝不需要在框架内滚动。 固定高度(例如 height:840px)总会截断内容:2026 年 9 月 11 日测量时,archify viewer 的高度为 905 到 1565px,具体取决于 message 数量和框架宽度(650–1100px),因为其中还包含工具栏和 Guided views 区块。使用运行时测量实际高度的方案,并始终在框架旁放置单独打开的链接:
<iframe class="archify-embed" src="DDMMYY-feature-t1.html" title="T1 — <tên task>" loading="lazy"></iframe>
<a class="embed-open" href="DDMMYY-feature-t1.html" target="_blank" rel="noopener">Mở sơ đồ riêng ↗</a>
.archify-embed{display:block;width:100%;height:1000px;border:0;border-radius:14px;background:transparent}
.embed-open{display:inline-block;margin-top:8px;font-size:12px}
// Auto-height: đọc scrollHeight THẬT của trang archify (cùng origin) → khung vừa khít, theo dõi đổi cỡ.
document.querySelectorAll('iframe.archify-embed').forEach(f => {
const fit = () => {
let d; try { d = f.contentDocument; } catch (e) {}
if (!d || !d.documentElement) { f.dataset.fit = 'blocked'; return; } // file:// — xem giới hạn bên dưới
const h = d.documentElement.scrollHeight;
if (Math.abs(h - f.offsetHeight) > 2) f.style.height = h + 'px';
};
f.addEventListener('load', () => { fit(); try { new ResizeObserver(fit).observe(f.contentDocument.body); } catch (e) {} });
});
已使用 Playwright 测量(archify 2.17):通过 http 打开(§Auto-Host),在 650px 和 1100px 两种宽度下,框架高度都与内容的 scrollHeight 完全一致;1 秒后仍保持不变(不会反复调整大小),且框架内不再出现滚动条。
使用 file:// 打开时的限制: Chrome 和 Firefox 会将每个 file:// 文件视为独立的 origin,因此父页面无法读取 contentDocument;框架会保持默认的 1000px 高度,仍可能需要在框架内滚动。替代方案是通过 §Auto-Host 打开页面(http://localhost:8765/...),或点击“单独打开图表 ↗”以全屏查看图表——这也是必须提供单独打开链接的原因。
2. Theme 与容器页面匹配 — 将 meta.visual_preset 留空。 安装默认值为 macos(系统字体,与 liquid-glass 相配)。不要从 archify/examples/*.json 复制 visual_preset(例如,现成的 sequence 使用 signal-flow 和等宽字体)。只有在 user 要求时才设置其他 preset,并在这种情况下于容器页面中声明 <meta name="overstack-preset" content="<preset>">。如果没有此 meta,R20 规则(PostToolUse hook)会阻止嵌入 preset 不同于 macos 的 archify artifact。
自包含 — 关键
用户会直接打开这些文件(file://、离线、双击)。输出 HTML 不得发出任何外部请求:不得有字体/CSS/JS CDN 链接、远程图片、@import 或 <script src>。所有内容(CSS、JS、SVG、图标)都必须内联在同一个文件中。指向外部网站的 <a href> 超链接没有问题——它们用于导航,不会加载资源。
输出路径 — 关键
始终将 HTML 文件写入当前项目根目录下的 llmwiki/html/。
文件名必须以今天的日期 DDMMYY- 为前缀(与输出报告草稿中的 DDMMYY 相同,确保 HTML 和 wiki 草稿配对)。例如,2026 年 6 月 4 日 → 040626-cell-formula-override.html。
- 单个文件:
llmwiki/html/DDMMYY-<slug>.html - 多个文件:
llmwiki/html/DDMMYY-index.html+llmwiki/html/DDMMYY-<slug>.html <slug>= 2–4 个 kebab-case 单词;DDMMYY= 今天的日期(例如040626)。- 绝不要写入项目根目录或其他目录。
- 如果
llmwiki/html/不存在,请先创建它。
自动托管
创建 HTML 文件后,始终启动本地 HTTP 服务器以供预览:
cd <project-root>
kill -9 $(lsof -ti :8765) 2>/dev/null
nohup npx serve -p 8765 > /tmp/serve.log 2>&1 &
通知用户:打开 http://localhost:8765/llmwiki/html/DDMMYY-<file>.html
如果端口 8765 已被占用,则跳过(服务器已在运行)。
Playwright 审计(必需——交付前的最后一步,必须在告知用户已就绪之前运行)
⚠️ 200826 真实教训,并非假设: 一个生成出来供人自行阅读代码、并严格按规范实现的页面(深色/浅色切换按钮、折叠侧边栏),竟然还是被擅自简化错了——主题按钮变成了右上角一个独立的浮动标签,正是本 SKILL.md 明令禁止的反模式(§Theme Toggle,“不是散落在角落的图标标签”);侧边栏也根本没有 .nav-toggle/.nav-close。生成页面时并未发现这个错误——直到用户自行打开页面并反馈后才暴露。阅读代码(或阅读 SKILL.md)不足以判断页面的实际外观和交互是否正确;必须用浏览器真正打开并检查,就像在 200826 这一轮中对每个 PoC 都严格执行的那样。
Auto-Host 运行后,必须先用真正的 Playwright 验证,再告知用户页面已完成。 如果项目中尚未安装 @playwright/test,请按照 /playwright-verify(独立技能;如果此框架已安装,该技能已提供)进行安装。编写一个可直接用 node 运行的 .mjs 脚本(不要通过 npx playwright test),至少检查:
- 页面加载时,console/pageerror 错误为 0。
- 如果页面有
<nav>侧边栏:.nav-toggle和.nav-close必须存在于 DOM 中(不能根据 CSS 推测——必须实际查询 DOM)。点击.nav-close→document.body必须包含nav-collapsed类,且.nav-toggle必须可见(opacity不为 0)。再次点击.nav-toggle→nav-collapsed必须消失。这正是 200826 教训中未检查的往返操作。 - 如果页面有主题切换: 必须是位于
<nav>内的.theme-row/.theme-switch(通过nav.contains(themeRowElement)检查),不能是固定在屏幕角落的独立控件。点击切换控件 →document.documentElement的data-theme必须变化,并截取两种主题的页面截图。 - 至少截取 3 种状态: 浅色主题、深色主题,以及(如果有侧边栏)已关闭的侧边栏——保存到 scratchpad,审计通过后无需保留。
import { chromium } from "@playwright/test";
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1200, height: 900 } });
let errors = [];
page.on("pageerror", e => errors.push(e.message));
page.on("console", m => { if (m.type()==='error') errors.push(m.text()); });
await page.goto("file://<đường-dẫn-tuyệt-đối-tới-file-vừa-sinh>.html", { waitUntil: "load" });
await page.waitForTimeout(500);
const hasNav = await page.evaluate(() => !!document.querySelector('nav'));
if (hasNav) {
const dom = await page.evaluate(() => ({
hasToggle: !!document.querySelector('.nav-toggle'),
hasClose: !!document.querySelector('.nav-close'),
}));
if (!dom.hasToggle || !dom.hasClose) errors.push('THIẾU .nav-toggle/.nav-close — sidebar không đóng/mở được');
if (dom.hasClose) {
await page.click('.nav-close'); await page.waitForTimeout(300);
const collapsed = await page.evaluate(() => document.body.classList.contains('nav-collapsed'));
if (!collapsed) errors.push('Click .nav-close không collapse được sidebar');
await page.screenshot({ path: '/tmp/audit-collapsed.png' });
await page.click('.nav-toggle'); await page.waitForTimeout(300);
}
}
await page.screenshot({ path: '/tmp/audit-light.png' });
// Dò control đổi theme bằng HÀNH VI thật (bấm thử), KHÔNG đoán theo tên
// class/id — bug 200826 dùng class="toggle" (không chứa chữ "theme"), nên một
// bộ dò theo tên class sẽ bỏ sót đúng case đã xảy ra thật. Bấm lần lượt mọi
// control có thể bấm được tới khi data-theme đổi, rồi kiểm nó có nằm trong
// .theme-row hay không — bất kể tên class/id của nó là gì.
const themeBefore = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
// loại .nav-toggle/.nav-close khỏi ứng viên — chúng CŨNG là <button>, và bấm
// nhầm .nav-close giữa vòng dò sẽ sập sidebar, khiến .theme-switch biến mất
// khỏi tầm bấm cho các lần thử sau (bug thật gặp khi tự viết script này)
const clickables = await page.$$('button:not(.nav-toggle):not(.nav-close), [role="switch"]');
let themeCtl = null, inThemeRow = false;
for (const el of clickables) {
await el.click().catch(() => {});
await page.waitForTimeout(150);
const themeAfter = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
if (themeAfter !== themeBefore) { themeCtl = el; inThemeRow = await el.evaluate(n => !!n.closest('.theme-row')); break; }
}
if (!themeCtl) errors.push('Bấm thử mọi <button>/[role=switch] không thấy data-theme đổi — thiếu theme toggle');
else if (!inThemeRow) errors.push('Tìm thấy control đổi được data-theme nhưng KHÔNG nằm trong .theme-row — khả năng là chip nổi góc rời sidebar (bug 200826)');
await page.screenshot({ path: '/tmp/audit-dark.png' });
if (errors.length) { console.log('AUDIT FAIL:', errors); process.exit(1); }
console.log('AUDIT PASS');
await browser.close();
如果审计失败:修复后重新审计——页面未通过时,不要告知用户已完成。 这是最后一道质量关卡,相当于代码层面的 medic --ci:失败就不要交付。审计通过后无需保留脚本或截图——这是验证后即丢弃的步骤,不是需要提交的产物(遵循 /playwright-verify 的约定:“独立脚本文件不进入 git——从 scratchpad 运行”)。
多文件模式
为每个 wiki 文件分别生成页面时(所有文件共用相同的 DDMMYY- 日期前缀):
- 创建一个
DDMMYY-index.html概览页(卡片网格链接到全部 N 个页面) - 为每个 wiki 文件创建
DDMMYY-{slug}.html(slug 根据文件名生成) - 每个页面共用同一套 CSS 设计系统,但使用各自版块的强调色
- 每个页面都有一个导航栏,链接到其他所有页面(突出显示当前页面)
- 每个页面都根据主题内容配有自己的动态 SVG 图表
交互式原型 / 可编辑数据网格(可选)
当用户要求“看看 UI 会是什么样”、“tạo bảng tương tác thử”,或想要可点击的可编辑网格/电子表格功能演示时,构建一个独立的交互式原型(使用相同的 DDMMYY-<slug>.html、原生 JS、无需构建)。⚠️ 仍须保持自包含——不要引入 Tailwind CDN、Google Fonts 或任何远程脚本。 使用纯内联 CSS(或复制到 <style> 中的内联工具类),并使用 Font 部分中的系统字体栈;与此技能生成的所有其他页面一样,该文件必须可从 file:// 打开,且零外部请求。以下可复用模式能让覆盖值/级联 UI 保持一致且一目了然:
- 区域设置感知的数字输入——
parseUserNumber(raw, locale):去掉区域设置中的千位分隔符,将小数分隔符替换为.,再调用Number()。使用Intl.NumberFormat(locale)显示。提供 VN ↔ US/Đài 切换。 - 覆盖值与受影响值,持久着色——每行计算两次:应用覆盖值时(
v)和不应用覆盖值时(base)。单元格状态为:覆盖值(在覆盖映射中)→ 琥珀色bg-amber-50 ring-amber-300+✦;受影响/级联(base[code] !== v[code])→ 祖母绿色bg-emerald-50 ring-emerald-200+↻。两种状态都会持续显示(而非仅闪烁),直到移除覆盖值。对本次提交中发生变化的单元格添加短暂的flash效果(约 1.4 秒)。 - 归因(哪个覆盖值导致了哪个单元格变化)——扰动计算:对于行中的每个覆盖值,移除该覆盖值后重新计算;值发生变化的单元格即由它导致。构建
causes: affected→[ov]和affects: ov→[affected]。通过以下方式显示:(a) 悬停单元格时,为同一行中的关联单元格添加高亮类(data-affects/data-srcs);(b) 使用title工具提示列出名称;(c) 保存后显示提示消息,列出重新计算的单元格。 - 轻松编辑——双击任意可编辑列即可立即显示内联输入框。不要使用阻塞式
confirm()限制编辑。以非阻塞方式标记“敏感”列(例如在表头中添加类型标签、工具提示或标记),切勿使用会阻止所有编辑的模态框。 - 使用合适的模态框,而非
confirm()——仅将样式化模态框用于破坏性操作(例如“清除所有覆盖值”);标题、okText和danger均应可配置。 - 异步 UI 后设置焦点——如果在关闭模态框/对话框后立即开始编辑,请通过一个计时周期设置输入框焦点(
setTimeout(...,0)),否则无法获得焦点。
保持与文档站点一致的浏览器顶栏(红黄绿按钮)、系统字体栈(var(--font-text))以及液态玻璃蓝白配色;上述数据网格模式中的琥珀色/祖母绿色覆盖状态是唯一允许的例外——它们表示编辑状态,而非主题颜色。
最佳实践
- **紧凑字号——针对 13″ 笔记本屏幕优化(用户反馈:2026-07-06,曾经反向调整过一次——不要放大):**要缩小,不要放大:正文
p为 13–13.5px,.lead为 14px,导航链接为 12px(垂直内边距约 5px),列表/表格为 12.5px,标签/说明文字为 10–10.5px,h2约 21px,hero 标题为clamp(26px,4vw,40px)。为了「易读」而放大字号,在 13″ 屏幕上是错误的——屏幕能显示的内容更少,换行更拥挤,视觉效果更差;想提升易读性,应调整行高/对比度,而不是字号。计数徽章可以小于 10px。 - **侧边栏:滚动,而非挤压(用户反馈:2026-07-07):**导航栏采用 flex-column 布局时,空间不足会通过 flex-shrink 压缩每个项目——必须阻止这种情况:使用
nav>*{flex-shrink:0}+overflow-y:auto,并彻底隐藏滚动条:nav{scrollbar-width:none;-ms-overflow-style:none} nav::-webkit-scrollbar{width:0;height:0;display:none}。项目始终保持自然高度;空间不足时,改为隐式滚动。 - 始终将 SVG 直接内嵌在 HTML 中(不要使用外部文件)——适用于自行绘制的图表。唯一例外:由 archify 引擎绘制的图表,按 §嵌入外部 artifact 中的方法使用
<iframe>嵌入。 - Hero 标题字号始终使用
clamp():font-size: clamp(32px,5vw,56px) - 绝不使用 Unicode 字符
☐创建清单——始终使用真实的<input type="checkbox">并搭配<label for="...">,使条目可点击。每个清单都要添加以下 CSS:
.checklist { list-style: none; display: flex; flex-direction: column; gap: 8px; }
.checklist li { display: flex; align-items: flex-start; gap: 12px; font-size: 13px; color: var(--text-2); cursor: pointer; }
.checklist li::before { display: none; }
.checklist input[type="checkbox"] {
width: 16px; height: 16px; border-radius: 4px; border: 1.5px solid #cbd5e1;
appearance: none; -webkit-appearance: none; cursor: pointer; flex-shrink: 0;
background: rgba(255,255,255,.8); margin-top: 2px; transition: all .15s;
}
.checklist input[type="checkbox"]:checked {
background: #0a84ff; border-color: #0a84ff;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M13 4L6.5 11 3 7.5' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E");
background-size: contain;
}
.checklist label { cursor: pointer; line-height: 1.5; }
.checklist input[type="checkbox"]:checked + label { text-decoration: line-through; color: #94a3b8; }
<ul class="checklist">
<li><input type="checkbox" id="c1"><label for="c1">Item text</label></li>
<li><input type="checkbox" id="c2"><label for="c2">Item text</label></li>
</ul>
- 始终确保每个
<section>都是自包含的,并按索引从强调色循环中选取颜色(使用#sec-{i}CSS 规则) - 所有图表的 SVG viewBox 宽度保持一致(900),以确保视觉协调
- 指向配套 Markdown 文件的「详细信息」页脚使用相对路径
../file.md - Hero 标题渐变应使用 3 个色标,从蓝色渐变到浅蓝色(不要从深海军蓝开始):
linear-gradient(135deg, #0a84ff, #5aa2e8, #cfe3fb) - 导航 Logo 渐变:
linear-gradient(135deg, #0a84ff, #64b5f7) - 分区数量不固定——使用取模(
i % 6)循环应用 6 种蓝色的调色板 - 写入 HTML 文件后,始终启动 auto-host 服务(参见上文的 Auto-Host 章节)
输出报告
完成所有主要技能任务后,向 wiki 写入一份提案草稿。
步骤
1. 构建文件名:
- 格式:
DDMMYY-<ten>.md DDMMYY= 今天的日期(例如,2026 年 6 月 2 日为020626)<ten>= 用 2–4 个 kebab-case 单词概括已完成的工作(例如,landing-page-coteccons、brand-kit-fintech、ingest-auth-spec)
2. 写入 llmwiki/wiki/sources/draft/DDMMYY-<ten>.md:
---
type: draft
title: "DDMMYY-<ten>"
status: proposed
tags: [<skill-name>, output-report]
timestamp: YYYY-MM-DD
---
# DDMMYY-<ten>
**Type:** draft
**Status:** proposed
**Tags:** <skill-name>, output-report
**Proposed:** YYYY-MM-DD
## What
<One sentence — what this skill invocation produced or decided>
## Output
<Key artefacts, files created/modified, or decisions made>
## Files
| File | Action |
|------|--------|
| `path/to/file` | created / modified |
## Notes
- Invoked via: `/<skill-name>` skill
## Origin
- **Draft:** `wiki/sources/draft/DDMMYY-<ten>.md`
- **Commit:** _(filled by verify-before-commit)_
- **Date promoted:** _(filled by verify-before-commit)_
3. 更新 wiki 索引和日志:
llmwiki/wiki/index.md— 追加一行:| [DDMMYY-<ten>](sources/draft/DDMMYY-<ten>.md) | draft | YYYY-MM-DD |llmwiki/wiki/log.md— 追加:## YYYY-MM-DD — <skill-name> — <ten>
仅当该技能没有产出任何 artifact,也没有产生任何决策时才跳过(例如,纯显示模式
/caveman-stats)。