prism-design 2.13.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 (90) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/LICENSE +21 -0
  3. package/README.md +203 -0
  4. package/bin/clone-architect.mjs +476 -0
  5. package/bin/prism.mjs +467 -0
  6. package/catalog/index.json +1155 -0
  7. package/extractions/airbnb.com/DESIGN.md +1068 -0
  8. package/extractions/airbnb.com/tokens.json +507 -0
  9. package/extractions/attio.com/DESIGN.md +1295 -0
  10. package/extractions/attio.com/tokens.json +438 -0
  11. package/extractions/auroxdashboard.com/DESIGN.md +724 -0
  12. package/extractions/auroxdashboard.com/tokens.json +195 -0
  13. package/extractions/careerexplorer.com/DESIGN.md +1178 -0
  14. package/extractions/careerexplorer.com/tokens.json +141 -0
  15. package/extractions/chance.co/DESIGN.md +1209 -0
  16. package/extractions/chance.co/tokens.json +160 -0
  17. package/extractions/choisis-ton-avenir.com/DESIGN.md +1265 -0
  18. package/extractions/choisis-ton-avenir.com/tokens.json +227 -0
  19. package/extractions/example.com/DESIGN.md +436 -0
  20. package/extractions/example.com/tokens.json +91 -0
  21. package/extractions/getdesign.md/DESIGN.md +1009 -0
  22. package/extractions/getdesign.md/tokens.json +219 -0
  23. package/extractions/github.com/DESIGN.md +1130 -0
  24. package/extractions/github.com/tokens.json +2092 -0
  25. package/extractions/hello-charly.com/DESIGN.md +1146 -0
  26. package/extractions/hello-charly.com/tokens.json +322 -0
  27. package/extractions/hyperliquid.xyz/DESIGN.md +779 -0
  28. package/extractions/hyperliquid.xyz/tokens.json +598 -0
  29. package/extractions/instagram.com/DESIGN.md +996 -0
  30. package/extractions/instagram.com/tokens.json +1240 -0
  31. package/extractions/jobirl.com/DESIGN.md +1160 -0
  32. package/extractions/jobirl.com/tokens.json +139 -0
  33. package/extractions/life360.com/DESIGN.md +1133 -0
  34. package/extractions/life360.com/tokens.json +491 -0
  35. package/extractions/lifesum.com/DESIGN.md +965 -0
  36. package/extractions/lifesum.com/tokens.json +170 -0
  37. package/extractions/linear.app/DESIGN.md +1301 -0
  38. package/extractions/linear.app/tokens.json +732 -0
  39. package/extractions/mavoie.org/DESIGN.md +1148 -0
  40. package/extractions/mavoie.org/tokens.json +128 -0
  41. package/extractions/miro.com/DESIGN.md +1237 -0
  42. package/extractions/miro.com/tokens.json +401 -0
  43. package/extractions/notion.so/DESIGN.md +1319 -0
  44. package/extractions/notion.so/tokens.json +906 -0
  45. package/extractions/onetonline.org/DESIGN.md +909 -0
  46. package/extractions/onetonline.org/tokens.json +280 -0
  47. package/extractions/posthog.com/DESIGN.md +1024 -0
  48. package/extractions/posthog.com/tokens.json +197 -0
  49. package/extractions/revolut.com/DESIGN.md +1080 -0
  50. package/extractions/revolut.com/tokens.json +401 -0
  51. package/extractions/stripe.com/DESIGN.md +1272 -0
  52. package/extractions/stripe.com/tokens.json +794 -0
  53. package/extractions/switchcollective.com/DESIGN.md +1040 -0
  54. package/extractions/switchcollective.com/tokens.json +98 -0
  55. package/extractions/truity.com/DESIGN.md +970 -0
  56. package/extractions/truity.com/tokens.json +166 -0
  57. package/extractions/uniquekicks.be/DESIGN.md +1171 -0
  58. package/extractions/uniquekicks.be/tokens.json +237 -0
  59. package/package.json +122 -0
  60. package/scripts/analyze.ts +281 -0
  61. package/scripts/bank-register.ts +379 -0
  62. package/scripts/bank.ts +374 -0
  63. package/scripts/browser-stealth.ts +189 -0
  64. package/scripts/clone.ts +198 -0
  65. package/scripts/compare-vs-gd-final.ts +273 -0
  66. package/scripts/compare-vs-gd.ts +269 -0
  67. package/scripts/compare.ts +405 -0
  68. package/scripts/deploy-site.ts +181 -0
  69. package/scripts/diff-snapshots.ts +340 -0
  70. package/scripts/enrich-catalog.ts +212 -0
  71. package/scripts/extract.ts +2038 -0
  72. package/scripts/extractors/advanced.ts +524 -0
  73. package/scripts/extractors/widgets.ts +711 -0
  74. package/scripts/generate-design-md.ts +5775 -0
  75. package/scripts/generate-final-pdf.ts +274 -0
  76. package/scripts/generate-og-image.ts +87 -0
  77. package/scripts/generate-showcase.ts +1588 -0
  78. package/scripts/generate-site.ts +847 -0
  79. package/scripts/mass-extract.sh +91 -0
  80. package/scripts/post-process-all.sh +55 -0
  81. package/scripts/regen-catalog.ts +203 -0
  82. package/scripts/shared/cache.ts +149 -0
  83. package/scripts/shared/css-helpers.ts +263 -0
  84. package/scripts/shared/logger.ts +57 -0
  85. package/scripts/shared/named-colors.ts +355 -0
  86. package/scripts/shared/types.ts +220 -0
  87. package/scripts/sync-catalog.ts +105 -0
  88. package/scripts/tokenize.ts +988 -0
  89. package/templates/layout-template.md +52 -0
  90. package/templates/tokens-template.json +34 -0
@@ -0,0 +1,988 @@
1
+ /**
2
+ * Prism — Tokenizer
3
+ *
4
+ * Convertit raw-css.json en tokens.json structuré et normalisé.
5
+ * Chaque token vient EXCLUSIVEMENT de l'extraction — aucune valeur inventée.
6
+ */
7
+
8
+ import { readFile, writeFile } from 'fs/promises';
9
+ import { join } from 'path';
10
+
11
+ interface DesignTokens {
12
+ meta: {
13
+ source: string;
14
+ domain: string;
15
+ extractedAt: string;
16
+ tokenizedAt: string;
17
+ };
18
+ colors: {
19
+ background: { primary: string; secondary: string; tertiary: string };
20
+ text: { primary: string; secondary: string; muted: string };
21
+ accent: { primary: string | null; secondary: string | null };
22
+ border: string;
23
+ shadow: string;
24
+ semantic?: { error?: string; success?: string; warning?: string; info?: string };
25
+ };
26
+ typography: {
27
+ fontFamily: { primary: string; secondary: string; mono: string };
28
+ fontSize: { xs: string; sm: string; base: string; lg: string; xl: string; '2xl': string; '3xl': string; '4xl': string };
29
+ fontWeight: { normal: string; medium: string; semibold: string; bold: string };
30
+ lineHeight: { tight: string; normal: string; relaxed: string };
31
+ letterSpacing: { tight: string; normal: string; wide: string };
32
+ };
33
+ spacing: {
34
+ xxs: string; xs: string; sm: string; md: string; base: string; lg: string; xl: string; '2xl': string; '3xl': string;
35
+ };
36
+ borderRadius: {
37
+ none: string; xs: string; sm: string; md: string; lg: string; xl: string; full: string;
38
+ };
39
+ shadows: Record<string, string>;
40
+ transitions: Record<string, string>;
41
+ layout: {
42
+ maxWidth: string;
43
+ headerHeight: string;
44
+ sidebarWidth: string;
45
+ gap: string;
46
+ containerPadding: string;
47
+ };
48
+ cssCustomProperties: Record<string, string>;
49
+ }
50
+
51
+ import {
52
+ parseRgb as parseRgbShared,
53
+ luminanceSimple as luminance,
54
+ parsePixels,
55
+ cleanColorValue,
56
+ } from './shared/css-helpers.js';
57
+
58
+ /** tokenize variant: gère CSS shorthand multi-value via first-color split */
59
+ function parseRgb(color: string): [number, number, number] | null {
60
+ if (!color) return null;
61
+ const firstColor = color.split(/\s+(?=rgb)/)[0].trim();
62
+ return parseRgbShared(firstColor);
63
+ }
64
+
65
+ function sortByPixels(values: string[]): string[] {
66
+ return [...values].filter(v => v.includes('px')).sort((a, b) => parsePixels(a) - parsePixels(b));
67
+ }
68
+
69
+ function pickClosest(values: string[], targetPx: number): string {
70
+ if (values.length === 0) return `${targetPx}px`;
71
+ let best = values[0];
72
+ let bestDist = Math.abs(parsePixels(best) - targetPx);
73
+ for (const v of values) {
74
+ const dist = Math.abs(parsePixels(v) - targetPx);
75
+ if (dist < bestDist) { best = v; bestDist = dist; }
76
+ }
77
+ return best;
78
+ }
79
+
80
+ /**
81
+ * Build a distinct spacing scale: for each target, pick the closest value
82
+ * BUT prefer values not already assigned to a smaller step (ensures md ≠ lg ≠ xl).
83
+ * If no distinct value exists (sparse data), synthesize a fallback via target × ratio.
84
+ */
85
+ function pickDistinctScale(values: string[], targets: number[]): string[] {
86
+ const distinct = Array.from(new Set(values)).sort((a, b) => parsePixels(a) - parsePixels(b));
87
+ const result: string[] = [];
88
+ const used = new Set<string>();
89
+
90
+ for (let i = 0; i < targets.length; i++) {
91
+ const target = targets[i];
92
+ // Find unused values, pick the closest to target
93
+ const available = distinct.filter(v => !used.has(v));
94
+ if (available.length === 0) {
95
+ // Out of distinct values: synthesize from target (8px scale: 4, 8, 12, 16, 24, 32, 48, 64)
96
+ result.push(`${target}px`);
97
+ continue;
98
+ }
99
+ let best = available[0];
100
+ let bestDist = Math.abs(parsePixels(best) - target);
101
+ for (const v of available) {
102
+ const dist = Math.abs(parsePixels(v) - target);
103
+ // Prefer values ≥ previous step (preserves monotonic scale)
104
+ if (i > 0) {
105
+ const prevPx = parsePixels(result[i - 1]);
106
+ if (parsePixels(v) < prevPx) continue;
107
+ }
108
+ if (dist < bestDist) { best = v; bestDist = dist; }
109
+ }
110
+ // Validate: if best is < previous step (regression), synthesize from target
111
+ if (i > 0 && parsePixels(best) <= parsePixels(result[i - 1])) {
112
+ // Try next-larger value in available
113
+ const next = available.find(v => parsePixels(v) > parsePixels(result[i - 1]));
114
+ if (next) { result.push(next); used.add(next); continue; }
115
+ result.push(`${target}px`);
116
+ continue;
117
+ }
118
+ result.push(best);
119
+ used.add(best);
120
+ }
121
+ // Phase 5.2.4 — Final monotonicity sweep: walk result, replace any step that's ≤ previous
122
+ // with max(target, prevPx+8). Using just targets[i] was the bug: if target < prevPx the sweep
123
+ // left the sequence non-monotone (Stripe 2xl=71→3xl=64, switchcollective xl=72→2xl=48 cases).
124
+ for (let i = 1; i < result.length; i++) {
125
+ if (parsePixels(result[i]) <= parsePixels(result[i - 1])) {
126
+ const prevPx = parsePixels(result[i - 1]);
127
+ result[i] = `${Math.max(targets[i], prevPx + 8)}px`;
128
+ }
129
+ }
130
+ return result;
131
+ }
132
+
133
+ function normalizeLineHeight(lineHeight: string | undefined, fontSize: string | undefined): string | null {
134
+ if (!lineHeight) return null;
135
+ // Already a ratio (no unit or unitless number)
136
+ if (!lineHeight.includes('px') && !lineHeight.includes('em')) {
137
+ const num = parseFloat(lineHeight);
138
+ return isNaN(num) ? null : num.toFixed(2).replace(/\.?0+$/, '');
139
+ }
140
+ // Pixel value — divide by fontSize to get ratio
141
+ const lhPx = parsePixels(lineHeight);
142
+ const fsPx = fontSize ? parsePixels(fontSize) : 0;
143
+ if (lhPx > 0 && fsPx > 0) {
144
+ const ratio = lhPx / fsPx;
145
+ // Round to 2 decimal places, strip trailing zeros
146
+ return ratio.toFixed(2).replace(/\.?0+$/, '');
147
+ }
148
+ return null;
149
+ }
150
+
151
+ function getSaturation(rgb: [number, number, number]): number {
152
+ return Math.max(...rgb) - Math.min(...rgb);
153
+ }
154
+
155
+ /**
156
+ * Resolve container max-width. CSS computed `main.maxWidth` is often `none`
157
+ * because the limit lives on a child container or in a CSS variable.
158
+ * Strategy: 1) trust element if explicit, 2) scan CSS vars for max-width
159
+ * tokens (homepage > page > container > content > prose priority), 3) eval
160
+ * calc() expressions if encountered.
161
+ */
162
+ function resolveMaxWidth(elements: any, cssVars: Record<string, string>): string {
163
+ const main = elements.main?.styles?.maxWidth;
164
+ if (main && main !== 'none' && main !== 'unset' && main !== 'auto') return main;
165
+
166
+ // Try common semantic container selectors first
167
+ for (const sel of ['hero', 'header', 'footer']) {
168
+ const w = elements[sel]?.styles?.maxWidth;
169
+ if (w && w !== 'none' && w !== 'unset' && w !== 'auto') return evalCalc(w);
170
+ }
171
+
172
+ // Scan CSS vars by priority — most-specific brand names win
173
+ const priorityOrder: RegExp[] = [
174
+ /homepage-?(max-?)?width/i,
175
+ /page-?(max-?)?width/i,
176
+ /site-?(max-?)?width/i,
177
+ /container-?(max-?)?width/i,
178
+ /content-?(max-?)?width/i,
179
+ /max-?width/i,
180
+ ];
181
+ for (const re of priorityOrder) {
182
+ for (const [key, value] of Object.entries(cssVars)) {
183
+ if (re.test(key) && typeof value === 'string' && value.trim()) {
184
+ const resolved = evalCalc(value.trim());
185
+ if (resolved && resolved !== 'none') return resolved;
186
+ }
187
+ }
188
+ }
189
+
190
+ // Last resort
191
+ return '1200px';
192
+ }
193
+
194
+ /** Evaluate a simple CSS calc(X + Y * Z) into a px value if possible. */
195
+ function evalCalc(value: string): string {
196
+ if (!value.includes('calc(')) return value;
197
+ const inner = value.match(/calc\(([^()]+)\)/)?.[1];
198
+ if (!inner) return value;
199
+ try {
200
+ // Strip "px" units, evaluate, re-append "px"
201
+ const expr = inner.replace(/px/g, '').replace(/\s+/g, ' ');
202
+ // Defensive: only allow digits, +, -, *, /, ., (, ), spaces
203
+ if (!/^[\d+\-*/.()\s]+$/.test(expr)) return value;
204
+ const result = Function(`"use strict"; return (${expr})`)();
205
+ if (typeof result === 'number' && isFinite(result) && result > 0) return `${Math.round(result)}px`;
206
+ } catch { /* fallthrough */ }
207
+ return value;
208
+ }
209
+
210
+ /**
211
+ * Pick a text.secondary that is GUARANTEED visually distinct from primary.
212
+ * Fix (audit-2026-05-28): "text.secondary dégénère à white/black 100% des cas"
213
+ * because classified.text[1] often equals bodyColor (no real second tier found).
214
+ *
215
+ * Strategy:
216
+ * 1. Walk classified.text for a value with luminance diff > 0.12 from primary
217
+ * 2. If none found, synthesize via rgba alpha (0.65 for opaque colors)
218
+ */
219
+ function pickDistinctTextSecondary(
220
+ textColors: string[],
221
+ primary: string,
222
+ isDark: boolean
223
+ ): string {
224
+ const primaryRgb = parseRgb(primary);
225
+ if (!primaryRgb) return textColors[1] || textColors[0] || primary;
226
+ const primaryLum = luminance(primaryRgb);
227
+
228
+ for (const candidate of textColors) {
229
+ const candRgb = parseRgb(candidate);
230
+ if (!candRgb) continue;
231
+ const candLum = luminance(candRgb);
232
+ if (Math.abs(candLum - primaryLum) > 0.12) {
233
+ return candidate;
234
+ }
235
+ }
236
+
237
+ // Synthesize via alpha blend (preserves brand color tone)
238
+ const [r, g, b] = primaryRgb;
239
+ return `rgba(${r}, ${g}, ${b}, 0.65)`;
240
+ }
241
+
242
+ /**
243
+ * Deduplicate colors perceptually — merge near-identical colors.
244
+ * Uses simple RGB distance (good enough for dedup); cluster representative = first occurrence.
245
+ * Threshold ~15 means colors with ΔRGB < 15 are considered "same color" (build noise, sub-pixel).
246
+ */
247
+ function dedupePerceptual(colors: string[], threshold = 12): string[] {
248
+ const reps: Array<{ raw: string; rgb: [number, number, number] }> = [];
249
+ for (const c of colors) {
250
+ const rgb = parseRgb(c);
251
+ if (!rgb) continue;
252
+ let isDup = false;
253
+ for (const rep of reps) {
254
+ const d = Math.sqrt(
255
+ Math.pow(rgb[0] - rep.rgb[0], 2) +
256
+ Math.pow(rgb[1] - rep.rgb[1], 2) +
257
+ Math.pow(rgb[2] - rep.rgb[2], 2)
258
+ );
259
+ if (d < threshold) { isDup = true; break; }
260
+ }
261
+ if (!isDup) reps.push({ raw: c, rgb: [rgb[0], rgb[1], rgb[2]] });
262
+ }
263
+ return reps.map(r => r.raw);
264
+ }
265
+
266
+ function classifyColors(
267
+ colors: string[],
268
+ bodyBgColor?: string,
269
+ ): { backgrounds: string[]; text: string[]; accents: string[]; border: string[] } {
270
+ const backgrounds: string[] = [];
271
+ const text: string[] = [];
272
+ const accents: string[] = [];
273
+ const border: string[] = [];
274
+
275
+ const seen = new Set<string>();
276
+
277
+ // Detect if site is dark-mode from body bg
278
+ const bodyBgRgb = bodyBgColor ? parseRgb(bodyBgColor) : null;
279
+ const isDark = bodyBgRgb ? luminance(bodyBgRgb) < 0.3 : false;
280
+
281
+ for (const c of colors) {
282
+ const norm = c.replace(/\s+/g, '');
283
+ if (seen.has(norm)) continue;
284
+ seen.add(norm);
285
+
286
+ const rgb = parseRgb(c);
287
+ if (!rgb) continue;
288
+ const lum = luminance(rgb);
289
+ const sat = getSaturation(rgb);
290
+
291
+ // Saturated colors are always accents regardless of mode
292
+ if (sat > 50 && lum > 0.1 && lum < 0.9) {
293
+ accents.push(c);
294
+ continue;
295
+ }
296
+
297
+ if (isDark) {
298
+ // Dark site: dark = backgrounds/surfaces, light = text, semi-transparent borders
299
+ if (lum < 0.1) backgrounds.push(c); // near-black backgrounds
300
+ else if (lum < 0.3) backgrounds.push(c); // dark elevated surfaces
301
+ else if (lum > 0.75) text.push(c); // light text on dark bg
302
+ else if (lum > 0.45 && lum <= 0.75) text.push(c); // muted text (secondary, tertiary)
303
+ else border.push(c); // mid tones = borders/dividers
304
+ } else {
305
+ // Light site: light = backgrounds, dark = text, near-white borders
306
+ if (lum > 0.9) backgrounds.push(c);
307
+ else if (lum > 0.7) border.push(c);
308
+ else if (lum < 0.15) text.push(c);
309
+ // Mid-tones (lum 0.15–0.7): accents ONLY if saturated (sat > 30)
310
+ // Otherwise neutral grays → borders/dividers, NOT accents
311
+ else if (sat > 30) accents.push(c);
312
+ else border.push(c);
313
+ }
314
+ }
315
+
316
+ return { backgrounds, text, accents, border };
317
+ }
318
+
319
+ async function tokenize(domain: string): Promise<void> {
320
+ const baseDir = join(process.cwd(), 'extractions', domain);
321
+ const rawPath = join(baseDir, 'raw-css.json');
322
+
323
+ console.log(`\n🔧 Tokenizing ${domain}...`);
324
+
325
+ const raw = JSON.parse(await readFile(rawPath, 'utf-8'));
326
+ const data = raw.desktop;
327
+ if (!data) { console.error('No desktop data'); process.exit(1); }
328
+
329
+ const { elements, allColors, allFontFamilies, allFontSizes, allBorderRadii, allShadows, allTransitions, cssCustomProperties } = data;
330
+
331
+ // ── Colors — detect dark mode before classification ──
332
+ // Handle transparent body backgrounds (common on Shopify, SPAs)
333
+ // If body bg is transparent, find the first non-transparent bg from sections/elements
334
+ function resolveBodyBackground(): string {
335
+ const rawBg = elements.body?.styles?.backgroundColor;
336
+ const isTransparent = !rawBg || rawBg === 'rgba(0, 0, 0, 0)' || rawBg === 'transparent';
337
+ if (!isTransparent) return rawBg;
338
+
339
+ // Gradient fallback: extract first color from backgroundImage (e.g. Vercel, Linear dark mode)
340
+ const bgImage = elements.body?.styles?.backgroundImage ||
341
+ (elements as any).main?.styles?.backgroundImage;
342
+ if (bgImage && bgImage !== 'none') {
343
+ const firstColor = bgImage.match(/rgb[a]?\([^)]+\)/)?.[0];
344
+ if (firstColor) return firstColor;
345
+ }
346
+
347
+ // CSS var fallback: check common background CSS var names
348
+ const bgVarKeys = ['--background', '--bg', '--color-background', '--surface', '--page-bg', '--bg-primary'];
349
+ for (const key of bgVarKeys) {
350
+ const v = String((cssCustomProperties as Record<string, string>)[key] || '').trim();
351
+ if (!v) continue;
352
+ if (parseRgb(v)) return v;
353
+ const hm = v.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
354
+ if (hm) return v; // return hex as-is, parseRgb caller handles hex
355
+ }
356
+
357
+ // Try other semantic elements for background
358
+ const candidates = [elements.main, elements.header, elements.hero, elements.card];
359
+ for (const el of candidates) {
360
+ const bg = el?.styles?.backgroundColor;
361
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg;
362
+ }
363
+
364
+ // v8-BUG-FIX: When body+html+main+header are ALL transparent, the actual visible bg
365
+ // is the browser default = WHITE. Do NOT fallback to allColors[0] which is
366
+ // typically the text color (rgb(0,0,0)) — that would falsely classify the site as dark.
367
+ // Web standard: transparent body → render on white canvas.
368
+ // This fixes addictsneakers.com (was wrongly detected as dark mode).
369
+ return 'rgb(255,255,255)';
370
+ }
371
+
372
+ // v2.6 Fix-1/5 — DIRECTIONAL canvas rescue. A common false-dark: <body> has a dark wrapper color
373
+ // (notion: #191918) while the actual content sits on white sections. Fix: if body is dark BUT the
374
+ // dominant rendered section area is light, the visible canvas is light. The reverse is NOT applied —
375
+ // a light <body> with large dark marketing bands (miro, revolut) is still a light site, so we never
376
+ // flip light→dark. This rescues notion without regressing miro/revolut (verified).
377
+ function toRgb(c: string): [number, number, number] | null {
378
+ return parseRgb(c) ?? (() => {
379
+ const hm = c.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
380
+ return hm ? [parseInt(hm[1], 16), parseInt(hm[2], 16), parseInt(hm[3], 16)] as [number, number, number] : null;
381
+ })();
382
+ }
383
+ function resolveDominantSectionBg(): string | null {
384
+ const sections = ((data as any).sections || []) as any[];
385
+ const areaByBg = new Map<string, number>();
386
+ for (const sec of sections) {
387
+ const bg: string = sec?.styles?.backgroundColor || '';
388
+ if (!parseRgb(bg)) continue;
389
+ const alphaM = bg.match(/rgba?\([^)]*,\s*([\d.]+)\s*\)/);
390
+ if (alphaM && parseFloat(alphaM[1]) === 0) continue; // skip transparent
391
+ const area = (sec?.rect?.height || 0) * (sec?.rect?.width || 0);
392
+ if (area <= 0) continue;
393
+ const k = cleanColorValue(bg);
394
+ areaByBg.set(k, (areaByBg.get(k) || 0) + area);
395
+ }
396
+ if (!areaByBg.size) return null;
397
+ return [...areaByBg.entries()].sort((x, y) => y[1] - x[1])[0][0];
398
+ }
399
+ function resolveCanvas(): string {
400
+ const body = resolveBodyBackground();
401
+ const bodyRgb = toRgb(body);
402
+ if (!bodyRgb || luminance(bodyRgb) >= 0.3) return body; // only rescue the dark-body case
403
+ const dom = resolveDominantSectionBg();
404
+ if (dom) {
405
+ const domRgb = toRgb(dom);
406
+ if (domRgb && luminance(domRgb) >= 0.3) return dom; // dark body + light content → light canvas
407
+ }
408
+ return body;
409
+ }
410
+
411
+ const bodyBg = resolveCanvas();
412
+ const classified = classifyColors(allColors, bodyBg);
413
+
414
+ const bodyColor = elements.body?.styles?.color || classified.text[0] || 'rgb(0,0,0)';
415
+
416
+ // Detect dark site — also handle hex values in bodyBg (from CSS var fallback above)
417
+ const bodyBgRgb: [number, number, number] | null = parseRgb(bodyBg) ?? (() => {
418
+ const hm = bodyBg.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
419
+ return hm ? [parseInt(hm[1], 16), parseInt(hm[2], 16), parseInt(hm[3], 16)] as [number, number, number] : null;
420
+ })();
421
+ const isDarkSite = bodyBgRgb ? luminance(bodyBgRgb) < 0.3 : false;
422
+
423
+ // ── Typography ──
424
+ // Generic/fallback fonts that should never be picked as primary when better options exist
425
+ const GENERIC_FONTS = new Set([
426
+ 'times new roman', 'times', 'georgia', 'palatino', 'garamond', 'bookman',
427
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
428
+ 'arial', 'helvetica', 'verdana', 'tahoma', 'trebuchet ms', 'comic sans ms',
429
+ 'apple color emoji', 'segoe ui emoji', 'segoe ui symbol', 'noto color emoji',
430
+ // KaTeX math library fonts — always parasitic, never brand fonts
431
+ 'katex_main', 'katex_ams', 'katex_caligraphic', 'katex_fraktur',
432
+ 'katex_math', 'katex_mathit', 'katex_size1', 'katex_size2', 'katex_size3', 'katex_size4',
433
+ ]);
434
+
435
+ // System/fallback fonts that should only be picked if no custom web font exists
436
+ const SYSTEM_FONTS = new Set([
437
+ '-apple-system', 'blinkmacsystemfont', 'system-ui', 'segoe ui',
438
+ 'ui-sans-serif', 'ui-serif', 'ui-monospace',
439
+ // i18n fallback fonts — lose to any branded web font
440
+ 'noto sans', 'noto serif', 'noto sans math', 'noto sans arabic',
441
+ 'noto sans hebrew', 'noto sans jp', 'noto sans kr', 'noto sans sc',
442
+ ]);
443
+
444
+ // Extract all individual font names from all font-family stacks
445
+ const allIndividualFonts: string[] = [];
446
+ for (const stack of allFontFamilies) {
447
+ const parts = (stack as string).split(',').map((f: string) => f.trim().replace(/"/g, ''));
448
+ allIndividualFonts.push(...parts);
449
+ }
450
+
451
+ // Also extract the body font specifically — pick the best font from body's stack
452
+ const bodyFontStack = elements.body?.styles?.fontFamily || '';
453
+ const bodyFontParts = bodyFontStack.split(',').map((f: string) => f.trim().replace(/"/g, ''));
454
+
455
+ function pickBestFont(fontList: string[]): string {
456
+ // First pass: look for custom web fonts (not generic, not system, not KaTeX)
457
+ for (const font of fontList) {
458
+ const lower = font.toLowerCase();
459
+ if (/^katex_/i.test(font)) continue; // always skip KaTeX math library
460
+ if (font && !GENERIC_FONTS.has(lower) && !SYSTEM_FONTS.has(lower)) return font;
461
+ }
462
+ // Second pass: accept system fonts as fallback
463
+ for (const font of fontList) {
464
+ const lower = font.toLowerCase();
465
+ if (font && !GENERIC_FONTS.has(lower)) return font;
466
+ }
467
+ // Last resort
468
+ return fontList[0] || 'system-ui';
469
+ }
470
+
471
+ // Deduplicate, filter generics, and sort with priority fonts first
472
+ const seen = new Set<string>();
473
+ const uniqueFamilies: string[] = [];
474
+
475
+ // Start with the body font (most important — sets the page's primary font)
476
+ let bodyPrimary = pickBestFont(bodyFontParts);
477
+ // v2.6 Fix-3 — if body resolves to a GENERIC/system font (e.g. miro body = "sans-serif"), the real
478
+ // brand font lives in heading/other stacks (e.g. Roobert PRO in allFontFamilies). Prefer it so the
479
+ // primary font isn't a generic placeholder (which then leaks into the YAML + narrative as truth).
480
+ const isGenericFontName = (f: string) => !f || GENERIC_FONTS.has(f.toLowerCase()) || SYSTEM_FONTS.has(f.toLowerCase());
481
+ if (isGenericFontName(bodyPrimary)) {
482
+ for (const stack of allFontFamilies) {
483
+ const parts = String(stack).split(',').map((f: string) => f.trim().replace(/['"]/g, ''));
484
+ const real = parts.find((f: string) => f && !isGenericFontName(f) && !/^katex_/i.test(f));
485
+ if (real) { bodyPrimary = real; break; }
486
+ }
487
+ }
488
+ if (bodyPrimary) {
489
+ seen.add(bodyPrimary.toLowerCase());
490
+ uniqueFamilies.push(bodyPrimary);
491
+ }
492
+
493
+ // Then add other meaningful fonts from all stacks
494
+ for (const font of allIndividualFonts) {
495
+ const lower = font.toLowerCase();
496
+ if (!font || seen.has(lower) || GENERIC_FONTS.has(lower)) continue;
497
+ seen.add(lower);
498
+ uniqueFamilies.push(font);
499
+ }
500
+
501
+ const sortedSizes = sortByPixels(allFontSizes);
502
+
503
+ // ── Spacing — CSS vars first (--spacing-* / --space-*), element padding as fallback ──
504
+ const spacingValues = new Set<string>();
505
+ const cssVarsForSpacing = (cssCustomProperties || {}) as Record<string, string>;
506
+ for (const [key, val] of Object.entries(cssVarsForSpacing)) {
507
+ if (/^--(spacing|space|size|gap)-/i.test(key) && val) {
508
+ const v = val.trim();
509
+ if (v.includes('px') && parsePixels(v) > 0 && parsePixels(v) < 200) {
510
+ spacingValues.add(v);
511
+ } else if (v.includes('rem')) {
512
+ const px = Math.round(parseFloat(v) * 16);
513
+ if (px > 0 && px < 200) spacingValues.add(`${px}px`);
514
+ }
515
+ }
516
+ }
517
+ // fallback: element-based padding/margin/gap if CSS vars yielded < 4 values
518
+ if (spacingValues.size < 4) {
519
+ for (const el of Object.values(elements)) {
520
+ if (!el) continue;
521
+ const e = el as { styles: Record<string, string> };
522
+ for (const prop of ['padding', 'margin', 'gap']) {
523
+ const val = e.styles?.[prop];
524
+ if (val) {
525
+ val.split(' ').forEach((v: string) => {
526
+ if (v.includes('px')) {
527
+ const px = Math.round(parsePixels(v));
528
+ if (px > 0 && px < 200) spacingValues.add(`${px}px`);
529
+ }
530
+ });
531
+ }
532
+ }
533
+ }
534
+ // Also pull section-level vertical padding (vPad) — premium sites put their
535
+ // generous whitespace on <section> wrappers, not on the key elements above.
536
+ const sectionsData = ((data as any).sections || []) as any[];
537
+ for (const sec of sectionsData) {
538
+ if (sec?.vPad && typeof sec.vPad === 'number') {
539
+ const px = Math.round(sec.vPad);
540
+ if (px > 0 && px < 200) spacingValues.add(`${px}px`);
541
+ }
542
+ // Also extract from section styles directly
543
+ const secPad = sec?.styles?.padding;
544
+ if (secPad && typeof secPad === 'string') {
545
+ secPad.split(' ').forEach((v: string) => {
546
+ if (v.includes('px')) {
547
+ const px = Math.round(parsePixels(v));
548
+ if (px > 0 && px < 200) spacingValues.add(`${px}px`);
549
+ }
550
+ });
551
+ }
552
+ }
553
+ }
554
+ const sortedSpacing = sortByPixels([...spacingValues]);
555
+
556
+ // ── Border radii — CSS vars first (--corner-radius-* / --radius-*), then extracted values ──
557
+ const radiusFromVars = new Set<string>();
558
+ for (const [key, val] of Object.entries(cssVarsForSpacing)) {
559
+ if (/^--(corner-radius|radius|rounded)-/i.test(key) && val) {
560
+ const v = val.trim();
561
+ if (v.includes('px') && parsePixels(v) >= 0 && parsePixels(v) < 9999) {
562
+ radiusFromVars.add(v);
563
+ } else if (v === '50%' || v === '100%' || v.includes('%')) {
564
+ radiusFromVars.add(v);
565
+ }
566
+ }
567
+ }
568
+ // ALWAYS merge allBorderRadii (actual rendered values, e.g. 14px on property cards)
569
+ // with CSS token vars — rendered values capture overrides not present in token system.
570
+ // Filter: only simple single-value entries (reject shorthand "10px 10px 10px 2px").
571
+ const allRadiiMerged = new Set<string>([...radiusFromVars]);
572
+ for (const r of allBorderRadii as string[]) {
573
+ if (/^\d+(?:\.\d+)?px$/.test(r.trim())) {
574
+ allRadiiMerged.add(r.trim());
575
+ }
576
+ }
577
+ const sortedRadii = sortByPixels([...allRadiiMerged]);
578
+
579
+ // ── Semantic muted text color from CSS vars ──
580
+ // Find the best muted/secondary text color from CSS vars (e.g. --palette-icon-tertiary,
581
+ // --palette-text-secondary, --color-text-muted). For light sites: lum 0.15–0.65 range.
582
+ // Saturation filter (sat ≤ 30): muted text must be near-gray — excludes error reds, accents.
583
+ // Priority: icon/text vars first (sort by saturation ascending, then by key priority).
584
+ interface MutedCandidate { raw: string; sat: number; priority: number; }
585
+ const mutedTextCandidates: MutedCandidate[] = [];
586
+ for (const [k, v] of Object.entries(cssCustomProperties || {})) {
587
+ if (typeof v !== 'string') continue;
588
+ const vt = v.trim();
589
+ if (!/muted|tertiary|secondary|sub|icon|caption|quiet|helper|hint/i.test(k)) continue;
590
+ // Skip bg vars — those are backgrounds not text colors
591
+ if (/\bpb-bg\b|palette-bg|--bg-|color-bg|background/i.test(k)) continue;
592
+ // Parse hex or rgb — parseRgb only handles rgb() format
593
+ let rgb: [number, number, number] | null = parseRgb(vt);
594
+ if (!rgb) {
595
+ const hm = vt.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
596
+ if (hm) rgb = [parseInt(hm[1], 16), parseInt(hm[2], 16), parseInt(hm[3], 16)];
597
+ }
598
+ if (!rgb) continue;
599
+ const lum = luminance(rgb);
600
+ const sat = Math.max(...rgb) - Math.min(...rgb);
601
+ // Saturation guard: muted gray must have sat ≤ 30 (excludes #E00B41 sat=213, etc.)
602
+ if (sat > 30) continue;
603
+ // Luminance range: not too dark (same as primary), not too light (border territory)
604
+ const lumOk = !isDarkSite ? (lum >= 0.15 && lum <= 0.65) : (lum >= 0.40 && lum <= 0.85);
605
+ if (!lumOk) continue;
606
+ // Priority: prefer vars with "icon" or "text" scope over general secondary/tertiary
607
+ const priority = /\bicon\b|\btext\b/i.test(k) ? 0 : 1;
608
+ mutedTextCandidates.push({ raw: cleanColorValue(vt), sat, priority });
609
+ }
610
+ // Sort by priority (icon/text first), then by saturation ascending (most neutral)
611
+ mutedTextCandidates.sort((a, b) => a.priority - b.priority || a.sat - b.sat);
612
+ const resolvedMutedText = mutedTextCandidates[0]?.raw || (isDarkSite
613
+ ? (classified.text[2] || classified.text[1] || bodyColor)
614
+ : (classified.text[1] || classified.border[0] || bodyColor));
615
+
616
+ // ── Build tokens ──
617
+ const tokens: DesignTokens = {
618
+ meta: {
619
+ source: data.url,
620
+ domain,
621
+ extractedAt: data.timestamp,
622
+ tokenizedAt: new Date().toISOString(),
623
+ },
624
+ colors: {
625
+ background: {
626
+ primary: cleanColorValue(bodyBg),
627
+ secondary: cleanColorValue(classified.backgrounds[1] || bodyBg),
628
+ tertiary: cleanColorValue(classified.backgrounds[2] || classified.backgrounds[1] || bodyBg),
629
+ },
630
+ text: {
631
+ primary: cleanColorValue(bodyColor),
632
+ // Fix (audit-2026-05-28): ensure secondary is visually distinct from primary
633
+ secondary: cleanColorValue(pickDistinctTextSecondary(
634
+ dedupePerceptual(classified.text, 15),
635
+ bodyColor,
636
+ isDarkSite
637
+ )),
638
+ muted: resolvedMutedText,
639
+ },
640
+ accent: (() => {
641
+ // Phase 5.2.1 — Reject browser default colors (akiflow → #0000ee was being accepted as brand)
642
+ const BROWSER_DEFAULTS = new Set([
643
+ '#0000ee', '#0000EE', 'rgb(0, 0, 238)', // default <a> link
644
+ '#551a8b', '#551A8B', 'rgb(85, 26, 139)', // default :visited
645
+ '#ee0000', '#EE0000', 'rgb(238, 0, 0)', // default :active
646
+ ]);
647
+ const isBrowserDefault = (c: string): boolean => {
648
+ if (!c) return false;
649
+ const norm = c.toLowerCase().replace(/\s+/g, '');
650
+ if (BROWSER_DEFAULTS.has(c)) return true;
651
+ // Check normalized forms
652
+ for (const d of BROWSER_DEFAULTS) {
653
+ if (d.toLowerCase().replace(/\s+/g, '') === norm) return true;
654
+ }
655
+ return false;
656
+ };
657
+
658
+ // Rank accents by DOM frequency (proxy = nb occurrences in allColors deduplicated list)
659
+ // + bonus +100 for CSS vars named --primary/--brand/--cta/--accent/--action
660
+ const freq = new Map<string, number>();
661
+ for (const c of allColors as string[]) {
662
+ if (isBrowserDefault(c)) continue; // Phase 5.2.1 reject
663
+ const rgb = parseRgb(c);
664
+ if (!rgb) continue;
665
+ if (getSaturation(rgb) > 50 && luminance(rgb) > 0.1 && luminance(rgb) < 0.9) {
666
+ const norm = cleanColorValue(c);
667
+ freq.set(norm, (freq.get(norm) || 0) + 1);
668
+ }
669
+ }
670
+ // Bonus/penalty for semantically named CSS vars
671
+ for (const [k, v] of Object.entries(cssCustomProperties as Record<string, string>)) {
672
+ const vt = String(v).trim();
673
+ if (isBrowserDefault(vt)) continue; // Phase 5.2.1 reject
674
+ const rgb = parseRgb(vt) ?? (() => {
675
+ const hm = vt.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
676
+ return hm ? [parseInt(hm[1], 16), parseInt(hm[2], 16), parseInt(hm[3], 16)] as [number, number, number] : null;
677
+ })();
678
+ if (!rgb) continue;
679
+ if (getSaturation(rgb) <= 50 || luminance(rgb) <= 0.1 || luminance(rgb) >= 0.9) continue;
680
+ const norm = cleanColorValue(vt);
681
+ if (/hover|active|pressed|focus(ed)?|disabled|error|danger|destructive|warning|alert|success|positive|confirm|info|notice|caution|inverse|dark-mode/i.test(k)) {
682
+ freq.set(norm, (freq.get(norm) || 0) - 200);
683
+ } else if (/primary|accent|brand|cta|action|button-bg/i.test(k)) {
684
+ freq.set(norm, (freq.get(norm) || 0) + 100);
685
+ }
686
+ }
687
+ const rankedEntries = [...freq.entries()].sort((a, b) => b[1] - a[1]);
688
+ const ranked = rankedEntries.map(([c]) => c);
689
+
690
+ // v2.6 Fix-1 — prefer RENDERED colors: accent.primary should actually appear on the page.
691
+ // Without this, a CSS-var-only value (e.g. miro `--tw-color-success-accent #00b473`, never
692
+ // painted) wins the +100 var-name bonus and becomes the brand color. We allow an unrendered
693
+ // color ONLY as a last resort and ONLY if it still carries a POSITIVE brand-var score (e.g.
694
+ // revolut's monochrome page whose violet lives in `--rui-color-accent #494fdf` + illustrations,
695
+ // never as a saturated element) — `success`/state vars now score negative and stay excluded.
696
+ const renderedSet = new Set((allColors as string[]).map(c => cleanColorValue(c)));
697
+ const rankedRendered = ranked.filter(c => renderedSet.has(c));
698
+ const rankedPositiveUnrendered = rankedEntries
699
+ .filter(([c, s]) => !renderedSet.has(c) && s > 0)
700
+ .map(([c]) => c);
701
+
702
+ // Highest-signal primary = background of the most prominent VIBRANT CTA button, read
703
+ // straight from the rendered DOM (beats frequency/var-name heuristics). Fixes notion
704
+ // (→ #455dd3 "Get Notion free") and miro (→ #fde050), which freq/var-bonus got wrong.
705
+ const ctaPrimary = (() => {
706
+ const btns = ((data as any).componentVariants?.buttons || []) as any[];
707
+ const cands = btns
708
+ .map(b => {
709
+ const bg = b?.styles?.backgroundColor || '';
710
+ const rgb = parseRgb(bg);
711
+ const area = (b?.rect?.width || 0) * (b?.rect?.height || 0);
712
+ return { bg, rgb, area };
713
+ })
714
+ .filter(c => !!c.rgb && !isBrowserDefault(c.bg)
715
+ && getSaturation(c.rgb as [number, number, number]) > 50
716
+ && luminance(c.rgb as [number, number, number]) > 0.1
717
+ && luminance(c.rgb as [number, number, number]) < 0.95);
718
+ cands.sort((a, b) => b.area - a.area);
719
+ return cands[0] ? cleanColorValue(cands[0].bg) : null;
720
+ })();
721
+
722
+ // classified.accents ⊂ allColors → always rendered; safe hard-blocked fallback.
723
+ const renderedFallback = classified.accents.find(a => !isBrowserDefault(a)) || null;
724
+ const ordered = [...(ctaPrimary ? [ctaPrimary] : []), ...rankedRendered, ...rankedPositiveUnrendered]
725
+ .filter((c, i, a) => a.indexOf(c) === i);
726
+ // Phase 5.2.1 — null if no rendered accent found (rather than mislabel an unrendered var).
727
+ return {
728
+ primary: ordered[0] || (renderedFallback ? cleanColorValue(renderedFallback) : null),
729
+ secondary: ordered[1] || ordered[0] || (renderedFallback ? cleanColorValue(renderedFallback) : null),
730
+ };
731
+ })(),
732
+ border: cleanColorValue(isDarkSite
733
+ ? (classified.border[0] || 'rgba(255,255,255,0.08)')
734
+ : (classified.border[0] || 'rgb(229,231,235)')),
735
+ shadow: isDarkSite ? 'rgba(0,0,0,0.3)' : 'rgba(0,0,0,0.1)',
736
+ semantic: (() => {
737
+ const result: Record<string, string> = {};
738
+ const PATTERNS: Array<[string, RegExp]> = [
739
+ ['error', /\berror\b|\bdanger\b|\bdestructive\b/i],
740
+ ['success', /\bsuccess\b|\bpositive\b|\bconfirm\b/i],
741
+ ['warning', /\bwarn(ing)?\b|\bcaution\b/i],
742
+ ['info', /\binfo(rmative)?\b|\bnotice\b/i],
743
+ ];
744
+ for (const [role, pattern] of PATTERNS) {
745
+ for (const [k, v] of Object.entries(cssCustomProperties as Record<string, string>)) {
746
+ if (!pattern.test(k)) continue;
747
+ const vt = String(v).trim();
748
+ const rgb = parseRgb(vt) ?? (() => {
749
+ const hm = vt.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
750
+ return hm ? [parseInt(hm[1], 16), parseInt(hm[2], 16), parseInt(hm[3], 16)] as [number, number, number] : null;
751
+ })();
752
+ if (rgb && getSaturation(rgb) > 40 && !result[role]) {
753
+ result[role] = cleanColorValue(vt);
754
+ break;
755
+ }
756
+ }
757
+ }
758
+ return Object.keys(result).length > 0 ? result : undefined;
759
+ })(),
760
+ },
761
+ typography: {
762
+ fontFamily: {
763
+ primary: uniqueFamilies[0] || 'system-ui',
764
+ secondary: uniqueFamilies[1] || uniqueFamilies[0] || 'system-ui',
765
+ mono: uniqueFamilies.find(f => /mono|code|consol/i.test(f)) || 'monospace',
766
+ },
767
+ fontSize: (() => {
768
+ // Smart scale: anchor on body base, distribute others across actual range
769
+ // FIX (audit-2026-05-28): guarantee monotonically increasing distinct values
770
+ // Avoid the degeneracy where lg=xl=2xl=3xl when only few sizes detected
771
+ const bodySize = elements.body?.styles?.fontSize || '16px';
772
+ const bodyPx = parsePixels(bodySize);
773
+
774
+ const unique = [...new Set(sortedSizes)].sort((a, b) => parsePixels(a) - parsePixels(b));
775
+ const displaySizes = unique.filter(s => parsePixels(s) >= bodyPx * 1.2);
776
+ const smallSizes = unique.filter(s => parsePixels(s) < bodyPx);
777
+
778
+ // Target scale (approximate ratios above body)
779
+ const targets = [
780
+ { name: 'lg', mul: 1.25 },
781
+ { name: 'xl', mul: 1.5 },
782
+ { name: '2xl', mul: 1.875 },
783
+ { name: '3xl', mul: 2.25 },
784
+ { name: '4xl', mul: 3.0 },
785
+ ];
786
+
787
+ // Pick distinct sizes by walking through display sizes,
788
+ // ensuring each subsequent token > previous (no degeneracy)
789
+ const pickedSizes: string[] = [];
790
+ let lastPx = bodyPx;
791
+ for (const t of targets) {
792
+ const targetPx = bodyPx * t.mul;
793
+ // First try: pick from displaySizes that are > lastPx and closest to targetPx
794
+ const candidates = displaySizes.filter(s => parsePixels(s) > lastPx);
795
+ let chosen: string;
796
+ if (candidates.length > 0) {
797
+ chosen = pickClosest(candidates, targetPx);
798
+ } else {
799
+ // Fallback: synthesize the target value, guarantee strictly > lastPx
800
+ const synthPx = Math.max(Math.round(targetPx), Math.round(lastPx + 2));
801
+ chosen = synthPx + 'px';
802
+ }
803
+ pickedSizes.push(chosen);
804
+ lastPx = parsePixels(chosen);
805
+ }
806
+
807
+ const xs = smallSizes[0] || pickClosest(sortedSizes, 12);
808
+ const sm = smallSizes.length > 1
809
+ ? smallSizes[smallSizes.length - 1]
810
+ : (parsePixels(xs) < bodyPx - 1 ? Math.round((parsePixels(xs) + bodyPx) / 2) + 'px' : pickClosest(sortedSizes, 14));
811
+
812
+ return {
813
+ xs,
814
+ sm,
815
+ base: bodySize,
816
+ lg: pickedSizes[0],
817
+ xl: pickedSizes[1],
818
+ '2xl': pickedSizes[2],
819
+ '3xl': pickedSizes[3],
820
+ '4xl': pickedSizes[4],
821
+ };
822
+ })(),
823
+ fontWeight: (() => {
824
+ // Phase 5.1.2 — Collect from BOTH elements AND componentVariants (was only KEY_SELECTORS before)
825
+ // Previously: 9/30 sites showed false "Don't use weight 700" because variants weren't sampled.
826
+ const weights = new Set<number>();
827
+ for (const el of Object.values(elements)) {
828
+ const fw = (el as any)?.styles?.fontWeight;
829
+ if (fw && /^\d+$/.test(String(fw).trim())) weights.add(Number(fw));
830
+ }
831
+ // Sample componentVariants too — heading variants often have weights NOT visible in KEY_SELECTORS
832
+ const variants = (data as any).componentVariants || {};
833
+ for (const group of Object.values(variants)) {
834
+ if (!Array.isArray(group)) continue;
835
+ for (const v of group) {
836
+ const fw = v?.styles?.fontWeight;
837
+ if (fw && /^\d+$/.test(String(fw).trim())) weights.add(Number(fw));
838
+ }
839
+ }
840
+ const sorted = [...weights].sort((a, b) => a - b);
841
+ return {
842
+ normal: String(sorted.find(w => w <= 400) ?? 400),
843
+ medium: String(sorted.find(w => w > 400 && w <= 500) ?? sorted.find(w => w > 400) ?? 500),
844
+ semibold: String(sorted.find(w => w >= 550 && w <= 650) ?? sorted.find(w => w > 500) ?? 600),
845
+ bold: String(sorted.find(w => w >= 700) ?? sorted[sorted.length - 1] ?? 700),
846
+ };
847
+ })(),
848
+ lineHeight: {
849
+ tight: normalizeLineHeight(elements.heading?.styles?.lineHeight, elements.heading?.styles?.fontSize) || '1.25',
850
+ normal: normalizeLineHeight(elements.body?.styles?.lineHeight, elements.body?.styles?.fontSize) || '1.5',
851
+ relaxed: normalizeLineHeight(elements.nav?.styles?.lineHeight, elements.nav?.styles?.fontSize) || '1.75',
852
+ },
853
+ letterSpacing: (() => {
854
+ // Extract real letter-spacing from heading/subheading variants — never hardcode
855
+ const h1Variants: any[] = (data as any).componentVariants?.headingH1 || [];
856
+ const h2Variants: any[] = (data as any).componentVariants?.headingH2 || [];
857
+ const toEm = (ls: string, fsStr?: string): string => {
858
+ if (!ls || ls === 'normal') return '0em';
859
+ if (ls.includes('em')) return ls;
860
+ const lsPx = parsePixels(ls);
861
+ if (lsPx === 0) return '0em';
862
+ const fsPx = fsStr ? parsePixels(fsStr) : 16;
863
+ return `${(lsPx / (fsPx || 16)).toFixed(3)}em`;
864
+ };
865
+ const rawValues: string[] = [];
866
+ for (const v of [...h1Variants, ...h2Variants]) {
867
+ const ls = v?.styles?.letterSpacing;
868
+ if (ls && ls !== '0px' && ls !== 'normal') rawValues.push(ls);
869
+ }
870
+ const headingLs = elements.heading?.styles?.letterSpacing;
871
+ if (headingLs && headingLs !== '0px' && headingLs !== 'normal') rawValues.push(headingLs);
872
+ const bodyLs = elements.body?.styles?.letterSpacing;
873
+ const tight = rawValues[0]
874
+ ? toEm(rawValues[0], elements.heading?.styles?.fontSize)
875
+ : '-0.025em';
876
+ const wide = rawValues.length > 1
877
+ ? toEm(rawValues[rawValues.length - 1], elements.heading?.styles?.fontSize)
878
+ : (elements.button?.styles?.letterSpacing !== 'normal' && elements.button?.styles?.letterSpacing !== '0px'
879
+ ? toEm(elements.button?.styles?.letterSpacing || '0', elements.button?.styles?.fontSize)
880
+ : '0.025em');
881
+ return {
882
+ tight,
883
+ normal: toEm(bodyLs || '0', elements.body?.styles?.fontSize),
884
+ wide,
885
+ };
886
+ })(),
887
+ },
888
+ spacing: (() => {
889
+ const scaleTargets = [4, 8, 12, 16, 24, 32, 48, 64];
890
+ const scaleValues = pickDistinctScale(sortedSpacing, scaleTargets);
891
+ return {
892
+ xxs: '2px',
893
+ xs: scaleValues[0],
894
+ sm: scaleValues[1],
895
+ md: scaleValues[2],
896
+ base: scaleValues[3],
897
+ lg: scaleValues[4],
898
+ xl: scaleValues[5],
899
+ '2xl': scaleValues[6],
900
+ '3xl': scaleValues[7],
901
+ };
902
+ })(),
903
+ borderRadius: {
904
+ none: '0px',
905
+ xs: pickClosest(sortedRadii, 4),
906
+ sm: pickClosest(sortedRadii, 8),
907
+ md: pickClosest(sortedRadii, 14), // card radius — rendered value (14px on property cards)
908
+ lg: pickClosest(sortedRadii, 20), // large rounded elements (pill buttons, search segments)
909
+ xl: pickClosest(sortedRadii, 32), // category strips, large containers
910
+ full: sortedRadii.find(r => parsePixels(r) >= 999) || '9999px',
911
+ },
912
+ shadows: Object.fromEntries(
913
+ allShadows.slice(0, 5).map((s: string, i: number) => [`shadow-${i + 1}`, s])
914
+ ),
915
+ transitions: Object.fromEntries(
916
+ allTransitions.slice(0, 5).map((t: string, i: number) => [`transition-${i + 1}`, t])
917
+ ),
918
+ layout: {
919
+ maxWidth: resolveMaxWidth(elements, cssCustomProperties || {}),
920
+ headerHeight: elements.header ? `${elements.header.rect.height}px` : '64px',
921
+ sidebarWidth: elements.sidebar ? `${elements.sidebar.rect.width}px` : '0px',
922
+ gap: elements.main?.styles?.gap || '16px',
923
+ containerPadding: elements.main?.styles?.padding || '24px',
924
+ },
925
+ cssCustomProperties: cssCustomProperties || {},
926
+ };
927
+
928
+ // Phase 5.2.2 — Contrast sanity check. If text.primary and bg.primary have <3:1 ratio,
929
+ // one of them is wrong (Framer/Shopify black-on-black, MongoDB/Notion near-black on dark).
930
+ // Strategy: trust bg from body, recalculate text from h1 element if conflict.
931
+ const _parseColorRgb = (s: string): [number, number, number] | null => {
932
+ const rgb = parseRgb(s);
933
+ if (rgb) return rgb;
934
+ const hm = s.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
935
+ return hm ? [parseInt(hm[1], 16), parseInt(hm[2], 16), parseInt(hm[3], 16)] : null;
936
+ };
937
+ const _wcagContrast = (rgb1: [number, number, number], rgb2: [number, number, number]): number => {
938
+ const lum = (rgb: [number, number, number]): number => {
939
+ const [r, g, b] = rgb.map(v => {
940
+ const n = v / 255;
941
+ return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4);
942
+ });
943
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
944
+ };
945
+ const l1 = lum(rgb1);
946
+ const l2 = lum(rgb2);
947
+ const lighter = Math.max(l1, l2);
948
+ const darker = Math.min(l1, l2);
949
+ return (lighter + 0.05) / (darker + 0.05);
950
+ };
951
+ const _textRgb = _parseColorRgb(tokens.colors.text.primary || '');
952
+ const _bgRgb = _parseColorRgb(tokens.colors.background.primary || '');
953
+ if (_textRgb && _bgRgb) {
954
+ const contrast = _wcagContrast(_textRgb, _bgRgb);
955
+ if (contrast < 3.0) {
956
+ console.warn(` ⚠️ Phase 5.2.2: text/bg contrast ${contrast.toFixed(2)}:1 < 3 — fallback to h1 color`);
957
+ const h1Color = elements.h1?.styles?.color || elements.heading?.styles?.color;
958
+ if (h1Color) {
959
+ const newRgb = _parseColorRgb(h1Color);
960
+ if (newRgb && _wcagContrast(newRgb, _bgRgb) >= 3.0) {
961
+ tokens.colors.text.primary = cleanColorValue(h1Color);
962
+ console.warn(` → text.primary corrected to ${tokens.colors.text.primary} (h1 color)`);
963
+ }
964
+ }
965
+ }
966
+ }
967
+
968
+ const outputPath = join(baseDir, 'tokens.json');
969
+ await writeFile(outputPath, JSON.stringify(tokens, null, 2));
970
+
971
+ console.log(`✅ Tokens saved to ${outputPath}`);
972
+ console.log(` ${Object.keys(cssCustomProperties || {}).length} CSS custom properties preserved`);
973
+ console.log(` Colors: ${allColors.length} found → ${Object.keys(tokens.colors).length} categories`);
974
+ console.log(` Fonts: ${uniqueFamilies.length} families, ${sortedSizes.length} sizes`);
975
+ console.log(` Spacing: ${sortedSpacing.length} values, Radii: ${sortedRadii.length} values`);
976
+ }
977
+
978
+ // ── CLI ──
979
+ const domain = process.argv[2];
980
+ if (!domain) {
981
+ console.error('Usage: npm run tokenize -- <domain>');
982
+ process.exit(1);
983
+ }
984
+
985
+ tokenize(domain).catch(err => {
986
+ console.error('Error:', err);
987
+ process.exit(1);
988
+ });