voidforge-build 23.11.3 → 23.12.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 (42) hide show
  1. package/dist/.claude/agents/batman-qa.md +1 -0
  2. package/dist/.claude/agents/galadriel-frontend.md +2 -0
  3. package/dist/.claude/agents/kusanagi-devops.md +4 -0
  4. package/dist/.claude/agents/lucius-config.md +6 -0
  5. package/dist/.claude/agents/silver-surfer-herald.md +11 -4
  6. package/dist/.claude/commands/architect.md +9 -0
  7. package/dist/.claude/commands/assemble.md +4 -1
  8. package/dist/.claude/commands/assess.md +13 -1
  9. package/dist/.claude/commands/audit-docs.md +106 -0
  10. package/dist/.claude/commands/deploy.md +28 -0
  11. package/dist/.claude/commands/engage.md +2 -0
  12. package/dist/.claude/commands/gauntlet.md +23 -4
  13. package/dist/.claude/commands/imagine.md +15 -0
  14. package/dist/.claude/commands/ux.md +32 -0
  15. package/dist/.claude/commands/void.md +1 -0
  16. package/dist/CHANGELOG.md +68 -0
  17. package/dist/CLAUDE.md +9 -0
  18. package/dist/VERSION.md +3 -1
  19. package/dist/docs/methods/AI_INTELLIGENCE.md +33 -0
  20. package/dist/docs/methods/ASSEMBLER.md +31 -2
  21. package/dist/docs/methods/BUILD_PROTOCOL.md +1 -0
  22. package/dist/docs/methods/CAMPAIGN.md +31 -3
  23. package/dist/docs/methods/DEVOPS_ENGINEER.md +158 -0
  24. package/dist/docs/methods/DOC_AUDIT.md +92 -0
  25. package/dist/docs/methods/FORGE_KEEPER.md +16 -5
  26. package/dist/docs/methods/GAUNTLET.md +33 -0
  27. package/dist/docs/methods/PRODUCT_DESIGN_FRONTEND.md +54 -0
  28. package/dist/docs/methods/QA_ENGINEER.md +20 -0
  29. package/dist/docs/methods/RELEASE_MANAGER.md +27 -0
  30. package/dist/docs/methods/SUB_AGENTS.md +31 -0
  31. package/dist/docs/methods/SYSTEMS_ARCHITECT.md +13 -0
  32. package/dist/docs/methods/TESTING.md +19 -0
  33. package/dist/docs/patterns/README.md +3 -0
  34. package/dist/docs/patterns/ai-eval.ts +63 -0
  35. package/dist/docs/patterns/autonomous-ops-triage-policy.md +102 -0
  36. package/dist/docs/patterns/daemon-process.ts +90 -0
  37. package/dist/docs/patterns/deploy-preflight.ts +85 -2
  38. package/dist/docs/patterns/design-tokens.ts +338 -0
  39. package/dist/docs/patterns/error-message-categorization.tsx +376 -0
  40. package/dist/wizard/lib/patterns/daemon-process.d.ts +2 -1
  41. package/dist/wizard/lib/patterns/daemon-process.js +89 -1
  42. package/package.json +2 -2
@@ -0,0 +1,102 @@
1
+ # Autonomous Operations Triage Policy
2
+
3
+ **Scope:** Ops-flavored projects (infrastructure repos, monitoring daemons, homelab automation, scheduled-task systems) where the assistant is invoked autonomously and must decide whether to act, propose, or escalate without the operator present.
4
+
5
+ **Status:** Pattern v1 (v23.11.4). Promoted by Wong from field reports #337, #336, #334.
6
+
7
+ **Why this exists:** Two operators independently reinvented the same 4-bucket model across three projects (threadplex-ops, 1999collection M30-M32, 1999collection M20-M28). The pattern is reusable across all infrastructure repos. Codifying it stops the reinvention.
8
+
9
+ ## The 4-Bucket Model
10
+
11
+ Classify every proposed autonomous action into exactly one bucket:
12
+
13
+ | Bucket | Action | When | Logging | Operator Notification |
14
+ |--------|--------|------|---------|----------------------|
15
+ | **A — Self-resolving** | Auto-execute | Action is fully reversible, low-blast-radius, has a clear procedure, and was authorized in a durable instruction (CLAUDE.md, agent definition, prior issue) | Append to ops log | None unless asked |
16
+ | **B — Runbook-safe** | Follow runbook procedure | Action is documented in a runbook, has been executed successfully before, and operator pre-approved the runbook | Append to ops log with runbook ID | Summary at next session start |
17
+ | **C — Operator-approval required** | Propose via Telegram button / GitHub issue / explicit message; WAIT | Action has medium blast radius, irreversible side effects, OR runbook ambiguity | Log proposal + decision | Active notification (Telegram, Slack, email per project) |
18
+ | **D — Hard-never** | Log + escalate; NEVER attempt | Action is on the forbidden list (e.g., production rollback without ticket, secret rotation without quorum, destructive migration without approval) | Log attempt + escalation | Active high-priority alert |
19
+
20
+ ## Pairing with SessionStart Hook
21
+
22
+ Ops projects should pair this policy with a `.claude/settings.json` SessionStart hook that injects current state and a reminder of the policy:
23
+
24
+ ```json
25
+ {
26
+ "hooks": {
27
+ "SessionStart": [
28
+ {
29
+ "command": "bash .claude/hooks/session-start.sh",
30
+ "description": "Inject current ops state + triage policy reminder"
31
+ }
32
+ ]
33
+ }
34
+ }
35
+ ```
36
+
37
+ The hook script outputs the current pending-action set (read from `logs/pending-actions.json` or similar) plus a one-line reminder pointing at this policy file.
38
+
39
+ ### Visibility rule
40
+
41
+ **SessionStart hook output is context-only — the assistant must `echo` the relevant portions back to the operator at session start.** Otherwise the operator has no visual confirmation that the hook fired and the policy is live. Both #337 and #336 documented operators discovering that hooks were silently running with no visibility — both reinvented an explicit echo step. Make it part of the policy from day one.
42
+
43
+ ## Decision Tree (Each Proposed Action)
44
+
45
+ ```
46
+ Is the action on the forbidden list (D)?
47
+ Yes → Log attempt, raise high-priority alert, STOP.
48
+ No → Continue.
49
+
50
+ Does the action have an approved runbook (B)?
51
+ Yes → Execute runbook, log with runbook ID. STOP.
52
+ No → Continue.
53
+
54
+ Is the action fully reversible AND low blast radius AND pre-authorized in durable instructions (A)?
55
+ Yes → Execute, log. STOP.
56
+ No → Bucket C: propose to operator, wait, do not execute until approved.
57
+ ```
58
+
59
+ ## Logging Format
60
+
61
+ Each ops log entry should record:
62
+
63
+ ```jsonl
64
+ {
65
+ "timestamp": "2026-05-12T19:42:00Z",
66
+ "bucket": "A|B|C|D",
67
+ "action": "short description",
68
+ "decision": "executed|proposed|escalated|skipped",
69
+ "rationale": "why this bucket",
70
+ "runbook_id": "RB-007 if applicable",
71
+ "operator_notified": false,
72
+ "outcome": "success|failed|pending"
73
+ }
74
+ ```
75
+
76
+ JSON Lines (one object per line) keeps the log greppable and append-only.
77
+
78
+ ## Adoption Checklist
79
+
80
+ For a new ops-flavored project:
81
+
82
+ - [ ] Pick the buckets that apply (most projects use all four)
83
+ - [ ] Write the forbidden list (Bucket D) FIRST — concrete and exhaustive
84
+ - [ ] Document each runbook (Bucket B) with a fixed ID, expected outcome, and rollback procedure
85
+ - [ ] Set up the SessionStart hook + echo step
86
+ - [ ] Configure the operator-notification channel (Telegram bot, GitHub issue, etc.)
87
+ - [ ] Establish the ops log path and rotation policy
88
+ - [ ] Add a one-line reminder of this policy to the project's `CLAUDE.md` Personality section
89
+
90
+ ## Non-Goals
91
+
92
+ This pattern is NOT:
93
+
94
+ - A replacement for `/campaign` or `/build` — those are user-driven workflows with human pacing
95
+ - A general agent-permissioning model — settings.json `allow`/`deny` lists handle tool-level permissions
96
+ - A substitute for tested code — Bucket A actions still need their own correctness verification
97
+
98
+ ## See Also
99
+
100
+ - `docs/patterns/daemon-process.ts` — daemon lifecycle, PID management, signal handling
101
+ - `docs/methods/HEARTBEAT.md` — long-running ops job scheduling
102
+ - `docs/methods/FIELD_MEDIC.md` — post-mortem analysis for ops incidents
@@ -325,6 +325,95 @@ function createLogger(logPath: string): { log: (msg: string) => void; close: ()
325
325
  };
326
326
  }
327
327
 
328
+ // ── .env Parsing (literal, $-safe) ────────────────────
329
+ // field report #344 F1: never source secrets via `export $(cat .env)` /
330
+ // `eval "$(cat .env)"`. The shell performs variable expansion and word
331
+ // splitting on the RHS, so a `$`-bearing secret — bcrypt hashes
332
+ // ($2b$...), JWTs, Postgres URLs with `$` in the password, anything with
333
+ // `$VAR`/`${...}`/backticks — gets mangled or silently truncated. Parse
334
+ // literally instead: read each line, split on the FIRST `=` only, and keep
335
+ // the value byte-for-byte (no expansion, no eval). For shells, the
336
+ // equivalent is `while IFS='=' read -r k v; do export "$k=$v"; done < .env`
337
+ // — note IFS='=' and `read -r` (raw, no backslash processing), which never
338
+ // re-expands the value.
339
+ //
340
+ // Prefer a runtime-native loader where available — it sidesteps the shell
341
+ // entirely:
342
+ // - Node 20.6+: `node --env-file=.env daemon.js` (literal parse, no shell).
343
+ // - systemd: `EnvironmentFile=/etc/voidforge/heartbeat.env` (also literal;
344
+ // unit-file `Environment=` lines do NOT undergo shell expansion).
345
+ // Use this helper only when you must parse `.env` in-process.
346
+
347
+ function parseDotenv(contents: string): Record<string, string> {
348
+ const out: Record<string, string> = {};
349
+ for (const rawLine of contents.split('\n')) {
350
+ const line = rawLine.replace(/\r$/, '');
351
+ // Skip blanks and comments. A leading `export ` prefix is tolerated.
352
+ const trimmed = line.trimStart();
353
+ if (trimmed === '' || trimmed.startsWith('#')) continue;
354
+ const body = trimmed.startsWith('export ') ? trimmed.slice(7) : trimmed;
355
+
356
+ // Split on the FIRST `=` only — values may legitimately contain `=`.
357
+ const eq = body.indexOf('=');
358
+ if (eq < 0) continue; // not a KEY=VALUE line — ignore, don't guess
359
+ const key = body.slice(0, eq).trim();
360
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; // invalid env name
361
+
362
+ let value = body.slice(eq + 1);
363
+ // Strip a single layer of matching surrounding quotes. Inside quotes the
364
+ // value is taken LITERALLY — no `$` expansion, no eval — which is the
365
+ // whole point: `PASS='p@$$w0rd'` keeps its `$$` intact.
366
+ if (value.length >= 2 &&
367
+ ((value[0] === '"' && value[value.length - 1] === '"') ||
368
+ (value[0] === "'" && value[value.length - 1] === "'"))) {
369
+ value = value.slice(1, -1);
370
+ } else {
371
+ // Unquoted: trim trailing inline whitespace only (POSIX-ish), never
372
+ // touch interior `$` characters.
373
+ value = value.trimEnd();
374
+ }
375
+ out[key] = value;
376
+ }
377
+ return out;
378
+ }
379
+
380
+ // ── systemd hardening stanza (Node daemons) ───────────
381
+ // field report #344 F3: when running this daemon under systemd, harden the
382
+ // unit — but DO NOT set `MemoryDenyWriteExecute=true` for a Node/V8 process.
383
+ // V8's JIT allocates pages that are written and then executed (it manages its
384
+ // own W^X internally); MDWE forbids any write+exec mapping, so the daemon
385
+ // takes a SIGTRAP and dies at boot, usually before it logs a single line. The
386
+ // safe, high-value sandbox flags below give most of MDWE's benefit without the
387
+ // JIT collision:
388
+ //
389
+ // [Unit]
390
+ // Description=VoidForge Heartbeat daemon
391
+ // After=network-online.target
392
+ // Wants=network-online.target
393
+ //
394
+ // [Service]
395
+ // Type=simple
396
+ // ExecStart=/usr/bin/node /opt/voidforge/daemon.js
397
+ // EnvironmentFile=/etc/voidforge/heartbeat.env # literal parse — see #344 F1
398
+ // Restart=on-failure
399
+ // RestartSec=5
400
+ //
401
+ // # Hardening — keep these:
402
+ // NoNewPrivileges=true # no setuid/setgid privilege escalation
403
+ // ProtectSystem=full # /usr, /boot, /etc mounted read-only
404
+ // ProtectHome=true # /home, /root, /run/user hidden
405
+ // PrivateTmp=true # private /tmp + /var/tmp namespace
406
+ // # MemoryDenyWriteExecute=true # <-- OMITTED ON PURPOSE: breaks V8 JIT
407
+ // # (SIGTRAP at boot). Re-enable ONLY for
408
+ // # Go/Rust/static daemons with no JIT.
409
+ //
410
+ // [Install]
411
+ // WantedBy=multi-user.target
412
+ //
413
+ // Go, Rust, and other AOT-compiled daemons emit no executable pages at
414
+ // runtime, so for THEM you can and should keep `MemoryDenyWriteExecute=true`.
415
+ // The omission above is V8-specific, not a general weakening.
416
+
328
417
  export {
329
418
  writePidFile, checkStalePid, removePidFile,
330
419
  generateSessionToken, validateToken,
@@ -333,6 +422,7 @@ export {
333
422
  setupSignalHandlers,
334
423
  JobScheduler,
335
424
  createLogger,
425
+ parseDotenv,
336
426
  PID_FILE, SOCKET_PATH, TOKEN_FILE, STATE_FILE, LOG_FILE,
337
427
  };
338
428
  export type { DaemonState, HeartbeatState, ScheduledJob };
@@ -4,13 +4,17 @@
4
4
  * Reference implementation for .claude/commands/deploy.md Step 2.5.
5
5
  * Scans the deploy artifact directory BEFORE upload. Exits non-zero on any hit.
6
6
  *
7
- * Evidence: field reports #305 (32-day credential leak), #303 (methodology exposure).
7
+ * Evidence: field reports #305 (32-day credential leak), #303 (methodology exposure),
8
+ * #343 F7 (stop-build-start loop mislabeled "blue-green" → 502 window every deploy).
8
9
  *
9
10
  * Key principles:
10
11
  * - Scan the deploy payload directory, NOT the repo root.
11
12
  * - Never auto-filter — a hit means the operator must investigate.
12
13
  * - Never print secret content; only paths + pattern IDs.
13
14
  * - Allowlist escape hatch via DEPLOY_PREFLIGHT_ALLOW (comma-separated globs).
15
+ * - Deploy-strategy claims must be backed by a real mechanism: a comment that
16
+ * says "blue-green"/"zero-downtime" without an atomic swap (rename, container
17
+ * swap, or LB cutover) is a lie that ships a 502 window (#343 F7).
14
18
  *
15
19
  * Usage:
16
20
  * npx tsx docs/patterns/deploy-preflight.ts ./dist
@@ -55,11 +59,82 @@ const TEXT_EXTENSIONS = new Set([
55
59
  ]);
56
60
 
57
61
  interface Hit {
58
- kind: 'name' | 'content';
62
+ kind: 'name' | 'content' | 'strategy';
59
63
  path: string;
60
64
  patternId: string;
61
65
  }
62
66
 
67
+ // ---------- deploy-strategy nomenclature check (field report #343 F7) ----------
68
+ // A stop-build-start loop mislabeled "blue-green"/"zero-downtime" still drops the
69
+ // old process before the new one is live, producing a 502 window on every deploy.
70
+ // The comment lies; the mechanism doesn't. This flags scripts whose comments CLAIM
71
+ // blue-green / zero-downtime but where no atomic-swap mechanism is detectable —
72
+ // temp-build-then-rename, container/image swap, or load-balancer cutover.
73
+
74
+ // File shapes that can carry a deploy strategy worth checking.
75
+ const DEPLOY_SCRIPT_EXTENSIONS = new Set([
76
+ '.sh', '.bash', '.zsh', '.yml', '.yaml', '.ps1',
77
+ ]);
78
+ const DEPLOY_SCRIPT_BASENAMES = new Set([
79
+ 'Dockerfile', 'Procfile', 'Makefile',
80
+ ]);
81
+
82
+ // Comments that CLAIM an atomic deploy strategy.
83
+ const STRATEGY_CLAIM_RE = /\b(blue[\s/_-]?green|zero[\s/_-]?downtime|hot[\s/_-]?swap|atomic\s+deploy(?:ment)?)\b/i;
84
+
85
+ // Any one of these signals a real atomic-swap mechanism is present.
86
+ const ATOMIC_SWAP_SIGNALS: { id: string; re: RegExp }[] = [
87
+ // temp build dir then rename/symlink-swap into place (release-then-link pattern)
88
+ { id: 'rename-swap', re: /\b(?:mv|rename|ln\s+-s(?:fn|nf|f)?)\b[^\n]*\b(?:current|live|release|active|prod(?:uction)?)\b/i },
89
+ { id: 'symlink-current', re: /\bln\s+-s(?:fn|nf|f)?\b[^\n]*\bcurrent\b/i },
90
+ // container / image swap: new container up, traffic moved, old removed
91
+ { id: 'container-swap', re: /\bdocker\b[^\n]*\b(?:run|up|--scale|service\s+update)\b|\bdocker[\s-]compose\b[^\n]*\bup\b[^\n]*\b(?:--no-recreate|--scale)\b|\bcontainer[\s_-]?swap\b/i },
92
+ { id: 'orchestrator-rollout', re: /\b(?:kubectl\s+rollout|helm\s+upgrade|nomad\s+job\s+run|ecs\b[^\n]*update-service)\b/i },
93
+ // load-balancer / proxy cutover: register new target, then drain/deregister old
94
+ { id: 'lb-cutover', re: /\b(?:register-targets|deregister-targets|modify-listener|switchover|traffic[\s_-]?shift|weighted[\s_-]?routing|upstream)\b/i },
95
+ { id: 'proxy-reload', re: /\b(?:nginx\s+-s\s+reload|caddy\s+reload|envoy\b[^\n]*config|haproxy\b[^\n]*reload)\b/i },
96
+ ];
97
+
98
+ // Sequences that betray a stop-build-start loop (kill old, then start new).
99
+ // Used only to strengthen the signal — a claim with NO atomic mechanism is
100
+ // already a hit; this just confirms the anti-pattern is actively present.
101
+ const STOP_START_RE = /\b(?:kill|stop|down|terminate|systemctl\s+stop|pm2\s+stop|docker\s+stop|docker\s+rm)\b[\s\S]{0,400}?\b(?:start|up|run|systemctl\s+start|pm2\s+start|npm\s+(?:run\s+)?start|node\b)/i;
102
+
103
+ function scanStrategy(fullPath: string, relPath: string): string | null {
104
+ const base = relPath.split(sep).pop() ?? '';
105
+ const ext = extname(fullPath).toLowerCase();
106
+ const looksLikeDeployScript =
107
+ DEPLOY_SCRIPT_EXTENSIONS.has(ext) ||
108
+ DEPLOY_SCRIPT_BASENAMES.has(base) ||
109
+ /deploy|release|rollout|cutover/i.test(base);
110
+ if (!looksLikeDeployScript) return null;
111
+
112
+ let stats;
113
+ try {
114
+ stats = statSync(fullPath);
115
+ } catch {
116
+ return null;
117
+ }
118
+ if (stats.size > 2_000_000) return null;
119
+
120
+ let buf: string;
121
+ try {
122
+ buf = readFileSync(fullPath, 'utf8');
123
+ } catch {
124
+ return null;
125
+ }
126
+
127
+ if (!STRATEGY_CLAIM_RE.test(buf)) return null; // no claim, nothing to verify
128
+ const hasAtomicSwap = ATOMIC_SWAP_SIGNALS.some((s) => s.re.test(buf));
129
+ if (hasAtomicSwap) return null; // claim is backed by a real mechanism
130
+
131
+ // Claim present, no atomic-swap mechanism. Distinguish the worst case:
132
+ // an actual stop-build-start loop wearing a blue-green label.
133
+ return STOP_START_RE.test(buf)
134
+ ? 'strategy-mislabel-stop-start'
135
+ : 'strategy-claim-no-atomic-swap';
136
+ }
137
+
63
138
  function globToRegex(glob: string): RegExp {
64
139
  const escaped = glob
65
140
  .replace(/[.+^${}()|[\]\\]/g, '\\$&')
@@ -167,6 +242,14 @@ function main(): void {
167
242
  const contentHit = scanContent(fullPath);
168
243
  if (contentHit) {
169
244
  hits.push({ kind: 'content', path: relPath, patternId: contentHit });
245
+ continue; // a secret hit is already terminal; don't double-report this file
246
+ }
247
+
248
+ // Deploy-strategy nomenclature check (field report #343 F7): a script whose
249
+ // comments claim blue-green / zero-downtime but ships no atomic-swap mechanism.
250
+ const strategyHit = scanStrategy(fullPath, relPath);
251
+ if (strategyHit) {
252
+ hits.push({ kind: 'strategy', path: relPath, patternId: strategyHit });
170
253
  }
171
254
  }
172
255
 
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Pattern: Semantic Design Tokens (primitive -> semantic -> CSS custom properties)
3
+ *
4
+ * Key principles:
5
+ * - NO raw color or type values in components. Components reference SEMANTIC
6
+ * tokens only (e.g., `surface.raised`, `text.muted`, `font.size.body`) —
7
+ * never primitives (e.g., `#4f46e5`, `gray-200`, `16px`).
8
+ * - Two layers: PRIMITIVES (the raw palette/scale — every hex, every step of
9
+ * the type scale) and SEMANTIC roles (what a thing MEANS — `accent`,
10
+ * `danger`, `border.subtle`). Semantic roles map TO primitives.
11
+ * - Tokens are emitted as CSS custom properties (`--vf-color-accent`) so a
12
+ * theme override is a `:root[data-theme=...]` block — not a recompile.
13
+ * - A palette or type pivot becomes a TOKEN edit (re-point semantic roles at
14
+ * different primitives), not a component-by-component rewrite.
15
+ *
16
+ * The trap (field report #351, #3): a rebrand asked for a new accent color and
17
+ * a tighter type scale. Components had hardcoded `#4f46e5` and `text-[15px]`
18
+ * inline in 60+ files. The "one color change" turned into a 60-file grep-and-
19
+ * replace that missed five spots (shipped a two-tone accent for a week) and
20
+ * couldn't support a dark theme at all because the values had no indirection.
21
+ * Scoping all color/type to semantic tokens makes the pivot a single edit.
22
+ *
23
+ * Agents: Galadriel (design system), Legolas (code), Samwise (a11y contrast)
24
+ *
25
+ * Framework adaptations:
26
+ * React/Next.js: This file — emit CSS vars into a <style> at the root, read
27
+ * them via Tailwind theme extension or plain `var(--vf-...)`.
28
+ * Tailwind: map semantic tokens into `theme.extend.colors` /
29
+ * `theme.extend.fontSize` as `var(--vf-...)` so `bg-accent` resolves to the
30
+ * token (see the Tailwind block at the bottom).
31
+ * Vue/Svelte: same CSS-var output; bind `data-theme` on a root element.
32
+ * Django/Rails templates: emit the `:root` block from `tokensToCss()` into a
33
+ * server-rendered <style> tag in the base layout; components use `var(...)`.
34
+ *
35
+ * Pairs with /docs/patterns/component.tsx (components consume tokens, never
36
+ * raw values) and /docs/patterns/combobox.tsx (a11y-critical surfaces).
37
+ */
38
+
39
+ // ── Layer 1: Primitives (the raw palette + scales) ───────────────────────────
40
+ // These are the ONLY place raw values live. Name them by what they ARE
41
+ // (a swatch index, a scale step), never by what they're FOR. A primitive
42
+ // never appears directly in a component.
43
+
44
+ export const primitives = {
45
+ color: {
46
+ // Neutral ramp
47
+ white: '#ffffff',
48
+ black: '#0a0a0a',
49
+ gray50: '#f9fafb',
50
+ gray100: '#f3f4f6',
51
+ gray200: '#e5e7eb',
52
+ gray500: '#6b7280',
53
+ gray700: '#374151',
54
+ gray900: '#111827',
55
+ // Brand ramp
56
+ indigo500: '#6366f1',
57
+ indigo600: '#4f46e5',
58
+ indigo700: '#4338ca',
59
+ // Status ramps
60
+ red500: '#ef4444',
61
+ red600: '#dc2626',
62
+ green500: '#22c55e',
63
+ amber500: '#f59e0b',
64
+ },
65
+ // Type scale — a modular scale, not arbitrary pixels.
66
+ fontSize: {
67
+ xs: '0.75rem',
68
+ sm: '0.875rem',
69
+ base: '1rem',
70
+ lg: '1.125rem',
71
+ xl: '1.5rem',
72
+ '2xl': '2rem',
73
+ },
74
+ fontWeight: {
75
+ regular: '400',
76
+ medium: '500',
77
+ semibold: '600',
78
+ bold: '700',
79
+ },
80
+ lineHeight: {
81
+ tight: '1.2',
82
+ normal: '1.5',
83
+ relaxed: '1.7',
84
+ },
85
+ fontFamily: {
86
+ sans: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
87
+ mono: 'ui-monospace, "SF Mono", "Cascadia Code", monospace',
88
+ },
89
+ } as const;
90
+
91
+ type PrimitiveColor = keyof typeof primitives.color;
92
+ type PrimitiveFontSize = keyof typeof primitives.fontSize;
93
+ type PrimitiveFontWeight = keyof typeof primitives.fontWeight;
94
+ type PrimitiveLineHeight = keyof typeof primitives.lineHeight;
95
+ type PrimitiveFontFamily = keyof typeof primitives.fontFamily;
96
+
97
+ // ── Layer 2: Semantic tokens (what a thing MEANS) ────────────────────────────
98
+ // A semantic token is a NAME FOR A ROLE that points at a primitive. Components
99
+ // reference these. Re-point them to pivot the whole product. The shape is the
100
+ // contract — a theme override must provide every role, so the type system
101
+ // guarantees no role is left un-themed.
102
+
103
+ export type SemanticTokens = {
104
+ color: {
105
+ 'bg.canvas': PrimitiveColor; // page background
106
+ 'bg.raised': PrimitiveColor; // cards, popovers
107
+ 'text.default': PrimitiveColor;
108
+ 'text.muted': PrimitiveColor;
109
+ 'text.on-accent': PrimitiveColor; // text placed on top of `accent`
110
+ 'border.subtle': PrimitiveColor;
111
+ accent: PrimitiveColor; // primary action / brand
112
+ 'accent.hover': PrimitiveColor;
113
+ danger: PrimitiveColor;
114
+ success: PrimitiveColor;
115
+ warning: PrimitiveColor;
116
+ };
117
+ type: {
118
+ 'size.caption': PrimitiveFontSize;
119
+ 'size.body': PrimitiveFontSize;
120
+ 'size.heading': PrimitiveFontSize;
121
+ 'size.display': PrimitiveFontSize;
122
+ 'weight.body': PrimitiveFontWeight;
123
+ 'weight.emphasis': PrimitiveFontWeight;
124
+ 'leading.body': PrimitiveLineHeight;
125
+ 'leading.heading': PrimitiveLineHeight;
126
+ 'family.ui': PrimitiveFontFamily;
127
+ 'family.code': PrimitiveFontFamily;
128
+ };
129
+ };
130
+
131
+ // ── Worked example, step 1: define the default (light) theme ─────────────────
132
+ // Every semantic role points at a primitive. This is the whole brand surface —
133
+ // change these mappings and the product re-skins. Note: no hex here, only
134
+ // references into `primitives`.
135
+
136
+ export const lightTheme: SemanticTokens = {
137
+ color: {
138
+ 'bg.canvas': 'white',
139
+ 'bg.raised': 'gray50',
140
+ 'text.default': 'gray900',
141
+ 'text.muted': 'gray500',
142
+ 'text.on-accent': 'white',
143
+ 'border.subtle': 'gray200',
144
+ accent: 'indigo600',
145
+ 'accent.hover': 'indigo700',
146
+ danger: 'red600',
147
+ success: 'green500',
148
+ warning: 'amber500',
149
+ },
150
+ type: {
151
+ 'size.caption': 'sm',
152
+ 'size.body': 'base',
153
+ 'size.heading': 'xl',
154
+ 'size.display': '2xl',
155
+ 'weight.body': 'regular',
156
+ 'weight.emphasis': 'semibold',
157
+ 'leading.body': 'normal',
158
+ 'leading.heading': 'tight',
159
+ 'family.ui': 'sans',
160
+ 'family.code': 'mono',
161
+ },
162
+ };
163
+
164
+ // ── Worked example, step 2: a theme override (dark) ──────────────────────────
165
+ // Because color roles are indirection, a dark theme is just a different
166
+ // primitive mapping. The type scale is unchanged here — override only what
167
+ // differs. A full rebrand would supply a new `primitives.color` ramp AND a new
168
+ // mapping; both still live in tokens, never in components.
169
+
170
+ export const darkTheme: SemanticTokens = {
171
+ color: {
172
+ 'bg.canvas': 'black',
173
+ 'bg.raised': 'gray900',
174
+ 'text.default': 'gray50',
175
+ 'text.muted': 'gray500',
176
+ 'text.on-accent': 'white',
177
+ 'border.subtle': 'gray700',
178
+ accent: 'indigo500', // lighter accent reads better on dark canvas
179
+ 'accent.hover': 'indigo600',
180
+ danger: 'red500',
181
+ success: 'green500',
182
+ warning: 'amber500',
183
+ },
184
+ type: lightTheme.type, // type scale is theme-invariant in this example
185
+ };
186
+
187
+ // ── Emit: semantic tokens -> CSS custom properties ───────────────────────────
188
+ // `--vf-color-<role>` and `--vf-type-<role>`, with `.` and braces flattened to
189
+ // `-`. The variable VALUE is the resolved primitive, so consumers never touch
190
+ // primitives directly. Emit one block per theme keyed by `data-theme`.
191
+
192
+ const CSS_VAR_PREFIX = '--vf';
193
+
194
+ function cssVarName(group: 'color' | 'type', role: string): string {
195
+ // 'accent.hover' -> '--vf-color-accent-hover'
196
+ const safeRole = role.replace(/[.\s]+/g, '-');
197
+ return `${CSS_VAR_PREFIX}-${group}-${safeRole}`;
198
+ }
199
+
200
+ function resolveColor(role: PrimitiveColor): string {
201
+ return primitives.color[role];
202
+ }
203
+
204
+ function resolveType(
205
+ tokenKey: keyof SemanticTokens['type'],
206
+ ref: string,
207
+ ): string {
208
+ if (tokenKey.startsWith('size.')) return primitives.fontSize[ref as PrimitiveFontSize];
209
+ if (tokenKey.startsWith('weight.')) return primitives.fontWeight[ref as PrimitiveFontWeight];
210
+ if (tokenKey.startsWith('leading.')) return primitives.lineHeight[ref as PrimitiveLineHeight];
211
+ if (tokenKey.startsWith('family.')) return primitives.fontFamily[ref as PrimitiveFontFamily];
212
+ throw new Error(`Unknown type token group: ${tokenKey}`);
213
+ }
214
+
215
+ /** Build the `--vf-*` declarations for one theme as `key: value` lines. */
216
+ export function themeToDeclarations(tokens: SemanticTokens): Record<string, string> {
217
+ const out: Record<string, string> = {};
218
+ for (const [role, ref] of Object.entries(tokens.color)) {
219
+ out[cssVarName('color', role)] = resolveColor(ref as PrimitiveColor);
220
+ }
221
+ for (const [role, ref] of Object.entries(tokens.type)) {
222
+ out[cssVarName('type', role)] = resolveType(
223
+ role as keyof SemanticTokens['type'],
224
+ ref as string,
225
+ );
226
+ }
227
+ return out;
228
+ }
229
+
230
+ /**
231
+ * Render every theme into a single CSS string: `:root` carries the default
232
+ * theme, `[data-theme="<name>"]` carries each override. Drop this into a
233
+ * <style> tag (React: dangerouslySetInnerHTML; templates: server-render).
234
+ */
235
+ export function tokensToCss(themes: {
236
+ default: SemanticTokens;
237
+ overrides?: Record<string, SemanticTokens>;
238
+ }): string {
239
+ const block = (selector: string, decls: Record<string, string>): string => {
240
+ const body = Object.entries(decls)
241
+ .map(([k, v]) => ` ${k}: ${v};`)
242
+ .join('\n');
243
+ return `${selector} {\n${body}\n}`;
244
+ };
245
+
246
+ const parts = [block(':root', themeToDeclarations(themes.default))];
247
+ for (const [name, theme] of Object.entries(themes.overrides ?? {})) {
248
+ parts.push(block(`[data-theme="${name}"]`, themeToDeclarations(theme)));
249
+ }
250
+ return parts.join('\n\n');
251
+ }
252
+
253
+ // ── Worked example, step 3: consume tokens in a component ─────────────────────
254
+ // The component references SEMANTIC tokens via `var(--vf-...)` — never a hex,
255
+ // never a pixel literal. Swapping `data-theme` on any ancestor re-themes it
256
+ // with zero component changes. This is the payoff: the pivot lives in tokens.
257
+
258
+ import type { CSSProperties, PropsWithChildren } from 'react';
259
+
260
+ const tokenColor = (role: keyof SemanticTokens['color']): string =>
261
+ `var(${cssVarName('color', role)})`;
262
+ const tokenType = (role: keyof SemanticTokens['type']): string =>
263
+ `var(${cssVarName('type', role)})`;
264
+
265
+ export function CalloutCard({ children }: PropsWithChildren): JSX.Element {
266
+ const style: CSSProperties = {
267
+ background: tokenColor('bg.raised'),
268
+ color: tokenColor('text.default'),
269
+ border: `1px solid ${tokenColor('border.subtle')}`,
270
+ borderRadius: 8,
271
+ padding: '1rem 1.25rem',
272
+ fontFamily: tokenType('family.ui'),
273
+ fontSize: tokenType('size.body'),
274
+ lineHeight: tokenType('leading.body'),
275
+ };
276
+ const ctaStyle: CSSProperties = {
277
+ background: tokenColor('accent'),
278
+ color: tokenColor('text.on-accent'),
279
+ fontWeight: tokenType('weight.emphasis'),
280
+ border: 'none',
281
+ borderRadius: 6,
282
+ padding: '0.5rem 0.875rem',
283
+ marginTop: '0.75rem',
284
+ cursor: 'pointer',
285
+ };
286
+ return (
287
+ <section style={style}>
288
+ {children}
289
+ {/* No #hex, no 15px — only token references. A rebrand never touches this file. */}
290
+ <button type="button" style={ctaStyle}>
291
+ Continue
292
+ </button>
293
+ </section>
294
+ );
295
+ }
296
+
297
+ // To mount the themes once at the app root (Next.js: in a Server Component
298
+ // layout, or a root <style> in app/layout.tsx):
299
+ //
300
+ // const css = tokensToCss({ default: lightTheme, overrides: { dark: darkTheme } });
301
+ // // <style dangerouslySetInnerHTML={{ __html: css }} />
302
+ // // then: <html data-theme={prefersDark ? 'dark' : undefined}> ... </html>
303
+ //
304
+ // produces, e.g.:
305
+ // :root { --vf-color-accent: #4f46e5; --vf-type-size-body: 1rem; ... }
306
+ // [data-theme="dark"] { --vf-color-accent: #6366f1; ... }
307
+
308
+ // ── Tailwind adaptation ──────────────────────────────────────────────────────
309
+ // Map semantic tokens into the Tailwind theme as `var(--vf-...)`, so utility
310
+ // classes (`bg-accent`, `text-muted`, `text-body`) resolve to tokens and a
311
+ // theme swap still flows through `data-theme`. Components keep using semantic
312
+ // utilities — never `bg-[#4f46e5]`.
313
+ //
314
+ // // tailwind.config.ts
315
+ // export default {
316
+ // theme: {
317
+ // extend: {
318
+ // colors: {
319
+ // canvas: 'var(--vf-color-bg-canvas)',
320
+ // raised: 'var(--vf-color-bg-raised)',
321
+ // accent: { DEFAULT: 'var(--vf-color-accent)', hover: 'var(--vf-color-accent-hover)' },
322
+ // muted: 'var(--vf-color-text-muted)',
323
+ // },
324
+ // fontSize: {
325
+ // caption: 'var(--vf-type-size-caption)',
326
+ // body: 'var(--vf-type-size-body)',
327
+ // heading: 'var(--vf-type-size-heading)',
328
+ // },
329
+ // },
330
+ // },
331
+ // };
332
+ //
333
+ // Lint guardrail (field report #351): forbid raw hex/px in component source so
334
+ // the indirection can't be bypassed. Stylelint: `color-no-hex` + a custom
335
+ // `declaration-property-value-disallowed-list` for `font-size: /\d+px/`; for
336
+ // className strings, an ESLint `no-restricted-syntax` rule banning Tailwind
337
+ // arbitrary-value brackets in color/size utilities (e.g. `bg-[#...]`,
338
+ // `text-[15px]`). The token layer only pays off if primitives can't leak past it.