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).
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ọ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:6px 10px;
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:200px}
@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:14px 4px 6px}
.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:9px;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:7px 13px;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 7px;border-radius:999px;position:absolute;top:-8px;right:-8px;background:#0a84ff}
.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)}
(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 72px; 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%); }
## Animated SVG Diagrams
Each diagram is an inline SVG (not external file) with `viewBox` for responsiveness.
### Key Animations (reusable CSS in `<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; } }
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:3px 10px;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:3px 9px;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>(with
…(truncated)