@trueclone/cli 0.0.0-stage → 0.1.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.
@@ -0,0 +1,491 @@
1
+ // One design system per site, from one canonical file.
2
+ // sites/<name>/design-tokens.json canonical: drafted from the capture, then reviewed/edited by hand
3
+ // sites/<name>/DESIGN.md Google design.md format (linted with @google/design.md)
4
+ // sites/<name>/globals.css shadcn/ui theme (Tailwind v4, oklch)
5
+ // sites/<name>/clone/tokens.css CSS variables the clone's own stylesheet must use
6
+ //
7
+ // usage: node scripts/design.mjs <name> render from design-tokens.json (drafts it first if missing)
8
+ // node scripts/design.mjs <name> --redraft write a fresh draft to design-tokens.draft.json (never overwrites edits)
9
+ import fs from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { spawnSync } from 'node:child_process';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { parseArgs } from './lib.mjs';
14
+
15
+ const { pos, flags } = parseArgs(process.argv.slice(2));
16
+ const [name] = pos;
17
+ if (!name) {
18
+ console.error('usage: npm run design -- <name> [--redraft]');
19
+ process.exit(1);
20
+ }
21
+ const root = path.resolve('sites', name);
22
+ const ref = path.join(root, 'reference');
23
+ const canonical = path.join(root, 'design-tokens.json');
24
+ const readJson = async (f, fallback) => { try { return JSON.parse(await fs.readFile(f, 'utf8')); } catch { return fallback; } };
25
+
26
+ // ======================= Colour maths =======================
27
+ const NAMED = { white: '#ffffff', black: '#000000', transparent: 'rgba(0, 0, 0, 0)' };
28
+ function parseColor(input) {
29
+ let s = String(input).trim().toLowerCase();
30
+ if (NAMED[s]) s = NAMED[s];
31
+ let m = s.match(/^#([0-9a-f]{3,8})$/);
32
+ if (m) {
33
+ let h = m[1];
34
+ if (h.length <= 4) h = [...h].map((c) => c + c).join('');
35
+ const n = (i) => parseInt(h.slice(i, i + 2), 16);
36
+ return { r: n(0), g: n(2), b: n(4), a: h.length === 8 ? +(n(6) / 255).toFixed(3) : 1 };
37
+ }
38
+ m = s.match(/^rgba?\(([^)]+)\)$/);
39
+ if (m) {
40
+ const p = m[1].split(/[\s,/]+/).filter(Boolean).map((v) => (v.endsWith('%') ? parseFloat(v) * 2.55 : parseFloat(v)));
41
+ return { r: p[0], g: p[1], b: p[2], a: p[3] === undefined ? 1 : (m[1].includes('%') && p[3] > 1 ? p[3] / 255 : p[3]) };
42
+ }
43
+ return null;
44
+ }
45
+ const hex2 = (n) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, '0');
46
+ const toHex = (c) => `#${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}${c.a < 1 ? hex2(c.a * 255) : ''}`;
47
+ const lin = (v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
48
+ function toOklch(c) {
49
+ const r = lin(c.r), g = lin(c.g), b = lin(c.b);
50
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
51
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
52
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
53
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
54
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
55
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
56
+ const C = Math.sqrt(A * A + B * B);
57
+ let H = (Math.atan2(B, A) * 180) / Math.PI;
58
+ if (H < 0) H += 360;
59
+ return { L, C, H: C < 0.0005 ? 0 : H, a: c.a };
60
+ }
61
+ const oklchCss = (c) => {
62
+ const o = toOklch(c);
63
+ const base = `oklch(${+o.L.toFixed(3)} ${+o.C.toFixed(3)} ${+o.H.toFixed(1)}`;
64
+ return o.a < 1 ? `${base} / ${Math.round(o.a * 100)}%)` : `${base})`;
65
+ };
66
+ const over = (fg, bg) => ({ r: fg.r * fg.a + bg.r * (1 - fg.a), g: fg.g * fg.a + bg.g * (1 - fg.a), b: fg.b * fg.a + bg.b * (1 - fg.a), a: 1 });
67
+ const luminance = (c) => 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b);
68
+ const contrast = (a, b) => { const x = luminance(a), y = luminance(b); return +((Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)).toFixed(2); };
69
+ const BLACK = parseColor('#000'), WHITE = parseColor('#fff');
70
+
71
+ // ======================= Draft from the capture =======================
72
+ async function draft() {
73
+ const meta = await readJson(path.join(ref, 'meta.json'), {});
74
+ const tokens = await readJson(path.join(ref, 'tokens.json'), null);
75
+ const spec = await readJson(path.join(ref, 'desktop.spec.json'), []);
76
+ if (!tokens) throw new Error(`no ${path.join(ref, 'tokens.json')}: run capture first`);
77
+ const rootPx = parseFloat(meta.rootFontSize?.desktop || tokens.rootFontSize || 16);
78
+ const rem = (px) => `${+(parseFloat(px) / rootPx).toFixed(4)}rem`;
79
+ const origin = 'captured';
80
+
81
+ // ---- colours: usage-weighted, roles inferred from lightness/chroma ----
82
+ const pool = new Map();
83
+ const add = (value, uses, kind) => {
84
+ const c = parseColor(value);
85
+ if (!c || c.a === 0) return;
86
+ const key = toHex(c);
87
+ const e = pool.get(key) || { c, hex: key, uses: 0, text: 0, bg: 0 };
88
+ e.uses += uses; e[kind] += uses;
89
+ pool.set(key, e);
90
+ };
91
+ const textChars = new Map();
92
+ const countText = (n) => { if (n.textLen && n.style?.color) textChars.set(n.style.color, (textChars.get(n.style.color) || 0) + n.textLen); (n.children || []).forEach(countText); };
93
+ spec.forEach(countText);
94
+ if (textChars.size) textChars.forEach((chars, color) => add(color, chars, 'text'));
95
+ else tokens.textColors.forEach((t) => add(t.value, t.uses, 'text'));
96
+ tokens.backgrounds.forEach((t) => add(t.value, t.uses, 'bg'));
97
+ const all = [...pool.values()].sort((a, b) => b.uses - a.uses);
98
+ const opaque = all.filter((e) => e.c.a === 1);
99
+ const lightness = (e) => toOklch(e.c).L;
100
+ const chroma = (e) => toOklch(e.c).C;
101
+ const bodyBg = parseColor(tokens.bodyBackground);
102
+ const surface = bodyBg && bodyBg.a === 1 ? { c: bodyBg, hex: toHex(bodyBg), uses: 1 } : opaque.find((e) => lightness(e) > 0.9) || { c: WHITE, hex: '#ffffff', generated: true };
103
+ const onSurface = opaque.filter((e) => e.text && contrast(e.c, surface.c) >= 4.5).sort((a, b) => b.text - a.text)[0] || { c: BLACK, hex: '#000000', generated: true };
104
+ const chromatic = opaque.filter((e) => chroma(e) > 0.06);
105
+ const primary = chromatic[0];
106
+ const inverse = opaque.filter((e) => e.bg >= 2 && lightness(e) < 0.25)[0];
107
+ const neutral = opaque.filter((e) => e.bg && e.hex !== surface.hex && lightness(e) > 0.85 && lightness(e) < 0.995 && chroma(e) < 0.02)[0];
108
+ const alphaDark = all.filter((e) => e.c.a < 1 && luminance(e.c) < 0.2 && e.c.a <= 0.2).sort((a, b) => b.bg - a.bg)[0];
109
+ const alphaLight = all.filter((e) => e.c.a < 1 && luminance(e.c) > 0.8).sort((a, b) => b.uses - a.uses)[0];
110
+ const mutedText = all.filter((e) => e.c.a < 1 && e.text && e.c.a >= 0.3).sort((a, b) => b.text - a.text)[0];
111
+
112
+ const colors = {};
113
+ const put = (key, e, role, gen = false) => {
114
+ if (!e) return;
115
+ colors[key] = { value: e.hex, role, uses: e.uses || 0, origin: gen || e.generated ? 'generated' : origin };
116
+ };
117
+ if (primary) {
118
+ put('primary', primary, 'Brand accent: calls to action, highlights, active states');
119
+ put('on-primary', { c: contrast(BLACK, primary.c) >= contrast(WHITE, primary.c) ? BLACK : WHITE, hex: contrast(BLACK, primary.c) >= contrast(WHITE, primary.c) ? '#000000' : '#ffffff', generated: true }, 'Text and icons on primary (best-contrast black or white)');
120
+ } else {
121
+ put('primary', onSurface, 'Monochrome site: the ink colour doubles as primary', true);
122
+ put('on-primary', { c: surface.c, hex: surface.hex }, 'Text on primary', true);
123
+ }
124
+ chromatic.slice(1, 3).forEach((e, i) => put(i ? 'tertiary' : 'secondary', e, 'Secondary accent'));
125
+ put('surface', surface, 'Default page background');
126
+ put('on-surface', onSurface, 'Default text colour on surface');
127
+ if (mutedText) put('on-surface-muted', mutedText, 'Secondary text, captions, metadata');
128
+ put('neutral', neutral, 'Quiet fills: secondary buttons, inputs, chips');
129
+ if (inverse) {
130
+ put('inverse-surface', inverse, 'Dark sections');
131
+ put('on-inverse-surface', { c: WHITE, hex: '#ffffff', generated: !opaque.some((e) => e.hex === '#ffffff' && e.text) }, 'Text on dark sections');
132
+ }
133
+ put('outline', alphaDark, 'Hairlines, borders and input fills on light surfaces');
134
+ put('outline-inverse', alphaLight, 'Hairlines on dark surfaces');
135
+ put('error', { c: parseColor('#dc2626'), hex: '#dc2626' }, 'Errors and destructive actions (not in source)', true);
136
+
137
+ // ---- typography: from measured text nodes, weighted by characters ----
138
+ const runs = new Map();
139
+ const walk = (n) => {
140
+ const s = n.style || {};
141
+ if (n.textLen && s['font-size']) {
142
+ const key = [s['font-family'], s['font-size'], s['line-height'], s['font-weight'] || '400', s['letter-spacing'] || 'normal', s['text-transform'] || 'none'].join('|');
143
+ const e = runs.get(key) || { key, chars: 0, tags: new Set() };
144
+ e.chars += n.textLen; e.tags.add(n.tag);
145
+ runs.set(key, e);
146
+ }
147
+ (n.children || []).forEach(walk);
148
+ };
149
+ spec.forEach(walk);
150
+ const inherit = (n, parent = {}) => { // spec omits inherited defaults; fill from parent chain
151
+ const s = { ...parent, ...(n.style || {}) };
152
+ n.style = s;
153
+ (n.children || []).forEach((k) => inherit(k, s));
154
+ };
155
+ if (!runs.size) { spec.forEach((n) => inherit(n)); spec.forEach(walk); }
156
+ const typeRuns = [...runs.values()].filter((e) => e.chars >= 12 || parseFloat(e.key.split('|')[1]) >= 24);
157
+ const fam = (e) => e.key.split('|')[0];
158
+ const sizeOf = (e) => parseFloat(e.key.split('|')[1]);
159
+ const famChars = new Map();
160
+ typeRuns.forEach((e) => famChars.set(fam(e), (famChars.get(fam(e)) || 0) + e.chars));
161
+ const bodyFamily = [...famChars].sort((a, b) => b[1] - a[1])[0]?.[0];
162
+ const headingFamily = [...new Set(typeRuns.filter((e) => fam(e) !== bodyFamily).sort((a, b) => sizeOf(b) - sizeOf(a)).map(fam))][0] || bodyFamily;
163
+ const typography = {};
164
+ const toType = (e) => {
165
+ const [family, size, lh, weight, ls, transform] = e.key.split('|');
166
+ const px = parseFloat(size);
167
+ const t = { fontFamily: family, fontSize: rem(px), fontWeight: Number(weight) || 400, lineHeight: lh === 'normal' ? 1.2 : +(parseFloat(lh) / px).toFixed(2) };
168
+ if (ls && ls !== 'normal' && parseFloat(ls) !== 0) t.letterSpacing = `${+(parseFloat(ls) / px).toFixed(3)}em`;
169
+ if (transform && transform !== 'none') t.textTransform = transform;
170
+ return { ...t, uses: e.chars, origin };
171
+ };
172
+ const dedupe = (list) => { const seen = new Set(); return list.filter((e) => { const k = `${fam(e)}|${sizeOf(e)}|${e.key.split('|')[3]}`; if (seen.has(k)) return false; seen.add(k); return true; }); };
173
+ const lhOf = (e) => { const lh = e.key.split('|')[2]; return lh === 'normal' ? 1.2 : parseFloat(lh) / sizeOf(e); };
174
+ const weightOf = (e) => Number(e.key.split('|')[3]) || 400;
175
+ const bySizeKeepMostUsed = (list) => { const m = new Map(); list.forEach((e) => { const k = sizeOf(e); if (!m.has(k) || m.get(k).chars < e.chars) m.set(k, e); }); return [...m.values()].sort((a, b) => sizeOf(b) - sizeOf(a)); };
176
+ if (headingFamily !== bodyFamily) {
177
+ const display = typeRuns.filter((e) => fam(e) === headingFamily);
178
+ const buttonish = bySizeKeepMostUsed(display.filter((e) => sizeOf(e) <= 18)).sort((a, b) => b.chars - a.chars)[0];
179
+ const heads = bySizeKeepMostUsed(display.filter((e) => sizeOf(e) > 18));
180
+ ['headline-xl', 'headline-lg', 'headline-md', 'headline-sm', 'headline-xs'].forEach((k, i) => { if (heads[i]) typography[k] = toType(heads[i]); });
181
+ if (buttonish) typography['label-button'] = toType(buttonish);
182
+ }
183
+ const bodyRuns = typeRuns.filter((e) => fam(e) === bodyFamily);
184
+ const base = [...bodyRuns].filter((e) => lhOf(e) > 1.1).sort((a, b) => b.chars - a.chars)[0] || [...bodyRuns].sort((a, b) => b.chars - a.chars)[0];
185
+ if (base) {
186
+ typography['body-md'] = toType(base);
187
+ const larger = bySizeKeepMostUsed(bodyRuns.filter((e) => sizeOf(e) > sizeOf(base)));
188
+ const smaller = bySizeKeepMostUsed(bodyRuns.filter((e) => sizeOf(e) < sizeOf(base)));
189
+ if (larger.length) typography['body-lg'] = toType(larger[larger.length - 1]);
190
+ if (smaller.length) typography['body-sm'] = toType(smaller[0]);
191
+ const strong = bodyRuns.filter((e) => sizeOf(e) === sizeOf(base) && weightOf(e) >= 600).sort((a, b) => b.chars - a.chars)[0];
192
+ if (strong) typography['body-md-strong'] = toType(strong);
193
+ const tight = bodyRuns.filter((e) => lhOf(e) <= 1.1).sort((a, b) => b.chars - a.chars)[0];
194
+ if (tight) typography['label-md'] = toType(tight);
195
+ }
196
+
197
+ // ---- spacing / layout from measured boxes ----
198
+ const hist = (vals) => { const m = new Map(); vals.forEach((v) => m.set(v, (m.get(v) || 0) + 1)); return [...m].sort((a, b) => b[1] - a[1]); };
199
+ const nodes = [];
200
+ const flat = (n) => { nodes.push(n); (n.children || []).forEach(flat); };
201
+ spec.forEach(flat);
202
+ const vw = Math.max(...spec.map((n) => n.box[2]));
203
+ const fullWidth = nodes.filter((n) => n.box[0] <= 1 && n.box[2] >= vw - 1 && n.style?.padding);
204
+ const pads = fullWidth.map((n) => n.style.padding.split(' ').map(parseFloat));
205
+ const sideMargin = hist(pads.map((p) => (p.length === 4 ? p[3] : p.length >= 2 ? p[1] : p[0])).filter((v) => v >= 16))[0]?.[0];
206
+ const sectionPads = hist(pads.map((p) => p[0]).filter((v) => v >= 48)).filter(([, n]) => n >= 2).slice(0, 3).map(([v]) => v).sort((a, b) => a - b);
207
+ const grids = nodes.filter((n) => n.style?.display === 'grid' && n.style['grid-template-columns']);
208
+ const cols = hist(grids.map((n) => n.style['grid-template-columns'].split(' ').length).filter((c) => c >= 4))[0]?.[0];
209
+ const gutter = hist(grids.map((n) => parseFloat(String(n.style.gap || '').split(' ').pop())).filter((v) => v > 0))[0]?.[0];
210
+ const gaps = hist(nodes.filter((n) => n.style?.display === 'flex' && n.style.gap).map((n) => parseFloat(n.style.gap))).slice(0, 4).map(([v]) => v).sort((a, b) => a - b);
211
+ const spacing = {};
212
+ if (sideMargin) spacing.margin = { value: rem(sideMargin), role: 'Page side margin', origin };
213
+ if (gutter) spacing.gutter = { value: rem(gutter), role: 'Grid column gap', origin };
214
+ if (cols) spacing.columns = { value: cols, role: 'Grid columns', origin };
215
+ ['xs', 'sm', 'md', 'lg'].forEach((k, i) => { if (gaps[i]) spacing[`gap-${k}`] = { value: rem(gaps[i]), role: 'Gap between stacked elements', origin }; });
216
+ ['section-sm', 'section-md', 'section-lg'].forEach((k, i) => { if (sectionPads[i]) spacing[k] = { value: rem(sectionPads[i]), role: 'Vertical section padding', origin }; });
217
+
218
+ // ---- radii ----
219
+ const radii = tokens.radii.map((r) => parseFloat(r.value)).filter((v) => !Number.isNaN(v) && String(v) === String(parseFloat(v)));
220
+ const distinct = [...new Set(radii)].sort((a, b) => a - b);
221
+ const rounded = {};
222
+ const rNames = ['sm', 'md', 'lg', 'xl', '2xl'];
223
+ distinct.filter((v) => v < 100).slice(0, 5).forEach((v, i) => { rounded[rNames[i]] = { value: rem(v), origin }; });
224
+ if (distinct.some((v) => v >= 100)) rounded.full = { value: '9999px', origin };
225
+
226
+ // ---- components (heuristic; review by hand) ----
227
+ const refColor = (val) => { const c = parseColor(val); if (!c) return val; const hex = toHex(c); const k = Object.keys(colors).find((key) => colors[key].value === hex); return k ? `{colors.${k}}` : hex; };
228
+ const refRadius = (val) => { const k = Object.keys(rounded).find((key) => rounded[key].value === rem(val)); return k ? `{rounded.${k}}` : rem(val); };
229
+ const components = {};
230
+ const findNode = (pred) => nodes.find(pred);
231
+ const btn = primary && findNode((n) => parseColor(n.style?.['background-color'] || '')?.r === primary.c.r && toHex(parseColor(n.style['background-color'])) === primary.hex && n.box[3] >= 28 && n.box[3] <= 64 && n.box[2] < 420);
232
+ if (btn) {
233
+ const host = findNode((n) => (n.children || []).includes(btn)) || btn;
234
+ const inner = findNode((n) => host.children?.includes(n) && n.style?.padding) || btn;
235
+ components['button-primary'] = { backgroundColor: '{colors.primary}', textColor: '{colors.on-primary}', rounded: refRadius(btn.style['border-radius'] || 0), padding: inner.style?.padding ? inner.style.padding.split(' ').map(rem).join(' ') : undefined, height: rem(btn.box[3]), typography: typography['label-button'] ? '{typography.label-button}' : typography['label-md'] ? '{typography.label-md}' : undefined, origin };
236
+ }
237
+ const sec = neutral && findNode((n) => n.style?.['background-color'] && toHex(parseColor(n.style['background-color'])) === neutral.hex && n.box[3] <= 64 && n.box[2] < 200);
238
+ if (sec) components['button-secondary'] = { backgroundColor: '{colors.neutral}', textColor: '{colors.on-surface}', rounded: refRadius(sec.style['border-radius'] || 0), padding: sec.style.padding ? sec.style.padding.split(' ').map(rem).join(' ') : undefined, height: rem(sec.box[3]), origin };
239
+ const input = findNode((n) => n.tag === 'input');
240
+ if (input) components['input-field'] = { backgroundColor: refColor(input.style['background-color'] || '#fff'), textColor: '{colors.on-surface}', rounded: refRadius(input.style['border-radius'] || 0), padding: input.style.padding ? input.style.padding.split(' ').map(rem).join(' ') : undefined, height: rem(input.box[3]), typography: typography['body-sm'] ? '{typography.body-sm}' : undefined, origin };
241
+ for (const c of Object.values(components)) for (const k of Object.keys(c)) if (c[k] === undefined) delete c[k];
242
+
243
+ // ---- shadcn theme mapping (light = page default, dark = the site's dark sections) ----
244
+ const g = (v) => ({ value: v, origin: 'generated' }); // explicit "not in source" values
245
+ const light = {
246
+ background: '{colors.surface}', foreground: '{colors.on-surface}',
247
+ card: '{colors.surface}', 'card-foreground': '{colors.on-surface}',
248
+ popover: '{colors.surface}', 'popover-foreground': '{colors.on-surface}',
249
+ primary: '{colors.primary}', 'primary-foreground': '{colors.on-primary}',
250
+ secondary: colors.neutral ? '{colors.neutral}' : g('#f4f4f5'), 'secondary-foreground': '{colors.on-surface}',
251
+ muted: colors.neutral ? '{colors.neutral}' : g('#f4f4f5'), 'muted-foreground': colors['on-surface-muted'] ? '{colors.on-surface-muted}@opaque' : g('#71717a'),
252
+ accent: colors.neutral ? '{colors.neutral}' : g('#f4f4f5'), 'accent-foreground': '{colors.on-surface}',
253
+ destructive: '{colors.error}',
254
+ border: colors.outline ? '{colors.outline}' : g('#e4e4e7'), input: colors.outline ? '{colors.outline}' : g('#e4e4e7'), ring: '{colors.primary}',
255
+ 'chart-1': '{colors.primary}', 'chart-2': colors.secondary ? '{colors.secondary}' : g('#2563eb'), 'chart-3': colors.tertiary ? '{colors.tertiary}' : g('#16a34a'), 'chart-4': g('#9333ea'), 'chart-5': g('#ea580c'),
256
+ sidebar: '{colors.surface}', 'sidebar-foreground': '{colors.on-surface}', 'sidebar-primary': '{colors.primary}', 'sidebar-primary-foreground': '{colors.on-primary}',
257
+ 'sidebar-accent': colors.neutral ? '{colors.neutral}' : g('#f4f4f5'), 'sidebar-accent-foreground': '{colors.on-surface}', 'sidebar-border': colors.outline ? '{colors.outline}' : g('#e4e4e7'), 'sidebar-ring': '{colors.primary}',
258
+ };
259
+ const darkBg = colors['inverse-surface'] ? '{colors.inverse-surface}' : g('#0a0a0a');
260
+ const darkFg = colors['on-inverse-surface'] ? '{colors.on-inverse-surface}' : g('#fafafa');
261
+ const darkLine = colors['outline-inverse'] ? '{colors.outline-inverse}' : g('rgba(255, 255, 255, 0.1)');
262
+ const dark = {
263
+ background: darkBg, foreground: darkFg, card: darkBg, 'card-foreground': darkFg, popover: darkBg, 'popover-foreground': darkFg,
264
+ primary: '{colors.primary}', 'primary-foreground': '{colors.on-primary}',
265
+ secondary: g('#262626'), 'secondary-foreground': darkFg, muted: g('#262626'), 'muted-foreground': g('#a3a3a3'), accent: g('#262626'), 'accent-foreground': darkFg,
266
+ destructive: '{colors.error}', border: darkLine, input: darkLine, ring: '{colors.primary}',
267
+ 'chart-1': '{colors.primary}', 'chart-2': light['chart-2'], 'chart-3': light['chart-3'], 'chart-4': light['chart-4'], 'chart-5': light['chart-5'],
268
+ sidebar: darkBg, 'sidebar-foreground': darkFg, 'sidebar-primary': '{colors.primary}', 'sidebar-primary-foreground': '{colors.on-primary}',
269
+ 'sidebar-accent': g('#262626'), 'sidebar-accent-foreground': darkFg, 'sidebar-border': darkLine, 'sidebar-ring': '{colors.primary}',
270
+ };
271
+ const radiusKey = rounded.md ? 'md' : rounded.sm ? 'sm' : null;
272
+
273
+ // ---- breakpoints from the site's stylesheets ----
274
+ const bps = new Map();
275
+ try {
276
+ for (const f of await fs.readdir(path.join(ref, 'assets', 'stylesheet'))) {
277
+ const css = await fs.readFile(path.join(ref, 'assets', 'stylesheet', f), 'utf8');
278
+ for (const m of css.matchAll(/@media[^{]*?\((?:max|min)-width:\s*([\d.]+)(px|em|rem)\)/g)) {
279
+ const px = m[2] === 'px' ? +m[1] : +m[1] * 16;
280
+ bps.set(px, (bps.get(px) || 0) + 1);
281
+ }
282
+ }
283
+ } catch { /* no stylesheets saved */ }
284
+
285
+ const sourceVars = Object.entries(tokens.cssVariables || {}).filter(([, v]) => parseColor(v)).slice(0, 40).map(([k, v]) => `${k}: ${v}`);
286
+ return {
287
+ $schema: 'rea-lab/design-tokens@1',
288
+ name: meta.mode === 'exact' ? (tokens.title || name) : `${name} (layout system)`,
289
+ description: `Design system reverse-engineered from ${meta.url || name}. Draft: review roles, names and notes before relying on it.`,
290
+ source: { url: meta.url, capturedAt: meta.capturedAt, mode: meta.mode || 'template' },
291
+ colors, typography, rounded, spacing, components,
292
+ theme: { light, dark, radius: radiusKey ? `{rounded.${radiusKey}}` : g('0.5rem'), fonts: { sans: bodyFamily, heading: headingFamily } },
293
+ layout: {
294
+ rootFontSize: `${rootPx}px`,
295
+ rootFontSizeByViewport: meta.rootFontSize || null,
296
+ breakpoints: [...bps].filter(([, n]) => n >= 2).map(([px]) => px).sort((a, b) => a - b),
297
+ },
298
+ notes: {
299
+ overview: '', colors: '', typography: '', layout: '', elevation: '', shapes: '', components: '',
300
+ dos: [], donts: [],
301
+ sourceVariables: sourceVars,
302
+ },
303
+ };
304
+ }
305
+
306
+ // ======================= Render =======================
307
+ const resolve = (val, t, depth = 0) => {
308
+ if (val && typeof val === 'object' && 'value' in val) return resolve(val.value, t, depth);
309
+ if (typeof val !== 'string') return val;
310
+ const opaque = val.endsWith('@opaque');
311
+ const m = val.replace('@opaque', '').match(/^\{([\w-]+)\.([\w-]+)\}$/);
312
+ if (!m || depth > 5) return val;
313
+ const node = t[m[1]]?.[m[2]];
314
+ let out = resolve(node && typeof node === 'object' && 'value' in node ? node.value : node, t, depth + 1);
315
+ if (opaque && typeof out === 'string') { // flatten alpha text onto the page background for shadcn's opaque foregrounds
316
+ const fg = parseColor(out), bg = parseColor(resolve('{colors.surface}', t));
317
+ if (fg && bg && fg.a < 1) out = toHex(over(fg, bg));
318
+ }
319
+ return out;
320
+ };
321
+ const isGenerated = (v, t) => (v && typeof v === 'object' && v.origin === 'generated')
322
+ || (typeof v === 'string' && /^\{colors\.([\w-]+)\}/.test(v) && t.colors[v.match(/^\{colors\.([\w-]+)\}/)[1]]?.origin === 'generated');
323
+
324
+ const yq = (v) => (typeof v === 'number' ? String(v) : JSON.stringify(String(v)));
325
+ function frontMatter(t) {
326
+ const L = ['---', 'version: alpha', `name: ${yq(t.name)}`, `description: ${yq(t.description)}`];
327
+ const section = (key, entries) => { if (!entries.length) return; L.push(`${key}:`); entries.forEach((e) => L.push(e)); };
328
+ const surf = parseColor(t.colors.surface?.value || '#fff');
329
+ section('colors', Object.entries(t.colors).map(([k, v]) => {
330
+ const c = parseColor(v.value);
331
+ const base = /inverse/.test(k) && t.colors['inverse-surface'] ? parseColor(t.colors['inverse-surface'].value) : surf;
332
+ return ' ' + k + ': ' + yq(c && c.a < 1 ? toHex(over(c, base)) : v.value);
333
+ }));
334
+ section('typography', Object.entries(t.typography).flatMap(([k, v]) => [` ${k}:`, ...['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing'].filter((p) => v[p] !== undefined).map((p) => ` ${p}: ${yq(v[p])}`)]));
335
+ section('rounded', Object.entries(t.rounded).map(([k, v]) => ` ${k}: ${yq(v.value)}`));
336
+ section('spacing', Object.entries(t.spacing).map(([k, v]) => ` ${k}: ${yq(v.value)}`));
337
+ section('components', Object.entries(t.components).flatMap(([k, v]) => [` ${k}:`, ...['backgroundColor', 'textColor', 'typography', 'rounded', 'padding', 'size', 'height', 'width'].filter((p) => v[p] !== undefined).map((p) => ` ${p}: ${yq(v[p])}`)]));
338
+ L.push('---');
339
+ return L.join('\n');
340
+ }
341
+
342
+ function designMd(t) {
343
+ const n = t.notes || {};
344
+ const flag = (o) => (o === 'generated' ? ' _(not in source, generated)_' : '');
345
+ const c = (k) => parseColor(resolve(`{colors.${k}}`, t));
346
+ const out = [frontMatter(t), '', `# ${t.name}`, ''];
347
+ const typeFamilies = [...new Set(Object.values(t.typography).map((v) => v.fontFamily.split(',')[0].trim()))];
348
+ out.push('## Overview', '', t.description, '',
349
+ `Source: ${t.source.url} (captured ${String(t.source.capturedAt || '').slice(0, 10)}, mode \`${t.source.mode}\`). Type families: ${typeFamilies.join(', ') || 'n/a'}. ${Object.keys(t.colors).length} colour tokens, ${Object.keys(t.typography).length} type levels.`, '');
350
+ if (n.overview) out.push(n.overview, '');
351
+
352
+ out.push('## Colors', '');
353
+ for (const [k, v] of Object.entries(t.colors)) {
354
+ const a = parseColor(v.value)?.a;
355
+ const alphaNote = a < 1 ? ' (' + Math.round(a * 100) + '% alpha; flattened onto its surface in the token list)' : '';
356
+ out.push('- **' + k + '** `' + v.value + '`' + alphaNote + ': ' + (v.role || '') + flag(v.origin));
357
+ }
358
+ out.push('');
359
+ if (n.colors) out.push(n.colors, '');
360
+ if (n.sourceVariables?.length) out.push('Colour variables declared by the source site (for traceability):', '', ...n.sourceVariables.map((s) => `- \`${s}\``), '');
361
+
362
+ out.push('## Typography', '');
363
+ for (const [k, v] of Object.entries(t.typography)) {
364
+ out.push(`- **${k}**: ${v.fontFamily.split(',')[0]} ${v.fontSize} / ${v.lineHeight}, weight ${v.fontWeight}${v.letterSpacing ? `, tracking ${v.letterSpacing}` : ''}${v.textTransform ? `, ${v.textTransform}` : ''}`);
365
+ }
366
+ out.push('');
367
+ if (n.typography) out.push(n.typography, '');
368
+
369
+ out.push('## Layout', '');
370
+ for (const [k, v] of Object.entries(t.spacing)) out.push(`- **${k}** \`${v.value}\`: ${v.role || ''}`);
371
+ out.push(`- Root font size ${t.layout.rootFontSize}${t.layout.rootFontSizeByViewport ? ` (by viewport: ${Object.entries(t.layout.rootFontSizeByViewport).map(([a, b]) => `${a} ${b}`).join(', ')}; every rem scales with it)` : ''}.`);
372
+ if (t.layout.breakpoints?.length) out.push(`- Breakpoints used by the source: ${t.layout.breakpoints.map((b) => `${b}px`).join(', ')}.`);
373
+ out.push('');
374
+ if (n.layout) out.push(n.layout, '');
375
+
376
+ out.push('## Elevation & Depth', '', n.elevation || 'No box shadows in the source. Depth comes from tonal contrast between light and dark sections, overlays and layering, not elevation.', '');
377
+
378
+ out.push('## Shapes', '');
379
+ for (const [k, v] of Object.entries(t.rounded)) out.push(`- **${k}** \`${v.value}\``);
380
+ out.push('');
381
+ if (n.shapes) out.push(n.shapes, '');
382
+
383
+ out.push('## Components', '');
384
+ for (const [k, v] of Object.entries(t.components)) {
385
+ const props = Object.entries(v).filter(([p]) => p !== 'origin').map(([p, val]) => `${p} \`${val}\``).join(', ');
386
+ out.push(`- **${k}**: ${props}`);
387
+ }
388
+ out.push('');
389
+ if (n.components) out.push(n.components, '');
390
+
391
+ out.push("## Do's and Don'ts", '');
392
+ const dos = [...(n.dos || [])], donts = [...(n.donts || [])];
393
+ if (c('primary') && c('on-primary')) dos.push(`Do pair **primary** with **on-primary** (contrast ${contrast(c('primary'), c('on-primary'))}:1).`);
394
+ if (c('primary') && c('surface')) {
395
+ const r = contrast(c('primary'), c('surface'));
396
+ (r < 3 ? donts : dos).push(`${r < 3 ? "Don't" : 'Do'} use **primary** as text or icons on **surface** (contrast ${r}:1${r < 3 ? ', fails WCAG' : ''}).`);
397
+ }
398
+ if (c('on-surface') && c('surface')) dos.push(`Do keep body text in **on-surface** on **surface** (contrast ${contrast(c('on-surface'), c('surface'))}:1).`);
399
+ dos.forEach((d) => out.push(`- ${d}`));
400
+ donts.forEach((d) => out.push(`- ${d}`));
401
+ out.push('');
402
+ return out.join('\n');
403
+ }
404
+
405
+ function brandKeys(t) {
406
+ const used = new Set((JSON.stringify(t.theme).match(/\{colors\.[\w-]+\}/g) || []).map((r) => r.slice(8, -1)));
407
+ return Object.keys(t.colors).filter((k) => !used.has(k));
408
+ }
409
+
410
+ function globalsCss(t) {
411
+ const val = (v) => resolve(v, t);
412
+ const css = (v) => { const r = val(v); const p = parseColor(r); return p ? oklchCss(p) : r; };
413
+ const line = (k, v) => ` --${k}: ${css(v)};${isGenerated(v, t) ? ' /* not in source */' : ''}`;
414
+ const radius = val(t.theme.radius);
415
+ const keys = Object.keys(t.theme.light);
416
+ const fonts = t.theme.fonts || {};
417
+ return [
418
+ `/* ${t.name}: shadcn/ui theme generated from design-tokens.json (rea-lab). Do not edit by hand; edit the tokens and re-run npm run design. */`,
419
+ '@import "tailwindcss";',
420
+ '@import "tw-animate-css";',
421
+ '',
422
+ '@custom-variant dark (&:is(.dark *));',
423
+ '',
424
+ '@theme inline {',
425
+ ...keys.map((k) => ` --color-${k}: var(--${k});`),
426
+ ...brandKeys(t).map((k) => ` --color-brand-${k}: var(--brand-${k});`),
427
+ ` --font-sans: ${fonts.sans || 'ui-sans-serif, system-ui, sans-serif'};`,
428
+ ` --font-heading: ${fonts.heading || fonts.sans || 'ui-sans-serif, system-ui, sans-serif'};`,
429
+ ' --radius-sm: calc(var(--radius) * 0.6);',
430
+ ' --radius-md: calc(var(--radius) * 0.8);',
431
+ ' --radius-lg: var(--radius);',
432
+ ' --radius-xl: calc(var(--radius) * 1.4);',
433
+ ' --radius-2xl: calc(var(--radius) * 1.8);',
434
+ ' --radius-3xl: calc(var(--radius) * 2.2);',
435
+ ' --radius-4xl: calc(var(--radius) * 2.6);',
436
+ '}',
437
+ '',
438
+ ':root {',
439
+ ` --radius: ${radius};${isGenerated(t.theme.radius, t) ? ' /* not in source */' : ''}`,
440
+ ...keys.map((k) => line(k, t.theme.light[k])),
441
+ ' /* brand colours from the source, beyond the shadcn roles */',
442
+ ...brandKeys(t).map((k) => ` --brand-${k}: ${oklchCss(parseColor(t.colors[k].value))};${t.colors[k].origin === 'generated' ? ' /* not in source */' : ''}`),
443
+ '}',
444
+ '',
445
+ '.dark {',
446
+ ...keys.map((k) => line(k, t.theme.dark[k])),
447
+ '}',
448
+ '',
449
+ '@layer base {',
450
+ ' * {',
451
+ ' @apply border-border outline-ring/50;',
452
+ ' }',
453
+ ' body {',
454
+ ' @apply bg-background text-foreground;',
455
+ ' }',
456
+ '}',
457
+ '',
458
+ ].join('\n');
459
+ }
460
+
461
+ function tokensCss(t) {
462
+ const L = [`/* ${t.name}: design tokens for the clone (generated from design-tokens.json; edit the JSON, not this file) */`, ':root {'];
463
+ for (const [k, v] of Object.entries(t.colors)) L.push(` --color-${k}: ${v.value};`);
464
+ for (const [k, v] of Object.entries(t.typography)) {
465
+ L.push(` --font-${k}: ${v.fontFamily};`, ` --text-${k}: ${v.fontSize};`, ` --leading-${k}: ${v.lineHeight};`, ` --weight-${k}: ${v.fontWeight};`);
466
+ if (v.letterSpacing) L.push(` --tracking-${k}: ${v.letterSpacing};`);
467
+ }
468
+ for (const [k, v] of Object.entries(t.rounded)) L.push(` --radius-${k}: ${v.value};`);
469
+ for (const [k, v] of Object.entries(t.spacing)) L.push(` --space-${k}: ${v.value};`);
470
+ L.push('}', '');
471
+ return L.join('\n');
472
+ }
473
+
474
+ // ======================= Main =======================
475
+ let tokens = await readJson(canonical, null);
476
+ if (!tokens || flags.redraft) {
477
+ const d = await draft();
478
+ const target = tokens ? path.join(root, 'design-tokens.draft.json') : canonical;
479
+ await fs.writeFile(target, JSON.stringify(d, null, 2));
480
+ console.log(`draft -> ${path.relative(process.cwd(), target)}${tokens ? ' (canonical file left untouched; merge by hand)' : ''}`);
481
+ if (!tokens) tokens = d;
482
+ }
483
+ await fs.writeFile(path.join(root, 'DESIGN.md'), designMd(tokens));
484
+ await fs.writeFile(path.join(root, 'globals.css'), globalsCss(tokens));
485
+ await fs.mkdir(path.join(root, 'clone'), { recursive: true });
486
+ await fs.writeFile(path.join(root, 'clone', 'tokens.css'), tokensCss(tokens));
487
+ console.log(`wrote DESIGN.md, globals.css, clone/tokens.css for ${name}`);
488
+
489
+ const lint = spawnSync(process.execPath, [fileURLToPath(import.meta.resolve('@google/design.md')), 'lint', path.join(root, 'DESIGN.md')], { encoding: 'utf8' });
490
+ console.log('--- @google/design.md lint ---');
491
+ console.log((lint.stdout || '') + (lint.stderr || ''));