specpipe 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +1319 -0
  2. package/bin/devkit.js +3 -0
  3. package/package.json +61 -0
  4. package/src/cli.js +76 -0
  5. package/src/commands/check.js +33 -0
  6. package/src/commands/diff.js +84 -0
  7. package/src/commands/init-adopt.js +54 -0
  8. package/src/commands/init-agents.js +118 -0
  9. package/src/commands/init-global.js +102 -0
  10. package/src/commands/init.js +311 -0
  11. package/src/commands/list.js +54 -0
  12. package/src/commands/remove.js +133 -0
  13. package/src/commands/upgrade.js +215 -0
  14. package/src/lib/agent-guards.js +100 -0
  15. package/src/lib/agent-install.js +161 -0
  16. package/src/lib/agents.js +280 -0
  17. package/src/lib/claude-global.js +183 -0
  18. package/src/lib/detector.js +93 -0
  19. package/src/lib/hasher.js +21 -0
  20. package/src/lib/installer.js +213 -0
  21. package/src/lib/logger.js +16 -0
  22. package/src/lib/manifest.js +102 -0
  23. package/src/lib/reconcile.js +56 -0
  24. package/templates/.claude/CLAUDE.md +79 -0
  25. package/templates/.claude/hooks/comment-guard.js +126 -0
  26. package/templates/.claude/hooks/file-guard.js +216 -0
  27. package/templates/.claude/hooks/glob-guard.js +104 -0
  28. package/templates/.claude/hooks/path-guard.sh +118 -0
  29. package/templates/.claude/hooks/self-review.sh +27 -0
  30. package/templates/.claude/hooks/sensitive-guard.sh +227 -0
  31. package/templates/.claude/settings.json +68 -0
  32. package/templates/docs/WORKFLOW.md +325 -0
  33. package/templates/docs/specs/.gitkeep +0 -0
  34. package/templates/hooks/specpipe-read-guard.sh +42 -0
  35. package/templates/hooks/specpipe-shell-guard.sh +65 -0
  36. package/templates/rules/specpipe-guards.md +40 -0
  37. package/templates/scripts/test-hooks.sh +66 -0
  38. package/templates/skills/sp-build/SKILL.md +776 -0
  39. package/templates/skills/sp-challenge/SKILL.md +255 -0
  40. package/templates/skills/sp-commit/SKILL.md +174 -0
  41. package/templates/skills/sp-explore/SKILL.md +730 -0
  42. package/templates/skills/sp-fix/SKILL.md +266 -0
  43. package/templates/skills/sp-humanize/SKILL.md +212 -0
  44. package/templates/skills/sp-investigate/SKILL.md +648 -0
  45. package/templates/skills/sp-md-render/SKILL.md +200 -0
  46. package/templates/skills/sp-md-render/components.md +415 -0
  47. package/templates/skills/sp-md-render/template.html +283 -0
  48. package/templates/skills/sp-plan/SKILL.md +947 -0
  49. package/templates/skills/sp-review/SKILL.md +268 -0
  50. package/templates/skills/sp-scaffold/SKILL.md +237 -0
  51. package/templates/skills/sp-scaffold/references/ARCHITECTURE.md.tmpl +228 -0
  52. package/templates/skills/sp-scaffold/references/DESIGN.md.tmpl +113 -0
  53. package/templates/skills/sp-scaffold/references/adr/NNNN-template.md +92 -0
  54. package/templates/skills/sp-scaffold/references/stack-profiles/react.md +36 -0
  55. package/templates/skills/sp-spec-render/SKILL.md +254 -0
  56. package/templates/skills/sp-spec-render/components.md +418 -0
  57. package/templates/skills/sp-spec-render/examples/user-auth.html +749 -0
  58. package/templates/skills/sp-spec-render/examples/user-auth.md +114 -0
  59. package/templates/skills/sp-spec-render/template.html +222 -0
  60. package/templates/skills/sp-voices/SKILL.md +1184 -0
@@ -0,0 +1,114 @@
1
+ # Spec: User Auth
2
+
3
+ **Created:** 2026-04-30
4
+ **Last updated:** 2026-05-14
5
+ **Status:** Active
6
+ **Snapshot limit:** 5
7
+
8
+ ## Overview
9
+
10
+ Email + password authentication với session cookies, password reset qua email, optional remember-me 30 ngày. Mục tiêu: replace legacy middleware (JWT trong localStorage không meet compliance).
11
+
12
+ ## Data Model
13
+
14
+ Entities: `User`, `Session`, `PasswordResetToken`. `Session` liên kết 1-N với `User`, TTL 30 ngày rolling khi có activity. `PasswordResetToken` single-use, TTL 1h, invalidate hết session khác khi reset thành công.
15
+
16
+ ## Stories
17
+
18
+ ### S-001: Login với email + password (P0)
19
+
20
+ **Description:** User nhập email + password → POST `/api/login` → match credentials qua bcrypt → tạo session row + set cookie `sid` (HttpOnly, Secure, SameSite=Lax) → redirect `/dashboard`. Sai password trả lỗi generic. Quá 5 lần sai trong 15 phút → 429.
21
+
22
+ **Acceptance Scenarios:**
23
+
24
+ AS-001: Login thành công với credential đúng
25
+ - **Given:** user `jane@acme.com` tồn tại, password đúng, không có session active
26
+ - **When:** POST `/api/login` với `{email, password}`
27
+ - **Then:** Response 200, set cookie `sid` (HttpOnly, Secure, SameSite=Lax), INSERT session row
28
+ - **Data:** email=`jane@acme.com`, password=`correct-horse-battery-staple`
29
+
30
+ AS-002: Sai password trả lỗi generic, không leak user tồn tại
31
+ - **Given:** user tồn tại nhưng password sai (hoặc user không tồn tại)
32
+ - **When:** POST `/api/login`
33
+ - **Then:** Response 401 với `"Invalid email or password"`. Không phân biệt 2 trường hợp ở message hay timing.
34
+
35
+ AS-003: Quá 5 lần sai → 429 trong 15 phút
36
+ - **Given:** 5 lần login fail liên tiếp cùng (email, IP) trong 15 phút
37
+ - **When:** Lần thứ 6 POST `/api/login`
38
+ - **Then:** Response 429 với header `Retry-After: 900`, dù password đúng cũng từ chối
39
+ - **Data:** Đếm theo cặp (email, IP) — tránh attacker spam từ IP khác khoá user thật
40
+
41
+ ### S-002: Logout (P0)
42
+
43
+ **Description:** User click logout → server xoá session row, clear cookie, redirect `/login`.
44
+
45
+ **Acceptance Scenarios:**
46
+
47
+ AS-004: Logout xoá session DB + clear cookie
48
+ - **Given:** Session active, cookie `sid` hợp lệ
49
+ - **When:** POST `/api/logout`
50
+ - **Then:** Session row deleted, `Set-Cookie: sid=; Max-Age=0`, 302 → `/login`
51
+
52
+ AS-005: Logout khi không có session vẫn 200 (idempotent)
53
+ - **Given:** Không có cookie `sid` hoặc session đã expire
54
+ - **When:** POST `/api/logout`
55
+ - **Then:** Response 200, không lỗi. Idempotent.
56
+
57
+ ### S-003: Password reset qua email (P1)
58
+
59
+ **Description:** User nhập email → backend tạo `PasswordResetToken` 32-char + send link → user click → đặt password mới → invalidate tất cả session khác.
60
+
61
+ **Acceptance Scenarios:**
62
+
63
+ AS-006: Request reset link gửi email (luôn 200 dù email tồn tại hay không)
64
+ - **Given:** Form reset password
65
+ - **When:** POST `/api/password-reset/request` với `{email}`
66
+ - **Then:** Response 200 generic `"Check your email"`. Nếu email tồn tại → gửi link, không tồn tại → no-op (anti-enumeration).
67
+
68
+ AS-007: Token expired (>1h) trả lỗi
69
+ - **Given:** Token tạo cách đây 65 phút
70
+ - **When:** POST `/api/password-reset/confirm` với token + new password
71
+ - **Then:** Response 410 `"Token expired"`
72
+
73
+ AS-008: Reset thành công invalidate tất cả session khác
74
+ - **Given:** User có 3 session active (laptop, phone, tablet), token còn hạn
75
+ - **When:** Reset password thành công
76
+ - **Then:** 3 session bị xoá, user phải login lại trên cả 3 thiết bị, token reset bị mark used
77
+
78
+ ### S-004: Remember-me cookie (P2)
79
+
80
+ **Description:** Checkbox "Remember me" trong form login → session TTL 30 ngày rolling thay vì 24h.
81
+
82
+ **Acceptance Scenarios:**
83
+
84
+ AS-009: Tick remember → cookie Max-Age 30 ngày
85
+ - **Given:** Form login có checkbox "Remember me" được tick
86
+ - **When:** Login thành công
87
+ - **Then:** Cookie `sid` có `Max-Age=2592000` (30 ngày), session row có `extended=true`
88
+
89
+ ## Constraints & Invariants
90
+
91
+ - C-001: Bcrypt cost 12 cho mọi password. Không dùng SHA*, không dùng PBKDF2. Migration từ legacy phải re-hash lúc login lần đầu.
92
+ - C-002: Cookie `sid` luôn `HttpOnly`, `Secure`, `SameSite=Lax`. Domain scope tới apex, không subdomain.
93
+ - C-003: Password tối thiểu 12 ký tự, zxcvbn score ≥ 3. Không bắt buộc ký tự đặc biệt (NIST 800-63B).
94
+ - C-004: Login error message generic — không leak user tồn tại hay không.
95
+ - C-005: Rate limit theo cặp `(email, IP)` rolling 15 phút.
96
+
97
+ ## What Already Exists
98
+
99
+ Legacy middleware ở `src/middleware/auth-legacy.ts` dùng JWT lưu localStorage — đây là cái cần thay (compliance flagged). Bcrypt utility đã có ở `src/lib/crypto/hash.ts`, reuse. Rate limit helper `src/lib/rate-limit.ts` đã có cho API publicly-facing — reuse cho login.
100
+
101
+ ## Not in Scope
102
+
103
+ - OAuth / SSO — defer phase 2
104
+ - 2FA / TOTP — separate spec, planned Q3
105
+ - Email change flow — defer V1+
106
+ - Audit log UI — log có ghi DB nhưng không expose UI
107
+
108
+ ## Change Log
109
+
110
+ | Date | Change | Ref |
111
+ |------|--------|-----|
112
+ | 2026-05-14 | Add S-004 Remember-me (P2) | — |
113
+ | 2026-05-08 | S-003 priority P2 → P1 sau review compliance | JIRA-1428 |
114
+ | 2026-04-30 | Initial creation | — |
@@ -0,0 +1,222 @@
1
+ <!doctype html>
2
+ <html lang="{{LANG}}" data-theme="auto">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Spec · {{FEATURE}}</title>
7
+ <style>
8
+ :root {
9
+ --bg:#fff; --bg-elev:#f6f8fa; --bg-sunken:#fafbfc;
10
+ --fg:#0a0c10; --fg-muted:#57606a; --fg-subtle:#8c959f;
11
+ --border:#d0d7de; --border-subtle:#e5e9ef;
12
+ --accent:#d97757; --accent-bg:#fff4ef;
13
+ --p0:#cf222e; --p0-bg:#ffebe9;
14
+ --p1:#bf8700; --p1-bg:#fff8c5;
15
+ --p2:#57606a; --p2-bg:#eaeef2;
16
+ --warn:#9a6700; --warn-bg:#fff8c5; --warn-border:#d4a72c;
17
+ --ok:#1a7f37;
18
+ --shadow:0 1px 0 rgba(31,35,40,.04);
19
+ --radius:6px; --radius-lg:10px;
20
+ --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
21
+ --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
22
+ }
23
+ @media (prefers-color-scheme: dark) {
24
+ :root[data-theme="auto"] {
25
+ --bg:#0d1117; --bg-elev:#161b22; --bg-sunken:#010409;
26
+ --fg:#e6edf3; --fg-muted:#8b949e; --fg-subtle:#6e7681;
27
+ --border:#30363d; --border-subtle:#21262d;
28
+ --accent:#f0a378; --accent-bg:#2a1810;
29
+ --p0:#f85149; --p0-bg:#2d0e10;
30
+ --p1:#d29922; --p1-bg:#2d2611;
31
+ --p2:#8b949e; --p2-bg:#21262d;
32
+ --warn:#d29922; --warn-bg:#2d2611; --warn-border:#9e6a03;
33
+ --ok:#3fb950;
34
+ --shadow:0 1px 0 rgba(0,0,0,.3);
35
+ }
36
+ }
37
+ :root[data-theme="dark"]{--bg:#0d1117;--bg-elev:#161b22;--bg-sunken:#010409;--fg:#e6edf3;--fg-muted:#8b949e;--fg-subtle:#6e7681;--border:#30363d;--border-subtle:#21262d;--accent:#f0a378;--accent-bg:#2a1810;--p0:#f85149;--p0-bg:#2d0e10;--p1:#d29922;--p1-bg:#2d2611;--p2:#8b949e;--p2-bg:#21262d;--warn:#d29922;--warn-bg:#2d2611;--warn-border:#9e6a03;--ok:#3fb950;--shadow:0 1px 0 rgba(0,0,0,.3);}
38
+ *{box-sizing:border-box}html,body{margin:0;padding:0}
39
+ body{font-family:var(--sans);font-size:14px;line-height:1.55;color:var(--fg);background:var(--bg);-webkit-font-smoothing:antialiased}
40
+ code,.mono{font-family:var(--mono);font-size:.92em}
41
+ a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
42
+ .skip{position:absolute;left:-9999px;top:8px;padding:8px 12px;background:var(--accent);color:#fff;border-radius:6px}.skip:focus{left:8px;z-index:1000}
43
+ .topbar{position:sticky;top:0;z-index:50;display:flex;align-items:center;gap:16px;padding:10px 20px;background:var(--bg);border-bottom:1px solid var(--border);font-size:13px}
44
+ .topbar .doc-type{font-family:var(--mono);font-size:11px;font-weight:600;letter-spacing:.05em;padding:2px 6px;background:var(--accent-bg);color:var(--accent);border-radius:4px}
45
+ .topbar .feature{font-weight:600;color:var(--fg)}
46
+ .topbar .meta{color:var(--fg-muted);display:flex;gap:14px;flex-wrap:wrap}
47
+ .topbar .meta .sep{color:var(--fg-subtle)}
48
+ .topbar .spacer{flex:1}
49
+ .status{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:500;padding:2px 8px;border-radius:999px}
50
+ .status::before{content:"";width:6px;height:6px;border-radius:50%}
51
+ .status.active{background:color-mix(in srgb,var(--ok) 14%,transparent);color:var(--ok)}
52
+ .status.active::before{background:var(--ok)}
53
+ .status.draft{background:color-mix(in srgb,var(--warn) 18%,transparent);color:var(--warn)}
54
+ .status.draft::before{background:var(--warn)}
55
+ .status.deprecated{background:color-mix(in srgb,var(--p0) 14%,transparent);color:var(--p0)}
56
+ .status.deprecated::before{background:var(--p0)}
57
+ .icon-btn{background:none;border:1px solid var(--border);color:var(--fg-muted);width:32px;height:32px;border-radius:6px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:background 80ms,color 80ms}
58
+ .icon-btn:hover{background:var(--bg-elev);color:var(--fg)}.icon-btn svg{width:16px;height:16px;fill:currentColor}
59
+ .layout{display:grid;grid-template-columns:300px minmax(0,1fr);gap:0}
60
+ @media (max-width:900px){.layout{grid-template-columns:1fr}}
61
+ .sidebar{position:sticky;top:53px;align-self:start;height:calc(100vh - 53px);overflow-y:auto;border-right:1px solid var(--border);padding:16px 12px 32px;background:var(--bg)}
62
+ @media (max-width:900px){.sidebar{position:static;height:auto;border-right:none;border-bottom:1px solid var(--border)}}
63
+ .toc-search{width:100%;padding:6px 10px;font:inherit;font-size:13px;background:var(--bg-elev);border:1px solid var(--border);border-radius:6px;color:var(--fg);margin-bottom:12px}
64
+ .toc-search:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:var(--accent)}
65
+ .toc-group{font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--fg-subtle);margin:14px 8px 6px;display:flex;align-items:center;gap:6px}
66
+ .toc-group .count{margin-left:auto;font-weight:500;color:var(--fg-subtle)}
67
+ .toc a{display:flex;align-items:center;gap:8px;padding:4px 8px;color:var(--fg-muted);border-radius:4px;font-size:13px;line-height:1.45}
68
+ .toc a:hover{background:var(--bg-elev);color:var(--fg);text-decoration:none}
69
+ .toc a.active{background:var(--accent-bg);color:var(--accent);font-weight:500}
70
+ .toc a .p{font-family:var(--mono);font-size:10px;flex-shrink:0;min-width:18px}
71
+ .toc a .p.p0{color:var(--p0)}.toc a .p.p1{color:var(--p1)}.toc a .p.p2{color:var(--p2)}
72
+ main{padding:24px 32px 80px;max-width:980px;min-width:0}
73
+ @media (max-width:600px){main{padding:16px}}
74
+ h1{font-size:22px;font-weight:600;margin:4px 0 4px;letter-spacing:-.01em}
75
+ h2{font-size:18px;font-weight:600;margin:32px 0 12px;padding-bottom:4px;border-bottom:1px solid var(--border-subtle)}
76
+ h2.subspec-head{display:flex;align-items:center;gap:10px;margin-top:48px;padding-bottom:8px;border-bottom:2px solid var(--accent)}
77
+ h2.subspec-head .ix{font-family:var(--mono);font-size:13px;font-weight:600;color:var(--accent);background:var(--accent-bg);padding:2px 8px;border-radius:4px}
78
+ h3{font-size:15px;font-weight:600;margin:0}
79
+ p{margin:0 0 12px}
80
+ .subtitle{color:var(--fg-muted);margin:0 0 24px;font-size:14px}
81
+ .tldr{background:var(--bg-elev);border:1px solid var(--border-subtle);border-left:3px solid var(--accent);border-radius:var(--radius);padding:14px 18px;margin:16px 0 24px}
82
+ .tldr-label{font-family:var(--mono);font-size:11px;font-weight:600;letter-spacing:.05em;color:var(--accent);text-transform:uppercase;margin-bottom:6px}
83
+ .tldr p{margin:0 0 8px}.tldr ul{margin:6px 0 0;padding-left:18px}.tldr li{margin:2px 0}
84
+ .badge{display:inline-flex;align-items:center;font-family:var(--mono);font-size:11px;font-weight:600;padding:1px 6px;border-radius:4px;letter-spacing:.02em}
85
+ .badge.p0{color:var(--p0);background:var(--p0-bg)}.badge.p1{color:var(--p1);background:var(--p1-bg)}.badge.p2{color:var(--p2);background:var(--p2-bg)}
86
+ .badge.count{color:var(--fg-muted);background:var(--bg-elev);font-weight:500}
87
+ .story{border:1px solid var(--border);border-radius:var(--radius-lg);margin:0 0 16px;background:var(--bg);box-shadow:var(--shadow);overflow:hidden}
88
+ .story-head{display:flex;align-items:center;gap:10px;padding:12px 16px;background:var(--bg-elev);border-bottom:1px solid var(--border-subtle)}
89
+ .story-head .id{font-family:var(--mono);font-size:12px;color:var(--fg-muted)}
90
+ .story-head .title{flex:1;font-weight:600;font-size:14px}
91
+ .story-body{padding:14px 16px}
92
+ .story-desc{color:var(--fg);margin:0 0 12px;font-size:13.5px}
93
+ .as-list{display:flex;flex-direction:column;gap:8px}
94
+ .as{border:1px solid var(--border-subtle);border-radius:var(--radius);overflow:hidden}
95
+ .as-head{display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--bg-sunken);cursor:pointer;user-select:none;list-style:none}
96
+ .as-head::-webkit-details-marker{display:none}
97
+ .as-head:hover{background:var(--bg-elev)}
98
+ .as-head .id{font-family:var(--mono);font-size:12px;color:var(--accent);font-weight:600;flex-shrink:0}
99
+ .as-head .desc{flex:1;font-size:13.5px;color:var(--fg)}
100
+ .chev{color:var(--fg-subtle);font-size:18px;line-height:1;transition:transform 120ms;flex-shrink:0;display:inline-block;width:14px;text-align:center}
101
+ .as[open] .chev{transform:rotate(90deg)}
102
+ .as-body{padding:10px 12px;border-top:1px solid var(--border-subtle);background:var(--bg)}
103
+ .gwt{display:grid;grid-template-columns:max-content 1fr;gap:4px 12px;font-size:13px;margin:0}
104
+ .gwt dt{font-family:var(--mono);font-size:11px;font-weight:600;color:var(--fg-subtle);letter-spacing:.04em;text-transform:uppercase;padding-top:3px}
105
+ .gwt dd{margin:0}
106
+ .gwt code{background:var(--bg-elev);padding:1px 4px;border-radius:3px}
107
+ .as-data{margin-top:8px;padding:6px 10px;background:var(--bg-sunken);border-radius:4px;font-family:var(--mono);font-size:12px;color:var(--fg-muted)}
108
+ .as-data b{color:var(--fg);font-weight:600}
109
+ .as-prose{font-size:13.5px;color:var(--fg);margin:0}
110
+ .callout{display:flex;gap:10px;padding:10px 14px;background:var(--warn-bg);border:1px solid var(--warn-border);border-radius:var(--radius);margin:0 0 8px;font-size:13.5px}
111
+ .callout .ico{flex-shrink:0;color:var(--warn);width:16px;height:16px;margin-top:2px}
112
+ .callout-title{font-weight:600;color:var(--warn);margin:0 0 2px;font-size:13px;font-family:var(--mono)}
113
+ .callout p{margin:0;color:var(--fg)}
114
+ .constraints{display:flex;flex-direction:column;gap:6px}
115
+ details.collapsible{border:1px solid var(--border-subtle);border-radius:var(--radius);background:var(--bg-elev);margin:0 0 10px}
116
+ details.collapsible>summary{list-style:none;cursor:pointer;padding:10px 14px;display:flex;align-items:center;gap:8px;font-weight:500;font-size:13.5px}
117
+ details.collapsible>summary::-webkit-details-marker{display:none}
118
+ details.collapsible>summary::before{content:"›";color:var(--fg-subtle);font-size:18px;line-height:1;transition:transform 120ms}
119
+ details.collapsible[open]>summary::before{transform:rotate(90deg)}
120
+ details.collapsible>summary .count{color:var(--fg-muted);font-weight:400;margin-left:auto}
121
+ details.collapsible>div{padding:8px 16px 14px;border-top:1px solid var(--border-subtle);background:var(--bg)}
122
+ .changelog{width:100%;border-collapse:collapse;font-size:13px}
123
+ .changelog th,.changelog td{text-align:left;padding:6px 10px 6px 0;border-bottom:1px solid var(--border-subtle)}
124
+ .changelog th{font-size:11px;font-weight:600;color:var(--fg-subtle);letter-spacing:.04em;text-transform:uppercase}
125
+ .changelog td.date{font-family:var(--mono);white-space:nowrap;color:var(--fg-muted)}
126
+ .subspec-table{width:100%;border-collapse:collapse;font-size:13px;margin:8px 0}
127
+ .subspec-table th,.subspec-table td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--border-subtle);vertical-align:top}
128
+ .subspec-table th{font-size:11px;font-weight:600;color:var(--fg-subtle);text-transform:uppercase;letter-spacing:.04em;background:var(--bg-sunken)}
129
+ .subspec-table td:first-child{font-family:var(--mono);font-weight:600;white-space:nowrap}
130
+ .nis ul{margin:0;padding-left:18px;font-size:13.5px}
131
+ .nis li{margin:3px 0;color:var(--fg-muted)}
132
+ .nis li b{color:var(--fg);font-weight:600}
133
+ .snapshots-list{display:flex;flex-direction:column;gap:6px}
134
+ .snapshot-row{display:flex;align-items:center;gap:12px;padding:6px 0;border-bottom:1px solid var(--border-subtle);font-size:13px}
135
+ .snapshot-row:last-child{border-bottom:none}
136
+ .snapshot-row .date{font-family:var(--mono);color:var(--fg-muted);min-width:100px}
137
+ .snapshot-row .reason{color:var(--fg-subtle);font-size:12px}
138
+ h2[id],h3[id],article[id]{scroll-margin-top:70px}
139
+ .toc-hidden{display:none!important}
140
+ @media print{
141
+ .topbar,.sidebar,.icon-btn{display:none}
142
+ .layout{grid-template-columns:1fr}main{max-width:none;padding:0}
143
+ details.collapsible>summary::before{display:none}
144
+ details,.as{break-inside:avoid}.as .as-body{display:block!important;border-top:1px solid var(--border)}
145
+ }
146
+ </style>
147
+ </head>
148
+ <body>
149
+ <svg width="0" height="0" style="position:absolute" aria-hidden="true">
150
+ <symbol id="i-sun" viewBox="0 0 16 16"><path d="M8 12.5a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9zM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0zm0 13a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13zM3.05 3.05a.75.75 0 0 1 1.06 0l1.06 1.06a.75.75 0 1 1-1.06 1.06L3.05 4.11a.75.75 0 0 1 0-1.06zm7.78 7.78a.75.75 0 0 1 1.06 0l1.06 1.06a.75.75 0 1 1-1.06 1.06l-1.06-1.06a.75.75 0 0 1 0-1.06zM0 8a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 8zm13 0a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5h-1.5A.75.75 0 0 1 13 8zM3.05 12.95a.75.75 0 0 1 0-1.06l1.06-1.06a.75.75 0 1 1 1.06 1.06L4.11 12.95a.75.75 0 0 1-1.06 0zm7.78-7.78a.75.75 0 0 1 0-1.06l1.06-1.06a.75.75 0 1 1 1.06 1.06l-1.06 1.06a.75.75 0 0 1-1.06 0z"/></symbol>
151
+ <symbol id="i-warn" viewBox="0 0 16 16"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm.53 4.75v2.5a.75.75 0 0 0 1.5 0v-2.5a.75.75 0 0 0-1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"/></symbol>
152
+ </svg>
153
+
154
+ <a class="skip" href="#content">{{SKIP_LABEL}}</a>
155
+
156
+ <header class="topbar">
157
+ <span class="doc-type">SPEC</span>
158
+ <span class="feature">{{FEATURE}}</span>
159
+ <span class="meta">
160
+ <span>v{{VERSION}}</span>
161
+ <span class="sep">·</span>
162
+ <span>{{UPDATED_LABEL}} {{LAST_UPDATED}}</span>
163
+ {{META_EXTRA}}
164
+ </span>
165
+ <span class="status {{STATUS_CLASS}}">{{STATUS}}</span>
166
+ <span class="spacer"></span>
167
+ <button class="icon-btn" id="theme-toggle" title="{{THEME_TIP}}" aria-label="{{THEME_TIP}}">
168
+ <svg><use href="#i-sun"/></svg>
169
+ </button>
170
+ </header>
171
+
172
+ <div class="layout">
173
+ <aside class="sidebar" aria-label="{{TOC_LABEL}}">
174
+ <input class="toc-search" id="toc-search" placeholder="{{SEARCH_PLACEHOLDER}}" type="search">
175
+ <nav class="toc" id="toc">
176
+ <!-- TOC_ENTRIES -->
177
+ </nav>
178
+ </aside>
179
+
180
+ <main id="content">
181
+ <h1>{{FEATURE}}</h1>
182
+ <p class="subtitle">{{SUBTITLE}}</p>
183
+ <!-- CONTENT_START -->
184
+ <!-- CONTENT_END -->
185
+ </main>
186
+ </div>
187
+
188
+ <script>
189
+ const root=document.documentElement;
190
+ const themeBtn=document.getElementById('theme-toggle');
191
+ const stored=localStorage.getItem('spec-theme');
192
+ if(stored)root.setAttribute('data-theme',stored);
193
+ themeBtn.addEventListener('click',()=>{
194
+ const cur=root.getAttribute('data-theme');
195
+ const next=cur==='dark'?'light':(cur==='light'?'auto':'dark');
196
+ root.setAttribute('data-theme',next);
197
+ localStorage.setItem('spec-theme',next);
198
+ });
199
+ const tocLinks=document.querySelectorAll('#toc a');
200
+ const tocById=new Map();
201
+ tocLinks.forEach(a=>tocById.set(a.dataset.target,a));
202
+ const targets=[...tocById.keys()].map(id=>document.getElementById(id)).filter(Boolean);
203
+ const setActive=(id)=>{
204
+ tocLinks.forEach(a=>a.classList.remove('active'));
205
+ const a=tocById.get(id);if(a)a.classList.add('active');
206
+ };
207
+ const obs=new IntersectionObserver((entries)=>{
208
+ const visible=entries.filter(e=>e.isIntersecting).sort((a,b)=>a.boundingClientRect.top-b.boundingClientRect.top);
209
+ if(visible[0])setActive(visible[0].target.id);
210
+ },{rootMargin:'-70px 0px -60% 0px',threshold:0});
211
+ targets.forEach(t=>obs.observe(t));
212
+ const search=document.getElementById('toc-search');
213
+ search.addEventListener('input',()=>{
214
+ const q=search.value.trim().toLowerCase();
215
+ tocLinks.forEach(a=>{
216
+ if(!q){a.classList.remove('toc-hidden');return;}
217
+ a.classList.toggle('toc-hidden',!a.textContent.toLowerCase().includes(q));
218
+ });
219
+ });
220
+ </script>
221
+ </body>
222
+ </html>