mcpField
D
docs-site-macos

docs-site-macos

github.com/rheinmir设计与 UI10269 次安装2 周前更新
代码仓库

关于 docs-site-macos

docs-site-macos 是用于生成 macOS 风格文档站点的技能,面向需要展示指南、教程或产品文档的场景。它生成零外部请求的自包含 HTML,并要求通过浏览器审计后再交付。

主要功能

  1. 01生成单文件或多文件 HTML 文档站
  2. 02提供侧边栏、思维导图和主题切换
  3. 03支持可拖动 SVG 图表与交互原型
  4. 04启动本地预览服务并执行 Playwright 审计
  5. 05输出审计报告和 Wiki 草稿

适用场景

  1. 01将 Markdown 内容制作成文档站点
  2. 02为产品功能或作品集创建展示页
  3. 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 引擎无法渲染(__bmReady reject)——参见 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 字符串。你只需完成以下两步:

  1. 在页面的 CSS 中设置:body{font-family:var(--font-text);font-weight:var(--fw-text)};标题和页面名称使用 font-family:var(--font-display);font-weight:var(--fw-heading)(不要单独声明系统字体栈,也不要写死 800)。
  2. 写完文件后,运行一条命令(幂等,打印 ✓ … 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}、footerHTML 框架—
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 PASSFAIL → B04
W09执行结果向用户报告链接 + 编写 Output Report 草稿 + index + log草稿 + index + log0 个 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:fixed chip,侧边栏缺少 .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 蓝色#0a84ffrgba(10,132,255,.05)
1#30b0c7 青绿色#30b0c7rgba(48,176,199,.05)
2#5856d6 靛蓝色#5856d6rgba(88,86,214,.05)
3#34c759 绿色#28a745rgba(52,199,89,.05)
4#ff9500 橙色#f08c00rgba(255,149,0,.06)
5#ff2d55 粉色#e0264brgba(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 可垂直调整大小(CSS resize),容器会平滑扩展。

此功能会根据编写的 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),至少检查:

  1. 页面加载时,console/pageerror 错误为 0。
  2. 如果页面有 <nav> 侧边栏: .nav-toggle 和 .nav-close 必须存在于 DOM 中(不能根据 CSS 推测——必须实际查询 DOM)。点击 .nav-close → document.body 必须包含 nav-collapsed 类,且 .nav-toggle 必须可见(opacity 不为 0)。再次点击 .nav-toggle → nav-collapsed 必须消失。这正是 200826 教训中未检查的往返操作。
  3. 如果页面有主题切换: 必须是位于 <nav> 内的 .theme-row/.theme-switch(通过 nav.contains(themeRowElement) 检查),不能是固定在屏幕角落的独立控件。点击切换控件 → document.documentElement 的 data-theme 必须变化,并截取两种主题的页面截图。
  4. 至少截取 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)。

评论

支持 Markdown