Imported from Rheinmir/setup (
skills/docs-site-macos/SKILL.md). Install upstream withnpx skills add Rheinmir/setup --skill docs-site-macos. Copyright stays with the author.
macOS Docs Site Builder
Build a single-file HTML documentation site with macOS-inspired design system.
Output is a self-contained .html file (no JS libraries, no build step).
WHAT
Purpose và context
- Purpose: dựng một docs site HTML tự chứa (một file, hoặc index + N trang) theo design system macOS liquid-glass — sidebar kính, mind map, sơ đồ SVG động kéo-thả được, theme sáng/tối — ghi vào
llmwiki/html/, tự host để xem trước, và chỉ báo "xong" sau khi Playwright audit PASS. - Trigger (when to use):
- User muốn docs site, landing page, showcase, portfolio, guide, tutorial site, feature overview hoặc product documentation — nhất là khi nói "clean", "modern", "Apple-like", "macOS style", "glass", "frosted", "animated diagrams", "single HTML file".
- Yêu cầu "liquid glass", "frosted glass", "translucent UI" cho trang docs/showcase.
- User có nhiều file markdown và muốn render thành một trang HTML trực quan có section.
- User nói "6 file" / muốn trang riêng cho từng chủ đề → Multi-File Mode (một HTML cho mỗi file wiki +
DDMMYY-index.html), KHÔNG gộp một trang.
- Non-goals: không tự viết lại CSS/JS theme toggle (nguồn duy nhất là skill
dark-mode-maker); không chép SVG của sơ đồ do engine archify vẽ ra ngoài (nhúng<iframe>); không kéo CDN/webfont/script ngoài; không ghi file ra ngoàillmwiki/html/; không giữ/commit script hay ảnh chụp audit.
Mental model
nội dung nguồn (markdown · wiki · mô tả) → N section (#sec-{i}, accent i % 6) → Page Architecture (skip-link · nav sidebar · main · hero · mind map · section · footer) → HTML tự chứa llmwiki/html/DDMMYY-<slug>.html → Auto-Host :8765 → Playwright Audit → Output Report draft.
Input và output contract
| Field | Required? | Ý nghĩa | |
|---|---|---|---|
| In | nội dung | có | file markdown / trang wiki / mô tả chủ đề — mỗi chủ đề thành một section |
| In | chế độ trang | không (mặc định = một file) | "6 file" / trang riêng mỗi chủ đề → Multi-File Mode |
| In | sơ đồ archify có sẵn | không | trang viewer *.html do /diagram → archify sinh, nhúng bằng <iframe> |
| In | yêu cầu prototype tương tác | không | "tạo bảng tương tác thử" → Interactive Prototype / Editable Data-Grid |
| Out | trang HTML | có | llmwiki/html/DDMMYY-<slug>.html (multi-file: DDMMYY-index.html + DDMMYY-<slug>.html), 0 request ngoài |
| Out | link xem trước | có | http://localhost:8765/llmwiki/html/DDMMYY-<file>.html |
| Out | kết quả audit | có | AUDIT PASS từ script Playwright — "xong" nghĩa là audit PASS |
| Out | output report | có (trừ khi 0 artifact) | llmwiki/wiki/sources/draft/DDMMYY-<ten>.md + dòng index + log |
Rules và capabilities
- RULE-01 (MUST): Self-Contained — CRITICAL: output HTML make ZERO external requests: no font/CSS/JS CDN links, no remote images, no
@import, no<script src>(hyperlink<a href>ra ngoài thì được). - RULE-02 (MUST): Output Path — CRITICAL: ALWAYS write HTML files to
llmwiki/html/inside the current project root; filename MUST be prefixed with today's dateDDMMYY-; NEVER write to the project root or any other directory. - RULE-03 (MUST): Navigation — SIDEBAR ONLY (không bao giờ dùng top bar), sidebar là kính thật và có
.nav-toggle/.nav-close. - RULE-04 (MUST): Mind Map mặc định — luôn vẽ một bản collapsible, sinh từ chính tài liệu.
- RULE-05 (MUST): Theme Toggle sáng/tối REQUIRED, KHÔNG được ép một mode — load skill
dark-mode-maker, nút gạt nằm trong<nav>. - RULE-06 (MUST): Accessibility & Document Head REQUIRED — viewport/meta/favicon inline, focus ring
:focus-visible, skip link +<main id="main">, reduced-motion toàn cục, SVG có text alternative. - RULE-07 (MUST): Node-Draggable cho mọi
.diagram-box; Copy Button cho mọipre.code-block; Water-Ripple cho mọi control tương tác (đều REQUIRED). - RULE-08 (MUST): Auto-Host — ALWAYS start a local HTTP server after writing the HTML file.
- RULE-09 (MUST): Playwright Audit REQUIRED trước khi báo user; FAIL → sửa rồi audit lại, không giao trang đỏ.
- RULE-10 (MUST): Thang cỡ chữ COMPACT cho màn 13″ — giảm chứ không tăng size.
- RULE-11 (MUST): Sơ đồ archify nhúng qua
<iframe>(khung vừa nội dung + link "Mở sơ đồ riêng ↗"), để trốngmeta.visual_preset(luật R20). - RULE-12 (MUST): KHÔNG gradient-text, KHÔNG sọc viền một cạnh —
background-clip:text(chữ tô gradient) vàborder-left/right: ≥3px solid <màu>trên thẻ/nút/callout là hai dấu hiệu AI-generated bị cổng tĩnh chặn cứng. Nhấn chữ bằng weight hoặc màu đặc; phân loại callout bằng chấm màu, nhãn, hoặc nền nhạt toàn thẻ (viền thì đều bốn cạnh). - RULE-13 (MUST): Chạy HAI CỔNG trước khi giao —
python3 fdk/tools/frontend-antipattern.py <trang>(tĩnh) vàNODE_PATH=$(npm root -g) node fdk/tools/html-visual-gate.mjs <trang>(chạy thật: chữ chìm < 4,5:1, khối dính < 8px, icon đè chữ, toggle, kính ở cả hai chế độ). Cổng đỏ thì SỬA rồi chạy lại; vá máy-làm-được bằngpython3 fdk/tools/html-slop-fix.py <trang>. Không báo xong khi còn cổng đỏ. - Capabilities: đọc nội dung nguồn; ghi file HTML vào thư mục output của dự án; chạy HTTP server cục bộ; điều khiển trình duyệt headless để đo DOM/console/ảnh chụp; ghi draft + index + log của wiki.
Failure boundaries
- Không có nội dung / không rõ chủ đề nào thành section → clarify với user, chưa sinh trang.
- Playwright audit FAIL (lỗi console, thiếu
.nav-toggle/.nav-close, theme toggle không nằm trong.theme-row) → blocked: sửa rồi audit lại; không báo trang đã xong ở trạng thái đỏ. - Chưa có
@playwright/test→ cài theo/playwright-verifyrồi mới audit; không bỏ bước audit. - Port 8765 đã bận → coi như server đang chạy, bỏ qua bước start (không phải lỗi).
- Mở iframe archify qua
file://không đo được chiều cao → partial chấp nhận được: khung giữ 1000px, link "Mở sơ đồ riêng ↗" là đường thay thế; muốn vừa khít thì mở qua Auto-Host. - Cổng tĩnh hoặc cổng chạy-thật còn đỏ → blocked: chạy
html-slop-fix.pycho phần máy vá được, phần còn lại sửa tay, rồi chạy lại cả hai; không giao trang còn finding. - Trình duyệt không có
DecompressionStream→ Mermaid engine không render (__bmReadyreject) — xem mục Mermaid Diagram Engine.
HOW
Font mặc định — NHÚNG hai họ (MUST, user chốt 20/09/2026 · đổi nội dung 21/09 · đổi TIÊU ĐỀ 22/09/2026)
Tiêu đề dùng Newsreader 600 (serif kiểu báo, --font-display, --fw-heading:600, --ls-heading:-.01em; một file tĩnh cắt tại wght 600 / opsz 24 — user chọn sau khi so sáu font trên cùng mẫu tiếng Việt). Nội dung dùng Be Vietnam Pro: 400, chữ đậm 600; ba file tĩnh 400/600/800, xin 500 ra 400, xin 700 ra 800 — nét thật; chữ trong sơ đồ/graph (svg · .diagram-box · .mm · .graph) dùng Lexend Deca: mặc định Light, đậm = Regular (user chốt 22/09/2026 — hai bản tĩnh chia theo dải độ đậm, --font-chart); --font-mono cho code giữ nguyên. Font được nhúng base64 vào chính trang (~175 KB) để mở file:// không mạng vẫn đúng font — KHÔNG dùng <link> Google Fonts, KHÔNG tự dán chuỗi base64 bằng tay. Việc của bạn gồm đúng hai bước:
- Trong CSS của trang:
body{font-family:var(--font-text);font-weight:var(--fw-text)}; tiêu đề và tên trang dùngfont-family:var(--font-display);font-weight:var(--fw-heading)(đừng khai stack hệ thống riêng, đừng ghi cứng 800). - Sau khi ghi xong file, chạy một lệnh (idempotent, in
✓ … 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 …
Chưa chạy bước 2 = trang rơi về font hệ thống → CHƯA xong. Kiểm nhanh: grep -c 'id="ovs-font"' <trang.html> phải ra 1.
Bước 2 còn tự gắn bộ khung (PLAN 220926) cho trang có sidebar .logo + ≥4 neo #…: icon tile cho mọi nav a chưa có .ic (icon chọn theo từ khoá tên mục, số thứ tự "01 ·" vào title), mục active = viên nền + chấm màu, vạch tiến độ đọc, skip-link, <main id="main">, favicon inline, scroll spy, ripple, mind map sinh từ h2/h3 (khi trang chưa có .mm), JS kéo-thả cho .diagram-box. Nguồn: fdk/tools/html_shell.py; CSS/JS mind map + kéo-thả là bản NGUYÊN VĂN của skill này (html_shell.py --sync). Chỉ .nav-toggle/.nav-close vẫn phải dựng tay. Luật R20 chặn trang thiếu khung kèm đúng lệnh --apply.
Hệ khoảng cách và nhịp chữ (MUST, PLAN 220926-spacing-system — nguồn fdk/wiki/sources/220926-spacing-standards.md)
- Một thang duy nhất cho padding/margin/gap: 2 · 4 · 8 · 12 · 16 · 20 · 24 · 32 · 40 · 48 · 64 · 80 · 96 px (IBM Carbon + Tailwind), dùng token
--sp-1…--sp-11của lớp nền. Không 5, 6, 9, 10, 13, 14px.html_font.py --applytự bẻ giá trị lệch về bậc gần nhất; cổng tĩnhspacing-off-scalechặn trang chưa qua bước đó. - Line-height: chữ nội dung 1,55–1,6 (
--lh-body), MỘT giá trị cho mọi đoạn/mục; tiêu đề 1,1–1,3 (--lh-heading). Dưới 1,5 cho đoạn nhiều dòng là lỗi (line-height-body). - Độ dài dòng: đoạn chữ ≤ 80 ký tự (WCAG 1.4.8), mục tiêu
max-width:var(--measure)= 34em (Baymard; ≈ 68 ký tự thật —70chcho ~89 ký tự vìchlà độ rộng chữ số "0", rộng hơn chữ trung bình). - Proximity: khoảng TRÊN tiêu đề ≥ 1,5 lần khoảng DƯỚI (USWDS) — tiêu đề thuộc về phần chữ phía sau. Mẫu tốt đã đo: 26px trên / 12px dưới.
- Phân tầng nhãn sidebar: tên trang · nhãn nhóm · mục phải khác nhau ở ≥ 2 trong 4 thuộc tính (cỡ, đậm, màu, hoa/thường) — luật
hierarchy-flat. Mục nav 13–14px đậm 600 màu chữ chính; nhãn nhóm chữ hoa nhỏ giãn chữ. - Vùng bấm ≥ 24×24px (WCAG 2.5.8).
- Thang tiêu đề to → nhỏ (MUST): h1 > h2 > h3 > h4 (mặc định lớp font: 32 · 24 · 20 · 17px) và không nhỏ hơn chữ nội dung; tên trang ≥ 1,2 × mục nav/tab (sidebar: logo 18px, mục 13px) — luật
heading-scale,title-scale. - Viết hoa chữ đầu (MUST) cho tiêu đề, nhãn, nút, tab, mục nav (
sentence-case; lớp nền tự sửa khi--apply). Tên riêng muốn giữ chữ thường:data-case="keep". - Kanban một style, thẻ cố định kích thước (MUST): mọi thẻ trên bảng cùng style và cùng rộng/cao; tiêu đề
line-clamp:2, dòng phụellipsis, bấm thẻ mở chi tiết — luậtkanban-uniform. - Code mẫu cho sidebar, lưới, kanban, list, motion, chart…: trang
skills/hallmark/references/design-showcase.html(máy khách:~/.claude/skills/hallmark/references/design-showcase.html); lấy khối bằngpython3 fdk/tools/build-design-showcase.py --get <id>(máy khách:python3 ~/.claude/harness/fdk/tools/build-design-showcase.py --get <id>;--listin index). - Khoảng nghỉ cho mắt (MUST): màn đầu chỉ tóm tắt, chi tiết hiện khi bấm (
<details>, popup, nút tóm tắt); đừng đặt ngang hàng hàng chục viên/chip/nút — luậteye-rest. Khác chuẩn vì yêu cầu đặc biệt →<meta name="overstack-exempt" content="…" data-reason="…">.
Main workflow
| Step | Type | Inputs | Action | Outputs/exit | Failure/next |
|---|---|---|---|---|---|
| W01 | judgment | nội dung, lời user | Chia nội dung thành N section (mỗi chủ đề/file một section); chọn một file hay Multi-File Mode (B01) | danh sách section + chế độ | thiếu nội dung → clarify |
| W02 | judgment | danh sách section | Dựng khung theo Page Architecture: skip-link, nav sidebar (kính thật, .nav-toggle/.nav-close), <main id="main">, hero, mind map, section-bg s-bg{i}, footer |
khung HTML | — |
| W03 | deterministic | N section | Sinh CSS theo Design System + CSS Generator Pattern (#sec-{i}, accent i % 6, biến thể dark-mode cho .tag), scrollbar overlay, font stack hệ thống |
CSS inline | — |
| W04 | judgment | nội dung mỗi section | Vẽ sơ đồ: SVG inline node-draggable (≤~5 node, tuyến tính) hoặc Mermaid engine (≥6 node / có nhánh) (B02); nhúng artifact archify qua <iframe> (B03) |
.diagram-box |
— |
| W05 | deterministic | khung + CSS | Gắn thành phần bắt buộc: mind map, copy button, water-ripple, collapse, scroll spy, theme toggle từ dark-mode-maker, Accessibility & Document Head |
trang đủ thành phần | — |
| W06 | effect | trang | Ghi llmwiki/html/DDMMYY-<slug>.html (tạo thư mục nếu chưa có) |
file HTML | — |
| W07 | effect | file HTML | Auto-Host: npx serve -p 8765 từ project root |
link http://localhost:8765/llmwiki/html/... |
port bận → server đã chạy, đi tiếp |
| W08 | deterministic | trang đang host | Playwright Audit bằng script .mjs chạy node: 0 lỗi console, round-trip sidebar, theme toggle trong .theme-row, chụp 3 trạng thái |
AUDIT PASS |
FAIL → B04 |
| W09 | effect | kết quả | Báo user link + viết Output Report draft + index + log | draft + index + log | 0 artifact → skip report |
Chi tiết từng bước (nguồn chân lý cho W01–W09): các mục Design System, Page Architecture, Mind Map, Section-Bg Pattern, Animated SVG Diagrams, Accessibility & Document Head, Output Path, Auto-Host, Playwright Audit, Multi-File Mode và Output Report bên dưới — chép nguyên văn từ bản trước migrate.
Branches
| ID | Kind | Guard | Hành vi | Skip / failure | Rejoin |
|---|---|---|---|---|---|
| B01 | user_optional | user nói "6 file" / muốn trang riêng mỗi chủ đề | Multi-File Mode: DDMMYY-index.html (card grid) + DDMMYY-{slug}.html mỗi file wiki, cùng design system, nav trỏ mọi trang |
không yêu cầu → một file | W06 |
| B02 | conditional_required | sơ đồ ≥6 node HOẶC có nhánh/merge | Mermaid Diagram Engine (beautiful-mermaid nhúng gzip+base64, glassmorphism post-processing) thay cho SVG tự đặt toạ độ | ≤~5 node tuyến tính → SVG tay; thiếu DecompressionStream → không render |
W05 |
| B03 | conditional_required | có sơ đồ do engine archify vẽ sẵn | Nhúng <iframe class="archify-embed"> + auto-height + link "Mở sơ đồ riêng ↗"; để trống visual_preset |
mở file:// → khung 1000px, dùng link mở riêng |
W05 |
| B04 | recovery | Playwright audit FAIL | Sửa đúng lỗi audit báo rồi chạy lại W08 | còn đỏ → không báo xong, tiếp tục sửa | W08 |
| B05 | user_optional | user muốn "xem UI sẽ trông ra sao" / "tạo bảng tương tác thử" | Interactive Prototype / Editable Data-Grid (vanilla JS, vẫn Self-Contained) | không yêu cầu → skip | W06 |
Validation và stopping
Phần kiểm bằng code: script Playwright ở W08 (console/pageerror, DOM .nav-toggle/.nav-close, round-trip nav-collapsed, data-theme đổi + control nằm trong .theme-row) — exit 1 là đỏ. Phần cần mắt: ảnh chụp theme sáng / tối / sidebar đóng. Dừng khi audit PASS; không có trần vòng sửa cứng trong bản gốc — nhưng không bao giờ báo "xong" khi còn đỏ. Script và ảnh audit là verify-rồi-vứt, không commit.
Examples
- Positive: "làm docs site macOS cho 4 file trong
llmwiki/wiki/concepts/" → một filellmwiki/html/190926-overstack-concepts.htmlcó sidebar, mind map 4 nhánh, 4 section#sec-0..3(accent theoi % 6), server:8765, script Playwright inAUDIT PASS→ báo linkhttp://localhost:8765/llmwiki/html/190926-overstack-concepts.html+ draft report. - Boundary/failure: trang sinh ra có nút theme là chip
position:fixedgóc phải, sidebar thiếu.nav-close→ audit inAUDIT FAIL: ['THIẾU .nav-toggle/.nav-close …', '… KHÔNG nằm trong .theme-row …'], exit 1 → B04 sửa rồi audit lại; KHÔNG báo user trang đã xong. - Boundary: user nói "6 file" → B01:
DDMMYY-index.html+ 6 trangDDMMYY-<slug>.html, không gộp một trang.
Design System
Color Palette
Base: white glass surfaces over a soft light-blue gradient field (see Background Plane below)
Text: #0f0f12 / #4a4a55
Border: rgba(30,90,170,.14) (cool blue-gray — never white-on-white)
Palette is LIQUID-GLASS LIGHT-BLUE + WHITE for the PATTERN (surfaces, background field, nav, hero, links). CONTENT section accents cycle Apple's secondary palette (see Palette Philosophy below) — confined to tags/h4/bullets so nhiều màu vẫn không rối. No flat-black accents, no saturated-color headings.
Background Plane (glass needs something to sample)
Honest naming: this design system is pragmatic CSS glass (transparency + backdrop blur), not true refraction. For the blur to read as material at all, the body background must NOT be a flat fill — give it a restrained monochrome gradient field:
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%);
}
Keep it this quiet — blue-family tints only, no loud glow layers ("light pollution"). The per-section .s-bgN::before overlays add the rest of the local variation.
Base refraction plane (BẮT BUỘC — gương phải thấy gì bên dưới): gradient phẳng không đủ cho blur "nghiền" — thêm 2 lớp fixed z-index:-1 dưới mọi content: (1) ORBS — 5-6 radial blobs lớn (blue chủ đạo + 1-2 tint Apple secondary, alpha .06-.22) trôi rất chậm (~46s ease alternate, translate ≤2.5% + scale ≤1.05); (2) DOT-GRID mảnh 1px/22px alpha ~.11 có mask fade dọc — chi tiết tần số cao để backdrop-filter biến thành texture kính thật. Đặt ít nhất 1-2 orb dọc mép TRÁI viewport (sau lưng sidebar) — sidebar là pane kính lớn nhất trang, không có màu sau lưng thì blur cỡ nào cũng ra tấm trắng. Kèm @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))}
Liquid-Glass Surface System (opacity ladder + blur scale)
Glass is a depth system, not a single class. Three surface tiers, each with its own alpha + blur — never repeat one alpha everywhere:
: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);
}
Tier assignment: nav = tier 1 · .card / .diagram-box / .repo-card = tier 2 · table / dense data zones = tier 3. Reserve the strongest glass for chrome; content density rises as material strength drops.
Palette Philosophy: blue = PATTERN, Apple secondary = CONTENT
Blue is the chrome/pattern color ONLY — sidebar nav, hero gradient, links, structural accents. Modern iOS tone #0a84ff, never dark/navy starts. Content sections cycle through Apple's secondary palette (teal/indigo/green/orange/pink) — nhiều màu được, miễn không rối: accents stay confined to .tag, .card h4, and li::before bullets.
Headings are DARK, never saturated color: .section-header h2 { color: #1d1d1f } for every section. A saturated blue heading reads dated ("nhà quê") — Apple uses near-black headlines with a colored eyebrow tag above.
Section Color Cycle (Apple secondary)
#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, ... */
Full accent table:
| Index | Accent | h4 tone | Gradient overlay |
|---|---|---|---|
| 0 | #0a84ff blue |
#0a84ff |
rgba(10,132,255,.05) |
| 1 | #30b0c7 teal |
#30b0c7 |
rgba(48,176,199,.05) |
| 2 | #5856d6 indigo |
#5856d6 |
rgba(88,86,214,.05) |
| 3 | #34c759 green |
#28a745 |
rgba(52,199,89,.05) |
| 4 | #ff9500 orange |
#f08c00 |
rgba(255,149,0,.06) |
| 5 | #ff2d55 pink |
#e0264b |
rgba(255,45,85,.05) |
For each section, use the accent for:
.tagbackground (at 10-12% opacity) + text.card h4color (use the darker h4 tone for orange/green/pink so text stays readable).card li::beforecolor (the›bullet)- ⛔ NOT for
.section-header h2— h2 is always#1d1d1f
Glassmorphism Cards
.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 and .repo-card share this tier-2 recipe. table (and any dense data region) drops to tier 3: background: var(--glass-3); backdrop-filter: blur(var(--blur-3)) — same border + edge highlight, calmer material so rows stay legible.
Liquid-glass rules (distilled from the liquid-glass-design skill):
- Edge highlight is mandatory — glass without
--edge-hireads as a washed card. - Directional light: faint top inner highlight + soft lower outer shadow on every pane.
- Text never sits on heavy blur alone; tier 3 (near-solid) backs all long-form reading and tables.
- Stacked glass must differ by at least one tier (alpha AND blur step) or the layers collapse into mud.
- Glow stays faint and blue-family only — depth comes from the ladder, edges, and shadow, not atmosphere.
- Large chrome panes (sidebar, full-height panels) NEVER use one flat alpha — flat white fill reads as a milky wall ("màu trơn trông chắn"). They need gradient-alpha glass + a specular sheen
::before+ a color orb directly behind them. See the Navigation section for the canonical recipe.
macOS Chrome Elements
The repo card and converter mockup use a macOS window header:
<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>
⚠️ Boxed full-width elements (repo-card, converter mockup, bất kỳ panel có viền) PHẢI có gutter ngang giống hero/section (bài học 13/06/2026 — user chê repo-card "chạm sát mép màn hình, nên có padding trái phải"): hero/section ăn gutter qua padding:…24px BÊN TRONG container max-width:1100px, nhưng một boxed element chỉ đặt max-width:1100px;margin:auto sẽ chạm sát 2 mép viewport khi màn hẹp hơn 1100px (vì nó không có padding trong, mà chính cái box là content). Fix: cho box cùng lề NỘI DUNG với hero bằng max-width:1052px (= 1100 − 48 gutter) và width:calc(100% - 48px):
.repo-card{ max-width:1052px; width:calc(100% - 48px); margin:8px auto 0; /* …glass tier-2… */ }
Rộng → cap 1052 căn giữa, thẳng hàng với chữ trong hero (1100−24−24); hẹp → luôn chừa 24px mỗi bên. Áp dụng cho MỌI boxed element đặt trực tiếp dưới <body> (ngoài luồng section): repo-card, converter mockup, banner/callout full-width.
⚠️ Body cuối của boxed element cần padding-bottom rộng hơn padding-top (bài học 13/06/2026 — user chê "chỗ chuyển tiếp bị cắt đứt không mượt"): khối nội dung cuối (vd .rc-body) nối thẳng xuống section kế tiếp; nếu padding dưới = padding trên (16px) thì chữ áp sát mép box, đọc như bị cụt. Cho đáy thở thêm: padding:16px 18px 22px (đáy ≥ trên + 6px). Quy tắc: pane kết thúc bằng text → bottom-pad ≥ top-pad.
Navigation — SIDEBAR ONLY (không bao giờ dùng top bar)
Máy gác (R20, hook PostToolUse): trang */html/*.html có hơn 3 mục (<section id> hoặc <h2>) mà không có <nav> chứa ít nhất 3 link #anchor sẽ bị chặn, bất kể skill nào sinh trang. Trang cố ý một cột thì khai <meta name="overstack-nav" content="none"> kèm lý do.
Mọi cỡ màn hình đều dùng LEFT SIDEBAR + nút collapse. ⛔ KHÔNG có chế độ top bar — top bar nhồi link wrap chữ rất xấu trên màn hẹp. Màn hẹp (<640px): sidebar OVERLAY đè content (body giữ padding-left:0), mặc định THU GỌN, user mở bằng nút toggle:
⚠️ Sidebar PHẢI là kính thật, không phải tấm trắng sữa (bài học 12/06/2026 — user chê "màu trơn trông hơi chắn"): fill phẳng --glass-1 alpha .55 trên nền sáng ra "sữa" đục, không ra gương. Pane chrome LỚN (sidebar, panel cao full màn) bắt buộc 3 thứ: (1) gradient-alpha glass — alpha biến thiên dọc mặt kính thay vì một hằng số; (2) specular sheen ::before — vùng sáng radial góc trên + dải sheen chéo; (3) orb màu ngay sau lưng pane (xem Background Plane) — blur 24px phải có màu thật để nghiền. --glass-1 chỉ còn dùng cho floating panel nhỏ:
: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ở */
}
Ripple effect (BẮT BUỘC trên nút sidebar + toggle): click vào đâu, một hình tròn lan ra TỪ ĐÚNG TOẠ ĐỘ đó và phủ từ từ kín nút (bán kính = khoảng cách xa nhất tới 4 góc), rồi fade. Phần tử cha cần CÓ position (relative/fixed/absolute đều chứa được ink) + overflow:hidden. ⚠️ KHÔNG viết rule chung ép position:relative lên .nav-toggle — nó sẽ đè position:fixed (cùng specificity, rule sau thắng) làm nút rơi xuống cuối trang:
Ripple ink là LIQUID GLASS, không phải vệt màu phẳng: specular highlight lệch góc (circle at 35% 30%), thân trắng mờ, viền xanh nhạt, backdrop-filter:blur(2px) để giọt nước tự khúc xạ content bên dưới, edge highlight inset:
.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);
})();
Chạy SAU script tạo .nav-toggle để nút toggle cũng có ripple.
Liên quan §Theme Toggle sáng/tối (bên dưới, dòng ~952): .theme-row là con trực tiếp của cùng nav này, dùng chung biến --nav-pad-y khai ở CSS nav phía trên — sửa padding của nav thì .theme-row tự theo, không cần sửa 2 chỗ.
- Tier-1 glass cho cả hai dạng
- Scroll spy via IntersectionObserver watching
section[id](selectornav akhông đổi)
Collapse (BẮT BUỘC với sidebar) — 2 nút riêng biệt: nút ĐÓNG ✕ nằm TRONG sidebar (góc trên phải, 26×26, bg mờ nhẹ) — bấm → sidebar translateX(-100%), body{padding-left:0} (trả lại nguyên column). Nút MỞ ☰ glass 32×32 lơ lửng góc trên trái, CHỈ hiện khi sidebar đang đóng (body:not(.nav-collapsed) .nav-toggle{opacity:0;pointer-events:none}). Cả 2 nút đều có ripple liquid-glass. Trạng thái nhớ localStorage('navCollapsed'); màn hẹp mặc định collapsed (matchMedia 640px). JS tự tạo cả 2 button — không cần sửa 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 kính NỔI trên nền sáng KHÔNG được dùng viền trắng đặc (bài học 13/06/2026 — user chê viền .nav-toggle "trông kỳ"): nút toggle (và mọi floating glass chip góc trên-trái) nằm trên vùng nền trang gần trắng-xanh phẳng → backdrop-filter:blur không có gì tối phía sau để nghiền thành kính, nên thứ rõ nhất lại là cái viền rgba(255,255,255,.55) — một vòng 1px nét căng, đọc thành "viền sticker dán lên", không ra mép kính. Fix: border:1px solid transparent, để mép sinh ra từ inset top-highlight + ring lạnh cực mảnh + drop-shadow lạnh:
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);
Quy tắc: viền trắng đặc CHỈ hợp khi pane có content tối/đa sắc phía sau để blur sample (vd sidebar đè lên section). Chip nổi trên nền sáng → mép bằng shadow lạnh, không bằng stroke trắng.
Scrollbar — overlay tự ẩn (theme thay scrollbar mặc định)
Thay scrollbar mặc định của trình duyệt bằng overlay ẩn HOÀN TOÀN — track/nền/corner/button đều trong suốt, KHÔNG còn dải gutter. Chỉ viên pill (thumb) fade-in (alpha 0 → tint xanh) và trượt theo nội dung khi đang cuộn hoặc khi rê chuột vào dải scrollbar (hover thumb). Áp dụng cho cả viewport lẫn sidebar (nav có overflow-y:auto). Thumb dùng background-clip:content-box + border:3px solid transparent để pill mảnh, bo tròn, nổi giữa gutter. JS thêm class .scrolling khi cuộn rồi gỡ sau ~900ms idle:
⚠️ Bài học 13/06/2026 (user: "ẩn đi hoàn toàn luôn kể cả thanh nền"): chỉ set track{background:transparent} là CHƯA đủ — track-piece, corner, button vẫn có thể vẽ nền/viền mặc định thành một dải xám mờ. Phải ép TẤT CẢ phần không phải thumb về background:transparent;border:0;box-shadow:none. Default thumb cũng alpha 0 (vô hình); chỉ fade qua background-color (transition mượt như opacity) khi .scrolling / :hover. KHÔNG opacity trực tiếp lên pseudo scrollbar (webkit không hỗ trợ ổn định) — dùng alpha của background-color.
⚠️ Khi scrollbar trong suốt → dải gutter lộ CANVAS, phải set nền cho html: gutter width:11px vẫn chiếm chỗ; track + thumb transparent nên nó lộ canvas phía sau. Nền gradient thường đặt trên body (đã trừ scrollbar) → KHÔNG phủ dải gutter → canvas html mặc định trắng → thành vệt trắng dọc mép phải full-height. Fix: cho html một nền tông xanh khớp 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 });
})();
Lưu ý: tint xanh #0a84ff để khớp pattern; thumb đậm dần theo hover→active. Firefox không ẩn hẳn được (reserve width), nên fallback là thanh thin đổi màu — chấp nhận được.
Page Architecture
<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>
Mind Map (MẶC ĐỊNH — luôn vẽ một bản)
Mọi docs site PHẢI kèm một mind map collapsible kiểu NotebookLM tóm tắt cấu trúc trang (các section / thực thể / mục chính) — đặt ngay sau hero hoặc trong section "Tổng quan". Đặc trưng: đường nối CONG (bezier) màu theo nhánh + node chip glass + chevron; mặc định ĐÓNG, click mở/đóng. Self-contained (CSS + JS thuần vẽ SVG, KHÔNG thư viện). Nội dung sinh từ chính tài liệu (mỗi section = một nhánh, mục con = lá), cắt mô tả lá ≤~54 ký tự cho gọn. Nhánh quá dài (>~8 lá đã là vấn đề) → KHÔNG để phẳng, chia tiếp thành nhánh con theo CHỨC NĂNG (root → nhánh → nhóm → lá): mỗi nhóm là node .cat has-children lồng trong, mang class màu .g0..g5 riêng (JS colorOf trả đúng màu cho đường cong); cố giữ mỗi nhóm ≤~8 lá. Ví dụ overstack chia CẢ dev-loop (12 → sửa-code / dựng-onboard / eval), orchestrate (10 → điều-phối / đánh-giá / vận-hành), utils (37 → 6 nhóm) — xem fdk/tools/build-overstack-docs.py (LOOP_GROUPS).
Cấu trúc (tree ngang: root → nhánh .cat → lá .leaf; có .mm-canvas + <svg class="mm-links"> để vẽ đường cong):
<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 (trong <style>; --ink2/--border từ design system; .b-0..b-4 cycle Apple secondary cho .nm+.ct+border — JS dùng CÙNG màu cho đường cong):
.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}}
Đo Playwright 22/09/2026: bản cũ .ct trắng trên #0a84ff = 3,67:1 ở 10px và node KHÔNG có biến thể tối (chữ 1,56:1 trên nền tối) → nay #0058d0 + khối tối ở trên.
(KHÔNG còn connector thẳng .row::before/.children::before — đường nối do JS vẽ bezier vào <svg class="mm-links">.)
JS (vẽ bezier màu theo nhánh + mặc định ĐÓNG nhánh .cat + click toggle + redraw — trong <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 không .cat) mở sẵn → nhánh hiện; nhánh .cat đóng → click xổ lá; đường cong tự vẽ lại mỗi lần toggle/resize. colorOf trả CÙNG màu với class .b-N. Bản chạy thật: llmwiki/html/overstack.html tab "Tham chiếu".
Section-Bg Pattern
Each <section> gets two classes: section-bg s-bgN (N = section index mod 6).
The gradient overlay is a ::before pseudo-element:
.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: padding: 64px 24px 80px; max-width: 1100px; margin: 0 auto;
CSS Generator Pattern
Generate the per-section CSS dynamically. For N sections, generate N rule sets using #sec-{i} IDs:
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 PHẢI có biến thể dark-mode riêng (bug thật 160926, không phải giả thuyết) — VÀ phải đổi cả màu CHỮ, không chỉ nền. .tag{background:accent tại 12%;color:accent} đủ tương phản trên nền sáng, nhưng CÙNG công thức đó trên nền gần-đen (#0c0f16) ra pill gần vô hình + chữ mờ (đo bằng Playwright thật — getComputedStyle + tính contrast ratio WCAG, không phải chỉ nhìn ảnh chụp). Hai vòng sửa sai đã đo được, đừng lặp lại:
- Sai lần 1: chỉ nâng alpha nền (
.22) + viền, GIỮ NGUYÊNcolor:accentcho chữ → contrast đo được 1.0–1.4:1 (mù chữ thật sự, screenshot trông "có vẻ ổn" vì mắt bắt được viền pill, không bắt được chữ mờ). - Sai lần 2: nâng alpha nền lên
.26+ đổi chữ sang trắng → PASS với accent tối (blue/indigo/pink) nhưng vẫn FAIL 2.0–2.3:1 với accent sáng (teal/green/orange) — nền tint càng đậm càng "ăn" mất chỗ tương phản cho chữ trắng, đặc biệt với accent có kênh G/R cao. - Đúng: GIỮ nền mỏng/tối (alpha
.12, gần bằng nền trang thật), viền đậm hơn (.55) để định hình cái pill, chữ trắng trung tính (#f2f4f8, không phải màu accent) đứng trên nền THẬT SỰ tối — không phụ thuộc kênh màu của accent nào. Đo Playwright xác nhận 11–16:1 trên cả 6 accent:
@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 không cần đổi (chữ trên nền tối ở đó vẫn đủ tương phản — đã đo, không phải giả định). Sinh trang bằng script? Giữ đúng nguyên tắc "một nguồn emit 2 khối" như _theme_css() — xem accent_css() trong fdk/tools/build-overstack-docs.py.
Animated SVG Diagrams
Each diagram is an inline SVG (not external file) with viewBox for responsiveness.
Key Animations (reusable CSS in <defs><style>)
@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; } }
Apply to SVG elements: .flow (dashed arrows), .pulse (nodes), .float (output badges), .glow (phone icon), .drone (bouncing), .blink (drone light), .orbit-ring (spinning product), .cameraFlash (strobe), .scan (scan line), .stackAnim (DICOM slices), .center-pulse (orbit core).
SVG Box Styling
- Nodes:
rx="6"orrx="8"rounded rects withfill="rgba(255,255,255,.7)"and colored stroke - Arrows:
<line>withmarker-end="url(#arrowN)"using<marker>def,stroke-width="2", and.flowclass - Text:
text-anchor="middle",font-size="9-11",font-weight="600"for labels - Use
font-familyfrom the page (var(--font-text)— macOS-first stack, see Font section) - Always include
xmlns="http://www.w3.org/2000/svg"on<svg>
Node-Draggable Diagrams (REQUIRED for every .diagram-box)
Every .diagram-box MUST be an interactive node graph, NOT a static or merely-pannable picture:
- Drag each box (node) individually — moving one table/step does NOT move the others. Connector lines re-route to follow the node automatically.
- Drag empty background = pan the whole canvas.
- Wheel = zoom toward the cursor. Double-click / ⟲ reset = restore node positions + canvas.
.diagram-boxis vertically resizable (CSSresize) so the container expands smoothly.
This is auto-detected from the authored SVG — no SVG markup changes needed. The JS treats every <rect> ≥ 70×30 as a node, adopts the <text>/small-<rect> children whose coords fall inside that rect, wraps them in a <g class="dnode">, and binds each <line> endpoint to the nearest node so connectors track movement. Coordinate conversion uses getScreenCTM().inverse() so dragging stays accurate under zoom/pan.
CSS — replace the old static .diagram-box rule with:
.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 — add once, call after DOM is parsed (script at end of <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();
Notes:
- Author SVGs exactly as before (fixed
viewBox, flat<rect>/<text>/<line>). Node grouping + line binding are inferred at runtime — keep node labels'x/yINSIDE their rect bounds so they get adopted correctly. - Connectors must be
<line>(withx1/y1/x2/y2) to auto-track.<path>connectors stay static — use<line>for anything that should follow a node. - Auto-fit: on drag release the SVG
viewBoxgrows to contain dragged nodes, so the svg height (and the box) sizes WITH the content — nodes never get clipped after release.svg{overflow:visible}keeps a node visible mid-drag too.fitViewBox()runs on pointerup + reset. - Idempotent (
dataset.draggableguard);resize:verticallets the user grow the container; flex viewport fills new height.
Mermaid Diagram Engine (auto-layout, for complex diagrams)
The hand-authored path above works because the LLM hand-picks every node's x/y — fine for small diagrams (≤~5 nodes, mostly linear), but coordinates get uneven once a diagram has real branching, because there is no layout algorithm behind it, only judgment. Use this Mermaid path instead when a diagram has ≥6 nodes OR has branches/merges — anything a real auto-layout engine earns its cost on. Below that threshold, stay on the hand-SVG path (ladder: YAGNI — don't pull in a 1.5MB engine for a 3-box flow).
This renders real Mermaid DSL through beautiful-mermaid (MIT, github.com/lukilabs/beautiful-mermaid) — a synchronous renderer built on ELK.js, chosen over official Mermaid.js because it's lighter, has no async flash, and its two-color theme (bg/fg + optional line/accent/muted/surface/border) is CSS-custom-properties on the <svg> root, so it re-themes instantly on the existing dark/light toggle with zero re-render. The vendored bundle is shipped gzip-compressed at vendor/beautiful-mermaid.min.js.gz.b64 (see vendor/README.md for provenance and the exact rebuild command) — built once, off-band; generating a page never runs a bundler or touches the network.
Why compressed: the raw bundle is 1.56MB — embedded verbatim, that's added to EVERY generated page regardless of how many diagrams it has. Gzipped + base64'd it's 625KB (measured: gzip -9 → 468,777 bytes raw, → 625,0
Truncated - read the full file at https://github.com/Rheinmir/setup/blob/f87eb9797393a97c19bb8e2edee3f3acc65c3118/skills/docs-site-macos/SKILL.md.