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.
- package/CHANGELOG.md +292 -0
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/bin/clone-architect.mjs +476 -0
- package/bin/prism.mjs +467 -0
- package/catalog/index.json +1155 -0
- package/extractions/airbnb.com/DESIGN.md +1068 -0
- package/extractions/airbnb.com/tokens.json +507 -0
- package/extractions/attio.com/DESIGN.md +1295 -0
- package/extractions/attio.com/tokens.json +438 -0
- package/extractions/auroxdashboard.com/DESIGN.md +724 -0
- package/extractions/auroxdashboard.com/tokens.json +195 -0
- package/extractions/careerexplorer.com/DESIGN.md +1178 -0
- package/extractions/careerexplorer.com/tokens.json +141 -0
- package/extractions/chance.co/DESIGN.md +1209 -0
- package/extractions/chance.co/tokens.json +160 -0
- package/extractions/choisis-ton-avenir.com/DESIGN.md +1265 -0
- package/extractions/choisis-ton-avenir.com/tokens.json +227 -0
- package/extractions/example.com/DESIGN.md +436 -0
- package/extractions/example.com/tokens.json +91 -0
- package/extractions/getdesign.md/DESIGN.md +1009 -0
- package/extractions/getdesign.md/tokens.json +219 -0
- package/extractions/github.com/DESIGN.md +1130 -0
- package/extractions/github.com/tokens.json +2092 -0
- package/extractions/hello-charly.com/DESIGN.md +1146 -0
- package/extractions/hello-charly.com/tokens.json +322 -0
- package/extractions/hyperliquid.xyz/DESIGN.md +779 -0
- package/extractions/hyperliquid.xyz/tokens.json +598 -0
- package/extractions/instagram.com/DESIGN.md +996 -0
- package/extractions/instagram.com/tokens.json +1240 -0
- package/extractions/jobirl.com/DESIGN.md +1160 -0
- package/extractions/jobirl.com/tokens.json +139 -0
- package/extractions/life360.com/DESIGN.md +1133 -0
- package/extractions/life360.com/tokens.json +491 -0
- package/extractions/lifesum.com/DESIGN.md +965 -0
- package/extractions/lifesum.com/tokens.json +170 -0
- package/extractions/linear.app/DESIGN.md +1301 -0
- package/extractions/linear.app/tokens.json +732 -0
- package/extractions/mavoie.org/DESIGN.md +1148 -0
- package/extractions/mavoie.org/tokens.json +128 -0
- package/extractions/miro.com/DESIGN.md +1237 -0
- package/extractions/miro.com/tokens.json +401 -0
- package/extractions/notion.so/DESIGN.md +1319 -0
- package/extractions/notion.so/tokens.json +906 -0
- package/extractions/onetonline.org/DESIGN.md +909 -0
- package/extractions/onetonline.org/tokens.json +280 -0
- package/extractions/posthog.com/DESIGN.md +1024 -0
- package/extractions/posthog.com/tokens.json +197 -0
- package/extractions/revolut.com/DESIGN.md +1080 -0
- package/extractions/revolut.com/tokens.json +401 -0
- package/extractions/stripe.com/DESIGN.md +1272 -0
- package/extractions/stripe.com/tokens.json +794 -0
- package/extractions/switchcollective.com/DESIGN.md +1040 -0
- package/extractions/switchcollective.com/tokens.json +98 -0
- package/extractions/truity.com/DESIGN.md +970 -0
- package/extractions/truity.com/tokens.json +166 -0
- package/extractions/uniquekicks.be/DESIGN.md +1171 -0
- package/extractions/uniquekicks.be/tokens.json +237 -0
- package/package.json +122 -0
- package/scripts/analyze.ts +281 -0
- package/scripts/bank-register.ts +379 -0
- package/scripts/bank.ts +374 -0
- package/scripts/browser-stealth.ts +189 -0
- package/scripts/clone.ts +198 -0
- package/scripts/compare-vs-gd-final.ts +273 -0
- package/scripts/compare-vs-gd.ts +269 -0
- package/scripts/compare.ts +405 -0
- package/scripts/deploy-site.ts +181 -0
- package/scripts/diff-snapshots.ts +340 -0
- package/scripts/enrich-catalog.ts +212 -0
- package/scripts/extract.ts +2038 -0
- package/scripts/extractors/advanced.ts +524 -0
- package/scripts/extractors/widgets.ts +711 -0
- package/scripts/generate-design-md.ts +5775 -0
- package/scripts/generate-final-pdf.ts +274 -0
- package/scripts/generate-og-image.ts +87 -0
- package/scripts/generate-showcase.ts +1588 -0
- package/scripts/generate-site.ts +847 -0
- package/scripts/mass-extract.sh +91 -0
- package/scripts/post-process-all.sh +55 -0
- package/scripts/regen-catalog.ts +203 -0
- package/scripts/shared/cache.ts +149 -0
- package/scripts/shared/css-helpers.ts +263 -0
- package/scripts/shared/logger.ts +57 -0
- package/scripts/shared/named-colors.ts +355 -0
- package/scripts/shared/types.ts +220 -0
- package/scripts/sync-catalog.ts +105 -0
- package/scripts/tokenize.ts +988 -0
- package/templates/layout-template.md +52 -0
- 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
|
+
});
|