@vimoxshah/tokenflow 1.1.1 → 1.2.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 (87) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/Dockerfile.team +20 -0
  3. package/README.md +30 -11
  4. package/bin/tokenflow.js +147 -12
  5. package/design/tokens.yaml +330 -0
  6. package/docs/architecture.md +5 -4
  7. package/docs/cli.md +204 -0
  8. package/docs/configuration.md +117 -2
  9. package/docs/design-system.md +187 -0
  10. package/docs/exports-and-budgets.md +85 -0
  11. package/docs/guard-codex.md +132 -0
  12. package/docs/ledger.md +144 -0
  13. package/docs/live-mode.md +40 -0
  14. package/docs/media/overview-aurora-dark.png +0 -0
  15. package/docs/media/receipts-aurora-dark.png +0 -0
  16. package/docs/providers-otel.md +179 -0
  17. package/docs/providers.md +54 -1
  18. package/docs/receipt-schema.md +74 -0
  19. package/docs/roadmap.md +182 -0
  20. package/docs/team-server.md +170 -0
  21. package/docs/ui-views.md +322 -0
  22. package/package.json +7 -2
  23. package/schemas/receipt.v0.json +160 -0
  24. package/scripts/build-dmg.sh +11 -2
  25. package/scripts/build-menubar-app.sh +58 -7
  26. package/scripts/design-build.js +475 -0
  27. package/src/analytics/anatomy.js +467 -0
  28. package/src/analytics/branch-compare.js +159 -0
  29. package/src/analytics/cache-health.js +141 -0
  30. package/src/analytics/live-view.js +266 -0
  31. package/src/analytics/receipt-schema.js +214 -0
  32. package/src/analytics/receipt.js +709 -0
  33. package/src/analytics/rhythm.js +184 -0
  34. package/src/analytics/whatif.js +263 -0
  35. package/src/commands/budget-scopes.js +133 -0
  36. package/src/commands/doctor-checks.js +400 -0
  37. package/src/commands/guard.js +531 -0
  38. package/src/commands/hooks.js +238 -0
  39. package/src/commands/pricing-diff.js +316 -0
  40. package/src/commands/receipt.js +226 -0
  41. package/src/commands/team-serve.js +407 -0
  42. package/src/commands/week.js +86 -0
  43. package/src/core/annotations.js +97 -0
  44. package/src/core/budget.js +33 -0
  45. package/src/core/bundle.js +45 -2
  46. package/src/core/ingest.js +33 -0
  47. package/src/core/live-status.js +227 -2
  48. package/src/core/policy.js +103 -0
  49. package/src/core/receipt-note.js +123 -0
  50. package/src/core/repo.js +64 -0
  51. package/src/core/sync.js +163 -26
  52. package/src/core/team.js +0 -0
  53. package/src/export/html-snapshot.js +28 -1
  54. package/src/export/menubar.js +21 -0
  55. package/src/export/receipt-card.js +210 -0
  56. package/src/export/week-card.js +185 -0
  57. package/src/providers/mock/index.js +383 -52
  58. package/src/providers/openai/index.js +31 -1
  59. package/src/providers/otel/index.js +656 -0
  60. package/src/server/routes/annotations.js +42 -0
  61. package/src/server/routes/cache-health.js +95 -0
  62. package/src/server/routes/index.js +54 -0
  63. package/src/server/routes/session.js +157 -0
  64. package/src/server/server.js +47 -1
  65. package/src/ui/app.js +541 -308
  66. package/src/ui/charts.js +95 -0
  67. package/src/ui/first-run.js +144 -0
  68. package/src/ui/index.html +4 -1
  69. package/src/ui/palette.js +335 -0
  70. package/src/ui/styles/anatomy.css +117 -0
  71. package/src/ui/styles/annotations.css +40 -0
  72. package/src/ui/styles/branches.css +99 -0
  73. package/src/ui/styles/cache.css +6 -0
  74. package/src/ui/styles/first-run.css +31 -0
  75. package/src/ui/styles/live.css +100 -0
  76. package/src/ui/styles/palette.css +85 -0
  77. package/src/ui/styles/rhythm.css +8 -0
  78. package/src/ui/styles/whatif.css +55 -0
  79. package/src/ui/styles.css +303 -196
  80. package/src/ui/views/anatomy.js +567 -0
  81. package/src/ui/views/annotations.js +121 -0
  82. package/src/ui/views/branches.js +304 -0
  83. package/src/ui/views/cache.js +232 -0
  84. package/src/ui/views/index.js +85 -0
  85. package/src/ui/views/live.js +683 -0
  86. package/src/ui/views/rhythm.js +206 -0
  87. package/src/ui/views/whatif.js +196 -0
@@ -0,0 +1,475 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The design-token compiler.
4
+ *
5
+ * node scripts/design-build.js compile design/tokens.yaml and write
6
+ * node scripts/design-build.js --check compile in memory, diff, run gates; exit 1 on drift or failure
7
+ *
8
+ * One source, three outputs:
9
+ * src/ui/styles.css a generated block between markers (the dashboard + offline snapshot)
10
+ * site/styles.css a generated block between markers (the landing page)
11
+ * menubar/TokenFlow/DesignTokens.swift a whole generated file (the menu bar)
12
+ *
13
+ * The blocks live INSIDE the existing stylesheets rather than in new files
14
+ * because the offline snapshot (src/export/html-snapshot.js) inlines exactly
15
+ * one stylesheet; a second file would silently break every exported page.
16
+ *
17
+ * Gates run on every compile and fail closed. They measure what a token file
18
+ * can measure — contrast, perceptual distance, monotonic ramps — and print
19
+ * what they cannot (rationing, hierarchy) as doctrine for review.
20
+ *
21
+ * Zero dependencies: the YAML is read with the project's own parser.
22
+ */
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import url from 'node:url';
26
+ import { parseYaml } from '../src/core/yaml.js';
27
+
28
+ const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
29
+ export const TOKENS_FILE = path.join(ROOT, 'design', 'tokens.yaml');
30
+ export const TARGETS = {
31
+ dashboard: path.join(ROOT, 'src', 'ui', 'styles.css'),
32
+ site: path.join(ROOT, 'site', 'styles.css'),
33
+ swift: path.join(ROOT, 'menubar', 'TokenFlow', 'DesignTokens.swift'),
34
+ };
35
+ export const START = '/* @generated design-tokens:start';
36
+ export const END = '/* @generated design-tokens:end */';
37
+
38
+ // ------------------------------------------------------------- colour math ---
39
+
40
+ export function hexToRgb(hex) {
41
+ const h = String(hex).trim().replace('#', '');
42
+ const s = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
43
+ if (!/^[0-9a-f]{6}$/i.test(s)) return null;
44
+ return [0, 2, 4].map((i) => parseInt(s.slice(i, i + 2), 16) / 255);
45
+ }
46
+ const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
47
+ export function luminance(hex) {
48
+ const rgb = hexToRgb(hex);
49
+ if (!rgb) return null;
50
+ const [r, g, b] = rgb.map(lin);
51
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
52
+ }
53
+ export function contrast(a, b) {
54
+ const la = luminance(a);
55
+ const lb = luminance(b);
56
+ if (la === null || lb === null) return null;
57
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la];
58
+ return (hi + 0.05) / (lo + 0.05);
59
+ }
60
+ export function lab(hex) {
61
+ const rgb = hexToRgb(hex);
62
+ if (!rgb) return null;
63
+ const [r, g, b] = rgb.map(lin);
64
+ const X = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047;
65
+ const Y = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 1.0;
66
+ const Z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883;
67
+ const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
68
+ return [116 * f(Y) - 16, 500 * (f(X) - f(Y)), 200 * (f(Y) - f(Z))];
69
+ }
70
+ /** CIE76 ΔE — the perceptual distance the design-kit ramp gate uses. */
71
+ export function deltaE(a, b) {
72
+ const A = lab(a);
73
+ const B = lab(b);
74
+ if (!A || !B) return null;
75
+ return Math.hypot(A[0] - B[0], A[1] - B[1], A[2] - B[2]);
76
+ }
77
+
78
+ // ------------------------------------------------------------------ gates ---
79
+
80
+ export const THRESHOLDS = {
81
+ inkPrimary: 7.0, // text-primary on plane (AAA for body)
82
+ inkSecondary: 4.5, // text-secondary on plane
83
+ inkMuted: 4.5, // text-muted on surface-1 (labels are 11.5px; no discount)
84
+ accentInk: 4.5, // button text on accent-solid
85
+ seriesOnSurface: 3.0, // a mark must separate from the chart surface
86
+ adjacentSeries: 20, // CIE76 ΔE between neighbouring series (design-kit gate)
87
+ firstThreeAllPairs: 20,
88
+ statusVsSeries: 12, // hard floor; below 20 is reported as a warning
89
+ divergingEnds: 40,
90
+ divergingMidChroma: 14, // |a|,|b| of the midpoint must stay near neutral
91
+ };
92
+
93
+ /**
94
+ * @param {object} t parsed tokens
95
+ * @returns {{failures:string[], warnings:string[], report:string[]}}
96
+ */
97
+ export function runGates(t) {
98
+ const failures = [];
99
+ const warnings = [];
100
+ const report = [];
101
+ const fail = (m) => failures.push(m);
102
+ const warn = (m) => warnings.push(m);
103
+ const fmt = (n) => (n === null ? 'n/a' : n.toFixed(2));
104
+
105
+ for (const [skinId, skin] of Object.entries(t.skins)) {
106
+ for (const mode of ['dark', 'light']) {
107
+ const c = skin[mode];
108
+ if (!c) { fail(`${skinId}: missing ${mode} block`); continue; }
109
+ for (const role of ['plane', 'surface-1', 'surface-2', 'surface-3', 'text-primary', 'text-secondary', 'text-muted', 'grid', 'axis', 'border', 'border-strong', 'hairline', 'accent', 'accent-solid', 'accent-ink', 'shadow', 'glow', 'hero-glow']) {
110
+ if (c[role] === undefined) fail(`${skinId}/${mode}: missing role ${role}`);
111
+ }
112
+ const where = `${skinId}/${mode}`;
113
+ const ip = contrast(c['text-primary'], c.plane);
114
+ const is = contrast(c['text-secondary'], c.plane);
115
+ const im = contrast(c['text-muted'], c['surface-1']);
116
+ const ai = contrast(c['accent-ink'], c['accent-solid']);
117
+ report.push(`${where}: ink primary ${fmt(ip)} · secondary ${fmt(is)} · muted ${fmt(im)} · accent-ink ${fmt(ai)}`);
118
+ if (ip < THRESHOLDS.inkPrimary) fail(`${where}: text-primary on plane ${fmt(ip)} < ${THRESHOLDS.inkPrimary}`);
119
+ if (is < THRESHOLDS.inkSecondary) fail(`${where}: text-secondary on plane ${fmt(is)} < ${THRESHOLDS.inkSecondary}`);
120
+ if (im < THRESHOLDS.inkMuted) fail(`${where}: text-muted on surface-1 ${fmt(im)} < ${THRESHOLDS.inkMuted}`);
121
+ if (ai < THRESHOLDS.accentInk) fail(`${where}: accent-ink on accent-solid ${fmt(ai)} < ${THRESHOLDS.accentInk}`);
122
+ // Lightness carries hierarchy. A card LIFTS off the plane toward the light
123
+ // in both modes; wells nested inside a card (surface-2, surface-3) step
124
+ // AWAY from the card — lighter still in dark mode, greyer in light mode.
125
+ const [Lp, L1, L2, L3] = [c.plane, c['surface-1'], c['surface-2'], c['surface-3']].map(luminance);
126
+ if (!(L1 > Lp)) fail(`${where}: surface-1 (${fmt(L1)}) does not lift off the plane (${fmt(Lp)})`);
127
+ const nests = mode === 'dark' ? (L2 > L1 && L3 > L2) : (L2 < L1 && L3 < L2);
128
+ if (!nests) fail(`${where}: surface-2/3 do not nest away from surface-1 in one direction (${[L1, L2, L3].map(fmt).join(' → ')})`);
129
+
130
+ // Series on this skin's chart surface.
131
+ const series = t.series[mode];
132
+ const relief = new Set((t.series.relief && t.series.relief[mode]) || []);
133
+ series.forEach((hex, i) => {
134
+ const cr = contrast(hex, c['surface-1']);
135
+ if (cr < THRESHOLDS.seriesOnSurface) {
136
+ const msg = `${where}: series-${i + 1} ${hex} on surface-1 is ${fmt(cr)} < ${THRESHOLDS.seriesOnSurface}`;
137
+ if (relief.has(i + 1)) warn(`${msg} (documented relief: table twin + direct labels)`); else fail(msg);
138
+ }
139
+ });
140
+ }
141
+ }
142
+
143
+ for (const mode of ['dark', 'light']) {
144
+ const s = t.series[mode];
145
+ if (!s || s.length !== 8) fail(`series/${mode}: expected 8 steps, got ${s ? s.length : 0}`);
146
+ for (let i = 1; i < s.length; i++) {
147
+ const d = deltaE(s[i - 1], s[i]);
148
+ if (d < THRESHOLDS.adjacentSeries) fail(`series/${mode}: slots ${i}–${i + 1} ΔE ${fmt(d)} < ${THRESHOLDS.adjacentSeries}`);
149
+ }
150
+ for (let i = 0; i < 3; i++) for (let j = i + 1; j < 3; j++) {
151
+ const d = deltaE(s[i], s[j]);
152
+ if (d < THRESHOLDS.firstThreeAllPairs) fail(`series/${mode}: slots ${i + 1}/${j + 1} (all-pairs set) ΔE ${fmt(d)} < ${THRESHOLDS.firstThreeAllPairs}`);
153
+ }
154
+ const worstAdj = Math.min(...s.slice(1).map((h, i) => deltaE(s[i], h)));
155
+ report.push(`series/${mode}: worst adjacent ΔE ${fmt(worstAdj)}`);
156
+
157
+ const seq = t.sequential[mode];
158
+ const Ls = seq.map(luminance);
159
+ const mono = Ls.every((v, i) => i === 0 || v > Ls[i - 1]) || Ls.every((v, i) => i === 0 || v < Ls[i - 1]);
160
+ if (!mono) fail(`sequential/${mode}: luminance is not monotonic`);
161
+
162
+ const div = t.diverging[mode];
163
+ if (!div || div.length !== 5) fail(`diverging/${mode}: expected 5 steps`);
164
+ else {
165
+ const ends = deltaE(div[0], div[4]);
166
+ if (ends < THRESHOLDS.divergingEnds) fail(`diverging/${mode}: ends ΔE ${fmt(ends)} < ${THRESHOLDS.divergingEnds}`);
167
+ const mid = lab(div[2]);
168
+ if (Math.abs(mid[1]) > THRESHOLDS.divergingMidChroma || Math.abs(mid[2]) > THRESHOLDS.divergingMidChroma) {
169
+ fail(`diverging/${mode}: midpoint ${div[2]} is not neutral (a ${fmt(mid[1])}, b ${fmt(mid[2])})`);
170
+ }
171
+ }
172
+
173
+ const statusRelief = new Set((t.status.relief && t.status.relief[mode]) || []);
174
+ for (const [name, hex] of Object.entries(t.status)) {
175
+ if (typeof hex !== 'string') continue; // the relief block, not a colour
176
+ s.forEach((sh, i) => {
177
+ const d = deltaE(hex, sh);
178
+ if (d < THRESHOLDS.statusVsSeries) {
179
+ const msg = `status/${name} vs series-${i + 1} (${mode}) ΔE ${fmt(d)} < ${THRESHOLDS.statusVsSeries}`;
180
+ if (statusRelief.has(name)) warn(`${msg} (documented relief: status is never a mark and always carries an icon or a word)`); else fail(msg);
181
+ } else if (d < 20) warn(`status/${name} vs series-${i + 1} (${mode}) ΔE ${fmt(d)} — close; status ships with icon + word`);
182
+ });
183
+ }
184
+ }
185
+
186
+ for (const [tier, m] of Object.entries(t.motion)) {
187
+ for (const [k, v] of Object.entries(m)) {
188
+ if (/^(press|fast|base|slow|stagger)$/.test(k) && !/^\d+ms$/.test(String(v))) fail(`motion/${tier}/${k}: "${v}" is not a millisecond value`);
189
+ }
190
+ if (tier === 'ui' && parseInt(m.slow, 10) > 300) fail(`motion/ui/slow ${m.slow} exceeds the 300ms ceiling for UI motion`);
191
+ }
192
+ return { failures, warnings, report };
193
+ }
194
+
195
+ // ------------------------------------------------------------- rendering ---
196
+
197
+ const ms = (v) => String(v);
198
+ const px = (v) => `${v}px`;
199
+
200
+ function rootBlock(t, { includeNarrative = true } = {}) {
201
+ const L = [];
202
+ L.push(` --sans: ${t.fonts.sans};`);
203
+ L.push(` --mono: ${t.fonts.mono};`);
204
+ L.push(` --serif: ${t.fonts.serif};`);
205
+ L.push(` --display: var(--sans);`);
206
+ L.push(` --figure: var(--sans);`);
207
+ L.push('');
208
+ L.push(' /* type scale — size-specific tracking and leading */');
209
+ for (const [k, v] of Object.entries(t.type)) {
210
+ L.push(` --fs-${k}: ${px(v.size)}; --tr-${k}: ${v.tracking}; --lh-${k}: ${v.leading};`);
211
+ }
212
+ L.push('');
213
+ L.push(` /* space */`);
214
+ L.push(' ' + t.space.map((n) => `--sp-${n}: ${px(n)};`).join(' '));
215
+ L.push('');
216
+ L.push(' /* radius */');
217
+ L.push(` --radius-xs: ${px(t.radius.xs)}; --radius-sm: ${px(t.radius.sm)}; --radius: ${px(t.radius.md)}; --radius-lg: ${px(t.radius.lg)}; --radius-pill: ${px(t.radius.pill)};`);
218
+ L.push('');
219
+ L.push(' /* motion · ui tier — nothing over 300ms, no animation on keyboard actions */');
220
+ const u = t.motion.ui;
221
+ L.push(` --dur-press: ${ms(u.press)}; --dur-fast: ${ms(u.fast)}; --dur-base: ${ms(u.base)}; --dur-slow: ${ms(u.slow)};`);
222
+ L.push(` --ease-out: ${u['ease-out']};`);
223
+ L.push(` --ease-in-out: ${u['ease-in-out']};`);
224
+ L.push(` --ease-drawer: ${u['ease-drawer']};`);
225
+ L.push(` --stagger: ${ms(u.stagger)}; --stagger-max: ${u['stagger-max']};`);
226
+ if (includeNarrative) {
227
+ const n = t.motion.narrative;
228
+ L.push(' /* motion · narrative tier — reveals on reading surfaces */');
229
+ L.push(` --nar-fast: ${ms(n.fast)}; --nar-base: ${ms(n.base)}; --nar-slow: ${ms(n.slow)};`);
230
+ L.push(` --nar-ease: ${n.ease};`);
231
+ L.push(` --nar-ease-out: ${n['ease-out']};`);
232
+ L.push(` --nar-stagger: ${ms(n.stagger)}; --nar-stagger-max: ${n['stagger-max']};`);
233
+ }
234
+ L.push('');
235
+ L.push(' /* status — reserved for state, never a series, never a mark, never themed */');
236
+ L.push(` --good: ${t.status.good};`);
237
+ L.push(` --warning: ${t.status.warning};`);
238
+ L.push(` --serious: ${t.status.serious};`);
239
+ L.push(` --critical: ${t.status.critical};`);
240
+ return L.join('\n');
241
+ }
242
+
243
+ function modeBlock(t, mode) {
244
+ const L = [];
245
+ L.push(` color-scheme: ${mode};`);
246
+ t.series[mode].forEach((h, i) => L.push(` --series-${i + 1}: ${h};`));
247
+ t.sequential[mode].forEach((h, i) => L.push(` --seq-${i + 1}: ${h};`));
248
+ t.diverging[mode].forEach((h, i) => L.push(` --div-${i + 1}: ${h};`));
249
+ return L.join('\n');
250
+ }
251
+
252
+ function skinRoles(c) {
253
+ const order = ['plane', 'surface-1', 'surface-2', 'surface-3', 'text-primary', 'text-secondary', 'text-muted', 'grid', 'axis', 'border', 'border-strong', 'hairline', 'accent', 'accent-solid', 'accent-ink', 'shadow', 'glow', 'hero-glow'];
254
+ return order.map((r) => ` --${r}: ${c[r]};`).join('\n');
255
+ }
256
+
257
+ function densityBlock(skin) {
258
+ const d = skin.density;
259
+ const L = [];
260
+ L.push(` --radius: ${px(d.radius)}; --radius-sm: ${px(d['radius-sm'])};`);
261
+ L.push(` --pad-card: ${d['pad-card']}; --gap-grid: ${d['gap-grid']}; --fig-scale: ${d['fig-scale']}; --sec-space: ${d['sec-space']}; --tracking-h: ${d['tracking-h']};`);
262
+ L.push(` --display: var(--${skin.display}); --figure: var(--${skin.figure});`);
263
+ return L.join('\n');
264
+ }
265
+
266
+ const header = (what) => `${START} — ${what}. Source: design/tokens.yaml · rebuild: npm run design. Hand edits here are overwritten and fail test/design.test.js. */`;
267
+
268
+ /** The dashboard's token layer: base roles, both modes, every skin × mode. */
269
+ export function renderDashboardCss(t) {
270
+ const L = [];
271
+ L.push(header('TokenFlow design tokens for the dashboard and the offline snapshot'));
272
+ L.push(':root {');
273
+ L.push(rootBlock(t));
274
+ L.push('}');
275
+ L.push('');
276
+ L.push('/* series / sequential / diverging steps belong to the MODE, never the skin */');
277
+ L.push(`:root, :root[data-mode='dark'] {\n${modeBlock(t, 'dark')}\n}`);
278
+ L.push(`:root[data-mode='light'] {\n${modeBlock(t, 'light')}\n}`);
279
+ L.push('');
280
+ for (const [id, skin] of Object.entries(t.skins)) {
281
+ L.push(`/* skin: ${id} — ${skin.note} */`);
282
+ const sel = skin.default ? `:root, :root[data-skin='${id}']` : `:root[data-skin='${id}']`;
283
+ L.push(`${sel} {\n${skinRoles(skin.dark)}\n${densityBlock(skin)}\n}`);
284
+ L.push(`:root[data-skin='${id}'][data-mode='light'] {\n${skinRoles(skin.light)}\n}`);
285
+ L.push('');
286
+ }
287
+ L.push(END);
288
+ return L.join('\n');
289
+ }
290
+
291
+ /** The landing page's token layer: one world, one mode, self-hosted faces. */
292
+ export function renderSiteCss(t) {
293
+ const l = t.landing;
294
+ const skin = t.skins[l.world];
295
+ const c = skin[l.mode];
296
+ const L = [];
297
+ L.push(header('TokenFlow design tokens for the landing page (single world)'));
298
+ for (const [role, f] of Object.entries(l.fonts)) {
299
+ for (const w of f.weights) {
300
+ L.push(`@font-face { font-family: '${f.family}'; font-style: normal; font-weight: ${w}; font-display: swap; src: url('fonts/${f.file}-${w}.woff2') format('woff2'); }`);
301
+ }
302
+ void role;
303
+ }
304
+ L.push('');
305
+ L.push(':root {');
306
+ L.push(rootBlock(t));
307
+ L.push('');
308
+ L.push(` --l-display: '${l.fonts.display.family}', var(--${l.fonts.display.fallback}); --l-body: '${l.fonts.body.family}', var(--${l.fonts.body.fallback}); --l-mono: '${l.fonts.mono.family}', var(--${l.fonts.mono.fallback});`);
309
+ L.push(` --l-tracking-display: ${l.fonts.display.tracking};`);
310
+ L.push('');
311
+ L.push(modeBlock(t, l.mode));
312
+ L.push('');
313
+ L.push(skinRoles(c));
314
+ L.push(densityBlock(skin));
315
+ L.push('}');
316
+ L.push(END);
317
+ return L.join('\n');
318
+ }
319
+
320
+ /** The menu bar's constants: the default skin in both modes, resolved by appearance. */
321
+ export function renderSwift(t) {
322
+ const skinId = Object.entries(t.skins).find(([, s]) => s.default)?.[0] || Object.keys(t.skins)[0];
323
+ const skin = t.skins[skinId];
324
+ const dyn = (role) => `Dyn(dark: "${skin.dark[role]}", light: "${skin.light[role]}")`;
325
+ const arr = (xs) => `[${xs.map((h) => `"${h}"`).join(', ')}]`;
326
+ const L = [];
327
+ L.push('// @generated by scripts/design-build.js from design/tokens.yaml — do not edit.');
328
+ L.push(`// Skin: ${skinId} (${skin.note}). The menu bar follows the system appearance,`);
329
+ L.push('// so every role is a dark/light pair resolved at draw time.');
330
+ L.push('import SwiftUI');
331
+ L.push('import AppKit');
332
+ L.push('');
333
+ L.push('enum DesignTokens {');
334
+ L.push(` static let version = ${t.version}`);
335
+ L.push('');
336
+ L.push(' struct Dyn {');
337
+ L.push(' let dark: String');
338
+ L.push(' let light: String');
339
+ L.push(' /// Follows the current appearance, including live switching.');
340
+ L.push(' var color: Color {');
341
+ L.push(' Color(nsColor: NSColor(name: nil) { appearance in');
342
+ L.push(' appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua');
343
+ L.push(' ? NSColor(dkHex: self.dark) : NSColor(dkHex: self.light)');
344
+ L.push(' })');
345
+ L.push(' }');
346
+ L.push(' func resolved(_ scheme: ColorScheme) -> Color {');
347
+ L.push(' Color(nsColor: NSColor(dkHex: scheme == .dark ? dark : light))');
348
+ L.push(' }');
349
+ L.push(' }');
350
+ L.push('');
351
+ L.push(' // surfaces and ink');
352
+ for (const role of ['plane', 'surface-1', 'surface-2', 'surface-3', 'text-primary', 'text-secondary', 'text-muted', 'grid', 'axis', 'accent', 'accent-solid', 'accent-ink']) {
353
+ const name = role.replace(/-(\w)/g, (_, ch) => ch.toUpperCase());
354
+ L.push(` static let ${name} = ${dyn(role)}`);
355
+ }
356
+ L.push('');
357
+ L.push(' // status — reserved for state, never a series, never a mark');
358
+ for (const [k, v] of Object.entries(t.status)) if (typeof v === 'string') L.push(` static let ${k} = Color(nsColor: NSColor(dkHex: "${v}"))`);
359
+ L.push('');
360
+ L.push(' // categorical series, fixed order, assigned by entity');
361
+ L.push(` static let seriesDark: [String] = ${arr(t.series.dark)}`);
362
+ L.push(` static let seriesLight: [String] = ${arr(t.series.light)}`);
363
+ L.push(' static func series(_ index: Int, _ scheme: ColorScheme) -> Color {');
364
+ L.push(' let steps = scheme == .dark ? seriesDark : seriesLight');
365
+ L.push(' return Color(nsColor: NSColor(dkHex: steps[index % steps.count]))');
366
+ L.push(' }');
367
+ L.push(' /// Appearance-following series step, for views that do not read a ColorScheme.');
368
+ L.push(' static func series(_ index: Int) -> Color {');
369
+ L.push(' Dyn(dark: seriesDark[index % seriesDark.count], light: seriesLight[index % seriesLight.count]).color');
370
+ L.push(' }');
371
+ L.push('');
372
+ L.push(' /// Raw status hexes for AppKit call sites (the status item title) that need an NSColor.');
373
+ L.push(' enum StatusHex {');
374
+ for (const [k, v] of Object.entries(t.status)) if (typeof v === 'string') L.push(` static let ${k} = "${v}"`);
375
+ L.push(' }');
376
+ L.push(` static let sequentialDark: [String] = ${arr(t.sequential.dark)}`);
377
+ L.push(` static let sequentialLight: [String] = ${arr(t.sequential.light)}`);
378
+ L.push('');
379
+ L.push(' // shape and space');
380
+ L.push(` static let radius: CGFloat = ${skin.density.radius}`);
381
+ L.push(` static let radiusSm: CGFloat = ${skin.density['radius-sm']}`);
382
+ L.push(` static let space: [CGFloat] = [${t.space.join(', ')}]`);
383
+ L.push('');
384
+ L.push(' // motion · ui tier (seconds)');
385
+ const sec = (v) => (parseInt(v, 10) / 1000).toFixed(3);
386
+ L.push(` static let durPress: Double = ${sec(t.motion.ui.press)}`);
387
+ L.push(` static let durFast: Double = ${sec(t.motion.ui.fast)}`);
388
+ L.push(` static let durBase: Double = ${sec(t.motion.ui.base)}`);
389
+ L.push(` static let durSlow: Double = ${sec(t.motion.ui.slow)}`);
390
+ L.push('');
391
+ L.push(' // type scale (points)');
392
+ for (const [k, v] of Object.entries(t.type)) L.push(` static let fs${k[0].toUpperCase()}${k.slice(1)}: CGFloat = ${v.size}`);
393
+ L.push('}');
394
+ L.push('');
395
+ L.push('extension NSColor {');
396
+ L.push(' /// "#rrggbb" → NSColor in sRGB. A malformed token is a build error upstream, so this never guesses.');
397
+ L.push(' convenience init(dkHex: String) {');
398
+ L.push(' var s = dkHex.trimmingCharacters(in: .whitespaces)');
399
+ L.push(' if s.hasPrefix("#") { s.removeFirst() }');
400
+ L.push(' let v = UInt32(s, radix: 16) ?? 0');
401
+ L.push(' self.init(srgbRed: CGFloat((v >> 16) & 0xff) / 255,');
402
+ L.push(' green: CGFloat((v >> 8) & 0xff) / 255,');
403
+ L.push(' blue: CGFloat(v & 0xff) / 255,');
404
+ L.push(' alpha: 1)');
405
+ L.push(' }');
406
+ L.push('}');
407
+ return L.join('\n') + '\n';
408
+ }
409
+
410
+ // ------------------------------------------------------------- assembly ---
411
+
412
+ export function loadTokens(file = TOKENS_FILE) {
413
+ return parseYaml(fs.readFileSync(file, 'utf8'));
414
+ }
415
+
416
+ /** Replace the generated block in a stylesheet; the rest of the file is hand-authored and untouched. */
417
+ export function splice(existing, block, label) {
418
+ const a = existing.indexOf(START);
419
+ const b = existing.indexOf(END);
420
+ if (a === -1 || b === -1 || b < a) {
421
+ throw new Error(`${label}: markers not found. Add a line "${START} */" and a line "${END}" where the tokens should live.`);
422
+ }
423
+ return existing.slice(0, a) + block + existing.slice(b + END.length);
424
+ }
425
+
426
+ export function compile(t = loadTokens()) {
427
+ const gates = runGates(t);
428
+ const outputs = {
429
+ dashboard: renderDashboardCss(t),
430
+ site: renderSiteCss(t),
431
+ swift: renderSwift(t),
432
+ };
433
+ return { tokens: t, gates, outputs };
434
+ }
435
+
436
+ /** What each target file would contain after compile. */
437
+ export function materialize(outputs, targets = TARGETS) {
438
+ const read = (f) => (fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '');
439
+ return {
440
+ dashboard: { file: targets.dashboard, next: splice(read(targets.dashboard), outputs.dashboard, 'src/ui/styles.css') },
441
+ site: { file: targets.site, next: splice(read(targets.site), outputs.site, 'site/styles.css') },
442
+ swift: { file: targets.swift, next: outputs.swift },
443
+ };
444
+ }
445
+
446
+ function main(argv) {
447
+ const check = argv.includes('--check');
448
+ const { gates, outputs } = compile();
449
+ for (const r of gates.report) console.log(` · ${r}`);
450
+ for (const w of gates.warnings) console.log(` ! ${w}`);
451
+ if (gates.failures.length) {
452
+ console.error(`\n${gates.failures.length} gate failure(s):`);
453
+ for (const f of gates.failures) console.error(` ✗ ${f}`);
454
+ process.exit(1);
455
+ }
456
+ const files = materialize(outputs);
457
+ let stale = 0;
458
+ for (const { file, next } of Object.values(files)) {
459
+ const cur = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
460
+ if (cur === next) continue;
461
+ stale++;
462
+ if (check) console.error(` ✗ ${path.relative(ROOT, file)} is out of date with design/tokens.yaml`);
463
+ else { fs.writeFileSync(file, next); console.log(` ✓ wrote ${path.relative(ROOT, file)}`); }
464
+ }
465
+ if (check) {
466
+ if (stale) { console.error('\nrun `npm run design` and commit the result'); process.exit(1); }
467
+ console.log(`\n✓ design tokens: gates pass, ${Object.keys(files).length} targets current`);
468
+ } else if (!stale) {
469
+ console.log(`\n✓ design tokens: gates pass, nothing to write`);
470
+ }
471
+ }
472
+
473
+ if (process.argv[1] && path.resolve(process.argv[1]) === url.fileURLToPath(import.meta.url)) {
474
+ main(process.argv.slice(2));
475
+ }