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.
- package/README.md +1319 -0
- package/bin/devkit.js +3 -0
- package/package.json +61 -0
- package/src/cli.js +76 -0
- package/src/commands/check.js +33 -0
- package/src/commands/diff.js +84 -0
- package/src/commands/init-adopt.js +54 -0
- package/src/commands/init-agents.js +118 -0
- package/src/commands/init-global.js +102 -0
- package/src/commands/init.js +311 -0
- package/src/commands/list.js +54 -0
- package/src/commands/remove.js +133 -0
- package/src/commands/upgrade.js +215 -0
- package/src/lib/agent-guards.js +100 -0
- package/src/lib/agent-install.js +161 -0
- package/src/lib/agents.js +280 -0
- package/src/lib/claude-global.js +183 -0
- package/src/lib/detector.js +93 -0
- package/src/lib/hasher.js +21 -0
- package/src/lib/installer.js +213 -0
- package/src/lib/logger.js +16 -0
- package/src/lib/manifest.js +102 -0
- package/src/lib/reconcile.js +56 -0
- package/templates/.claude/CLAUDE.md +79 -0
- package/templates/.claude/hooks/comment-guard.js +126 -0
- package/templates/.claude/hooks/file-guard.js +216 -0
- package/templates/.claude/hooks/glob-guard.js +104 -0
- package/templates/.claude/hooks/path-guard.sh +118 -0
- package/templates/.claude/hooks/self-review.sh +27 -0
- package/templates/.claude/hooks/sensitive-guard.sh +227 -0
- package/templates/.claude/settings.json +68 -0
- package/templates/docs/WORKFLOW.md +325 -0
- package/templates/docs/specs/.gitkeep +0 -0
- package/templates/hooks/specpipe-read-guard.sh +42 -0
- package/templates/hooks/specpipe-shell-guard.sh +65 -0
- package/templates/rules/specpipe-guards.md +40 -0
- package/templates/scripts/test-hooks.sh +66 -0
- package/templates/skills/sp-build/SKILL.md +776 -0
- package/templates/skills/sp-challenge/SKILL.md +255 -0
- package/templates/skills/sp-commit/SKILL.md +174 -0
- package/templates/skills/sp-explore/SKILL.md +730 -0
- package/templates/skills/sp-fix/SKILL.md +266 -0
- package/templates/skills/sp-humanize/SKILL.md +212 -0
- package/templates/skills/sp-investigate/SKILL.md +648 -0
- package/templates/skills/sp-md-render/SKILL.md +200 -0
- package/templates/skills/sp-md-render/components.md +415 -0
- package/templates/skills/sp-md-render/template.html +283 -0
- package/templates/skills/sp-plan/SKILL.md +947 -0
- package/templates/skills/sp-review/SKILL.md +268 -0
- package/templates/skills/sp-scaffold/SKILL.md +237 -0
- package/templates/skills/sp-scaffold/references/ARCHITECTURE.md.tmpl +228 -0
- package/templates/skills/sp-scaffold/references/DESIGN.md.tmpl +113 -0
- package/templates/skills/sp-scaffold/references/adr/NNNN-template.md +92 -0
- package/templates/skills/sp-scaffold/references/stack-profiles/react.md +36 -0
- package/templates/skills/sp-spec-render/SKILL.md +254 -0
- package/templates/skills/sp-spec-render/components.md +418 -0
- package/templates/skills/sp-spec-render/examples/user-auth.html +749 -0
- package/templates/skills/sp-spec-render/examples/user-auth.md +114 -0
- package/templates/skills/sp-spec-render/template.html +222 -0
- 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>
|