@avocadostudio-ai/migration-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,733 @@
1
+ // ── Named CSS colors (common subset) ──
2
+ const NAMED_COLORS = {
3
+ white: "#ffffff",
4
+ black: "#000000",
5
+ red: "#ff0000",
6
+ green: "#008000",
7
+ blue: "#0000ff",
8
+ yellow: "#ffff00",
9
+ cyan: "#00ffff",
10
+ magenta: "#ff00ff",
11
+ orange: "#ffa500",
12
+ purple: "#800080",
13
+ pink: "#ffc0cb",
14
+ gray: "#808080",
15
+ grey: "#808080",
16
+ silver: "#c0c0c0",
17
+ navy: "#000080",
18
+ teal: "#008080",
19
+ maroon: "#800000",
20
+ olive: "#808000",
21
+ lime: "#00ff00",
22
+ aqua: "#00ffff",
23
+ fuchsia: "#ff00ff",
24
+ coral: "#ff7f50",
25
+ tomato: "#ff6347",
26
+ salmon: "#fa8072",
27
+ gold: "#ffd700",
28
+ khaki: "#f0e68c",
29
+ plum: "#dda0dd",
30
+ orchid: "#da70d6",
31
+ tan: "#d2b48c",
32
+ crimson: "#dc143c",
33
+ indigo: "#4b0082",
34
+ violet: "#ee82ee",
35
+ turquoise: "#40e0d0",
36
+ sienna: "#a0522d",
37
+ peru: "#cd853f",
38
+ linen: "#faf0e6",
39
+ beige: "#f5f5dc",
40
+ ivory: "#fffff0",
41
+ lavender: "#e6e6fa",
42
+ snow: "#fffafa",
43
+ seashell: "#fff5ee",
44
+ mintcream: "#f5fffa",
45
+ azure: "#f0ffff",
46
+ aliceblue: "#f0f8ff",
47
+ ghostwhite: "#f8f8ff",
48
+ whitesmoke: "#f5f5f5",
49
+ honeydew: "#f0fff0",
50
+ floralwhite: "#fffaf0",
51
+ oldlace: "#fdf5e6",
52
+ cornsilk: "#fff8dc",
53
+ bisque: "#ffe4c4",
54
+ wheat: "#f5deb3",
55
+ gainsboro: "#dcdcdc",
56
+ lightgray: "#d3d3d3",
57
+ lightgrey: "#d3d3d3",
58
+ darkgray: "#a9a9a9",
59
+ darkgrey: "#a9a9a9",
60
+ dimgray: "#696969",
61
+ dimgrey: "#696969",
62
+ slategray: "#708090",
63
+ slategrey: "#708090",
64
+ lightslategray: "#778899",
65
+ lightslategrey: "#778899",
66
+ darkslategray: "#2f4f4f",
67
+ darkslategrey: "#2f4f4f",
68
+ };
69
+ const SKIP_VALUES = new Set([
70
+ "transparent",
71
+ "inherit",
72
+ "currentcolor",
73
+ "initial",
74
+ "unset",
75
+ "none",
76
+ "revert",
77
+ "revert-layer",
78
+ ]);
79
+ // ── Color normalization ──
80
+ function clamp(n) {
81
+ return Math.max(0, Math.min(255, Math.round(n)));
82
+ }
83
+ function componentToHex(c) {
84
+ return clamp(c).toString(16).padStart(2, "0");
85
+ }
86
+ export function normalizeColor(value) {
87
+ const v = value.trim().toLowerCase();
88
+ if (SKIP_VALUES.has(v))
89
+ return null;
90
+ // Hex
91
+ if (v.startsWith("#")) {
92
+ const hex = v.slice(1);
93
+ if (hex.length === 3) {
94
+ return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`;
95
+ }
96
+ if (hex.length === 4) {
97
+ // #rgba → #rrggbb (drop alpha)
98
+ return `#${hex[0]}${hex[0]}${hex[1]}${hex[1]}${hex[2]}${hex[2]}`;
99
+ }
100
+ if (hex.length === 6)
101
+ return `#${hex}`;
102
+ if (hex.length === 8) {
103
+ // #rrggbbaa → #rrggbb (drop alpha)
104
+ return `#${hex.slice(0, 6)}`;
105
+ }
106
+ return null;
107
+ }
108
+ // rgb/rgba
109
+ const rgbMatch = v.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
110
+ if (rgbMatch) {
111
+ const [, r, g, b] = rgbMatch;
112
+ return `#${componentToHex(Number(r))}${componentToHex(Number(g))}${componentToHex(Number(b))}`;
113
+ }
114
+ // hsl/hsla
115
+ const hslMatch = v.match(/^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%/);
116
+ if (hslMatch) {
117
+ const h = Number(hslMatch[1]) / 360;
118
+ const s = Number(hslMatch[2]) / 100;
119
+ const l = Number(hslMatch[3]) / 100;
120
+ const [r, g, b] = hslToRgb(h, s, l);
121
+ return `#${componentToHex(r)}${componentToHex(g)}${componentToHex(b)}`;
122
+ }
123
+ // Named color
124
+ if (NAMED_COLORS[v])
125
+ return NAMED_COLORS[v];
126
+ return null;
127
+ }
128
+ function hslToRgb(h, s, l) {
129
+ if (s === 0) {
130
+ const val = Math.round(l * 255);
131
+ return [val, val, val];
132
+ }
133
+ const hue2rgb = (p, q, t) => {
134
+ let tt = t;
135
+ if (tt < 0)
136
+ tt += 1;
137
+ if (tt > 1)
138
+ tt -= 1;
139
+ if (tt < 1 / 6)
140
+ return p + (q - p) * 6 * tt;
141
+ if (tt < 1 / 2)
142
+ return q;
143
+ if (tt < 2 / 3)
144
+ return p + (q - p) * (2 / 3 - tt) * 6;
145
+ return p;
146
+ };
147
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
148
+ const p = 2 * l - q;
149
+ return [
150
+ Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
151
+ Math.round(hue2rgb(p, q, h) * 255),
152
+ Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
153
+ ];
154
+ }
155
+ // ── Color lightness for sorting/classification ──
156
+ function hexLightness(hex) {
157
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
158
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
159
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
160
+ return 0.299 * r + 0.587 * g + 0.114 * b;
161
+ }
162
+ // ── Color extraction regex ──
163
+ const COLOR_VALUE_RE = /#(?:[0-9a-fA-F]{3,8})\b|rgba?\([^)]+\)|hsla?\([^)]+\)|[a-zA-Z]+/g;
164
+ const COLOR_PROP_RE = /(?:^|[{;\s])\s*(color|background-color|background|border-color|border)\s*:\s*([^;}{]+)/gi;
165
+ const FONT_FAMILY_RE = /(?:^|[{;\s])\s*font-family\s*:\s*([^;}{]+)/gi;
166
+ const BORDER_RADIUS_RE = /(?:^|[{;\s])\s*border-radius\s*:\s*([^;}{]+)/gi;
167
+ function parseSelectorBlocks(css) {
168
+ const blocks = [];
169
+ // Simplistic: match `selector { body }` — won't handle nested @ rules perfectly but good enough
170
+ const re = /([^{}]+)\{([^{}]*)\}/g;
171
+ let m;
172
+ while ((m = re.exec(css)) !== null) {
173
+ blocks.push({ selector: m[1].trim(), body: m[2] });
174
+ }
175
+ return blocks;
176
+ }
177
+ function isHeadingSelector(selector) {
178
+ return /\bh[1-6]\b|\.heading|\.title/i.test(selector);
179
+ }
180
+ // ── extractDesignTokens ──
181
+ /**
182
+ * Extract design tokens from CSS text.
183
+ * When `resolvedCssVars` is provided (from Playwright getComputedStyle),
184
+ * var() references are resolved to actual values before normalization.
185
+ */
186
+ export function extractDesignTokens(css, resolvedCssVars) {
187
+ // Pre-process CSS: replace var() references with resolved values
188
+ const processedCss = resolvedCssVars ? resolveVarReferences(css, resolvedCssVars) : css;
189
+ const colors = extractColors(processedCss);
190
+ const fonts = extractFonts(processedCss);
191
+ const radii = extractRadii(processedCss);
192
+ return { colors, fonts, radii };
193
+ }
194
+ /** Replace var(--name) and var(--name, fallback) references with resolved computed values */
195
+ function resolveVarReferences(css, vars) {
196
+ // Match var(--name) or var(--name, fallback)
197
+ return css.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)/gi, (_match, name, fallback) => {
198
+ const resolved = vars[name];
199
+ if (resolved)
200
+ return resolved;
201
+ // Use fallback value if provided, otherwise keep original
202
+ return fallback?.trim() ?? _match;
203
+ });
204
+ }
205
+ function classifyProperty(prop) {
206
+ const p = prop.toLowerCase();
207
+ if (p === "color")
208
+ return "text";
209
+ if (p === "background-color" || p === "background")
210
+ return "background";
211
+ if (p === "border-color" || p === "border")
212
+ return "border";
213
+ return "text";
214
+ }
215
+ function extractColors(css) {
216
+ // Accumulate: key = normalized hex, value = { usage set, frequency, property }
217
+ const map = new Map();
218
+ let match;
219
+ const re = new RegExp(COLOR_PROP_RE.source, "gi");
220
+ while ((match = re.exec(css)) !== null) {
221
+ const prop = match[1];
222
+ const rawValue = match[2].trim();
223
+ const usage = classifyProperty(prop);
224
+ // Extract color values from the declaration value
225
+ const colorRe = new RegExp(COLOR_VALUE_RE.source, "gi");
226
+ let cm;
227
+ while ((cm = colorRe.exec(rawValue)) !== null) {
228
+ const normalized = normalizeColor(cm[0]);
229
+ if (!normalized)
230
+ continue;
231
+ const existing = map.get(normalized);
232
+ if (existing) {
233
+ existing.usage.add(usage);
234
+ existing.frequency++;
235
+ }
236
+ else {
237
+ map.set(normalized, {
238
+ usage: new Set([usage]),
239
+ frequency: 1,
240
+ property: prop.toLowerCase(),
241
+ });
242
+ }
243
+ }
244
+ }
245
+ // Convert to array
246
+ const result = [];
247
+ for (const [value, data] of map) {
248
+ // Pick primary usage: if multiple, prefer text > background > border
249
+ let primaryUsage;
250
+ const usages = data.usage;
251
+ if (usages.has("text") && usages.has("background")) {
252
+ primaryUsage = "accent"; // appears in both — likely brand/accent
253
+ }
254
+ else if (usages.has("text")) {
255
+ primaryUsage = "text";
256
+ }
257
+ else if (usages.has("background")) {
258
+ primaryUsage = "background";
259
+ }
260
+ else {
261
+ primaryUsage = "border";
262
+ }
263
+ result.push({
264
+ value,
265
+ usage: primaryUsage,
266
+ frequency: data.frequency,
267
+ property: data.property,
268
+ });
269
+ }
270
+ result.sort((a, b) => b.frequency - a.frequency);
271
+ return result;
272
+ }
273
+ function extractFonts(css) {
274
+ const blocks = parseSelectorBlocks(css);
275
+ const seen = new Map();
276
+ for (const block of blocks) {
277
+ const re = new RegExp(FONT_FAMILY_RE.source, "gi");
278
+ let m;
279
+ while ((m = re.exec(block.body)) !== null) {
280
+ const raw = m[1].trim();
281
+ // Take the first font family, strip quotes
282
+ const first = raw.split(",")[0].trim().replace(/^["']|["']$/g, "");
283
+ if (!first)
284
+ continue;
285
+ const usage = isHeadingSelector(block.selector)
286
+ ? "heading"
287
+ : "body";
288
+ // heading takes priority over body if already seen
289
+ if (seen.has(first)) {
290
+ const existing = seen.get(first);
291
+ if (usage === "heading" && existing.usage !== "heading") {
292
+ existing.usage = "heading";
293
+ }
294
+ }
295
+ else {
296
+ seen.set(first, { family: first, usage });
297
+ }
298
+ }
299
+ }
300
+ return [...seen.values()];
301
+ }
302
+ function extractRadii(css) {
303
+ const freq = new Map();
304
+ const re = new RegExp(BORDER_RADIUS_RE.source, "gi");
305
+ let m;
306
+ while ((m = re.exec(css)) !== null) {
307
+ const val = m[1].trim();
308
+ freq.set(val, (freq.get(val) || 0) + 1);
309
+ }
310
+ return [...freq.entries()]
311
+ .sort((a, b) => b[1] - a[1])
312
+ .map(([v]) => v);
313
+ }
314
+ // ── mapToThemeVariables ──
315
+ export function mapToThemeVariables(tokens) {
316
+ const vars = {};
317
+ // Include accent colors in both bg and text pools since they appear in both contexts.
318
+ // For bg, also include light accent colors; for text, include dark accent colors.
319
+ const bgColors = tokens.colors.filter((c) => c.usage === "background" || (c.usage === "accent" && hexLightness(c.value) > 0.5));
320
+ const textColors = tokens.colors.filter((c) => c.usage === "text" || (c.usage === "accent" && hexLightness(c.value) < 0.5));
321
+ // True accent colors: used in both text + background, not near-white/near-black (likely brand)
322
+ const accentColors = tokens.colors.filter((c) => c.usage === "accent" && hexLightness(c.value) > 0.08 && hexLightness(c.value) < 0.85);
323
+ // Sort backgrounds by lightness descending (lightest first)
324
+ const bgByLightness = [...bgColors].sort((a, b) => hexLightness(b.value) - hexLightness(a.value));
325
+ // Sort texts by lightness ascending (darkest first)
326
+ const textByDarkness = [...textColors].sort((a, b) => hexLightness(a.value) - hexLightness(b.value));
327
+ // Detect dark theme: if the most frequent background has lightness < 0.3, the site is dark-themed
328
+ const mostFreqBg = bgColors.length > 0 ? bgColors[0] : null;
329
+ const isDarkTheme = mostFreqBg != null && hexLightness(mostFreqBg.value) < 0.3;
330
+ // Sort texts by lightness descending (lightest first) — used for dark theme text selection
331
+ const textByLightness = [...textColors].sort((a, b) => hexLightness(b.value) - hexLightness(a.value));
332
+ if (isDarkTheme) {
333
+ // ── Dark theme: backgrounds stay dark, text goes light ──
334
+ // --bg-0: the dark most-frequent background
335
+ if (mostFreqBg)
336
+ vars["--bg-0"] = mostFreqBg.value;
337
+ // --bg-100: a slightly lighter dark shade (not white)
338
+ if (bgByLightness.length > 1) {
339
+ // Find a bg that is lighter than bg-0 but still dark (lightness < 0.5)
340
+ const slightlyLighter = bgByLightness.find((c) => c.value !== mostFreqBg.value && hexLightness(c.value) < 0.5);
341
+ if (slightlyLighter) {
342
+ vars["--bg-100"] = slightlyLighter.value;
343
+ }
344
+ else {
345
+ // Fallback: adjust bg-0 to be slightly lighter
346
+ vars["--bg-100"] = adjustLightness(mostFreqBg.value, 0.06);
347
+ }
348
+ }
349
+ // --surface / --surface-border: dark-appropriate values
350
+ if (bgByLightness.length > 0) {
351
+ vars["--surface"] = adjustLightness(mostFreqBg.value, 0.04);
352
+ vars["--surface-border"] = adjustLightness(mostFreqBg.value, 0.12);
353
+ }
354
+ // --text-100: lightest text (not darkest!) for dark themes
355
+ if (textByLightness.length > 0) {
356
+ vars["--text-100"] = textByLightness[0].value;
357
+ }
358
+ // --text-200: second lightest text
359
+ if (textByLightness.length > 1) {
360
+ vars["--text-200"] = textByLightness[1].value;
361
+ }
362
+ // --heading: light text for dark bg
363
+ if (textByLightness.length > 0) {
364
+ vars["--heading"] = textByLightness[0].value;
365
+ }
366
+ // --body: lightest text color
367
+ if (textByLightness.length > 0) {
368
+ vars["--body"] = textByLightness[0].value;
369
+ }
370
+ // --body-secondary: slightly dimmer light text
371
+ if (textByLightness.length > 1) {
372
+ const secondary = textByLightness.find((c) => hexLightness(c.value) > 0.3 && c.value !== textByLightness[0].value);
373
+ if (secondary)
374
+ vars["--body-secondary"] = secondary.value;
375
+ }
376
+ // --footer-bg: darkest bg (could be even darker than --bg-0)
377
+ if (bgByLightness.length > 0) {
378
+ const darkestBg = bgByLightness[bgByLightness.length - 1];
379
+ vars["--footer-bg"] = darkestBg.value;
380
+ }
381
+ // --footer-text: light text for dark footer
382
+ if (textByLightness.length > 0) {
383
+ vars["--footer-text"] = textByLightness[0].value;
384
+ }
385
+ }
386
+ else {
387
+ // ── Light theme: existing logic ──
388
+ // --bg-0: most frequent background, or lightest
389
+ if (bgByLightness.length > 0) {
390
+ vars["--bg-0"] =
391
+ hexLightness(mostFreqBg.value) > 0.4
392
+ ? mostFreqBg.value
393
+ : bgByLightness[0].value;
394
+ }
395
+ // --bg-100: second most common background (different from bg-0)
396
+ if (bgByLightness.length > 1) {
397
+ const bg0 = vars["--bg-0"];
398
+ const second = bgByLightness.find((c) => c.value !== bg0);
399
+ if (second)
400
+ vars["--bg-100"] = second.value;
401
+ }
402
+ // --text-100: most frequent text color (or darkest)
403
+ if (textByDarkness.length > 0) {
404
+ vars["--text-100"] = textByDarkness[0].value;
405
+ }
406
+ // --text-200: second text color
407
+ if (textByDarkness.length > 1) {
408
+ vars["--text-200"] = textByDarkness[1].value;
409
+ }
410
+ // --heading: same as text-100 or darkest text
411
+ if (textByDarkness.length > 0) {
412
+ vars["--heading"] = textByDarkness[0].value;
413
+ }
414
+ // --body: most frequent text color
415
+ if (textColors.length > 0) {
416
+ // Prefer the most frequent text-only color
417
+ const bodyTexts = tokens.colors.filter((c) => c.usage === "text");
418
+ if (bodyTexts.length > 0) {
419
+ vars["--body"] = bodyTexts[0].value;
420
+ }
421
+ else {
422
+ vars["--body"] = textByDarkness[0].value;
423
+ }
424
+ }
425
+ // --body-secondary: lighter body text
426
+ if (textByDarkness.length > 1) {
427
+ // Pick the lightest text color that isn't near-white
428
+ const secondary = [...textByDarkness]
429
+ .reverse()
430
+ .find((c) => hexLightness(c.value) < 0.85);
431
+ if (secondary)
432
+ vars["--body-secondary"] = secondary.value;
433
+ }
434
+ // --footer-bg: darkest background color
435
+ if (bgByLightness.length > 0) {
436
+ const darkestBg = bgByLightness[bgByLightness.length - 1];
437
+ vars["--footer-bg"] = darkestBg.value;
438
+ }
439
+ // --footer-text: lightest text color
440
+ if (textByDarkness.length > 0) {
441
+ const lightestText = textByDarkness[textByDarkness.length - 1];
442
+ vars["--footer-text"] = lightestText.value;
443
+ }
444
+ }
445
+ // --brand: most frequent accent color, or a color appearing in both text + background
446
+ if (accentColors.length > 0) {
447
+ vars["--brand"] = accentColors[0].value;
448
+ }
449
+ else {
450
+ // Fallback: find a non-neutral color used as text that isn't very dark/light
451
+ const candidate = textColors.find((c) => {
452
+ const l = hexLightness(c.value);
453
+ return l > 0.15 && l < 0.7;
454
+ });
455
+ if (candidate)
456
+ vars["--brand"] = candidate.value;
457
+ }
458
+ if (vars["--brand"]) {
459
+ // --brand-hover: slightly darker variant
460
+ vars["--brand-hover"] = adjustLightness(vars["--brand"], -0.08);
461
+ // --brand-subtle: very light version for light themes, darker for dark themes
462
+ vars["--brand-subtle"] = isDarkTheme
463
+ ? adjustLightness(vars["--brand"], -0.15)
464
+ : adjustLightness(vars["--brand"], 0.35);
465
+ // --brand-fg: text on brand background
466
+ vars["--brand-fg"] =
467
+ hexLightness(vars["--brand"]) > 0.5 ? "#000000" : "#ffffff";
468
+ }
469
+ // ── Derived surface/compound variables ──
470
+ // --caption: lighter text for captions/labels
471
+ if (!vars["--caption"]) {
472
+ if (isDarkTheme && textByLightness.length > 1) {
473
+ // For dark themes, pick a mid-lightness text
474
+ const cap = textByLightness.find(c => hexLightness(c.value) > 0.3 && hexLightness(c.value) < 0.7);
475
+ if (cap)
476
+ vars["--caption"] = cap.value;
477
+ }
478
+ else if (textByDarkness.length > 1) {
479
+ const cap = [...textByDarkness].reverse().find(c => hexLightness(c.value) < 0.65 && hexLightness(c.value) > 0.3);
480
+ if (cap)
481
+ vars["--caption"] = cap.value;
482
+ }
483
+ if (!vars["--caption"])
484
+ vars["--caption"] = vars["--body-secondary"] ?? "#64748b";
485
+ }
486
+ // --surface: always set (dark branch may have set it already)
487
+ if (!vars["--surface"]) {
488
+ vars["--surface"] = isDarkTheme
489
+ ? adjustLightness(vars["--bg-0"] ?? "#1a1a1a", 0.04)
490
+ : vars["--bg-100"] ?? "#f8fafc";
491
+ }
492
+ if (!vars["--surface-border"]) {
493
+ vars["--surface-border"] = isDarkTheme
494
+ ? adjustLightness(vars["--bg-0"] ?? "#1a1a1a", 0.12)
495
+ : "#e2e8f0";
496
+ }
497
+ // --border: surface border or derived
498
+ if (!vars["--border"])
499
+ vars["--border"] = vars["--surface-border"];
500
+ // --bg-1: alternate background (similar to bg-100)
501
+ if (!vars["--bg-1"])
502
+ vars["--bg-1"] = vars["--bg-100"] ?? vars["--bg-0"] ?? "#ffffff";
503
+ // --section-bg: section background
504
+ vars["--section-bg"] = `var(--bg-100)`;
505
+ // --card-bg: slightly offset from main bg
506
+ if (!vars["--card-bg"])
507
+ vars["--card-bg"] = vars["--surface"] ?? vars["--bg-100"] ?? "#f8fafc";
508
+ // --card-shadow
509
+ vars["--card-shadow"] = isDarkTheme ? "0 1px 3px rgba(0,0,0,0.3)" : "0 1px 3px rgba(0,0,0,0.08)";
510
+ // --hero-bg / --cta-bg: default to bg-0
511
+ if (!vars["--hero-bg"])
512
+ vars["--hero-bg"] = `var(--bg-0)`;
513
+ if (!vars["--cta-bg"])
514
+ vars["--cta-bg"] = `var(--bg-100)`;
515
+ // --placeholder-img
516
+ vars["--placeholder-img"] = isDarkTheme ? "#374151" : "#e2e8f0";
517
+ // ── Footer (only set defaults if not already set by dark/light branch) ──
518
+ if (!vars["--footer-heading"])
519
+ vars["--footer-heading"] = isDarkTheme ? (vars["--heading"] ?? "#f1f5f9") : "#f1f5f9";
520
+ if (!vars["--footer-link"])
521
+ vars["--footer-link"] = vars["--footer-text"] ?? "#94a3b8";
522
+ if (!vars["--footer-link-hover"])
523
+ vars["--footer-link-hover"] = isDarkTheme ? "#ffffff" : "#e2e8f0";
524
+ if (!vars["--footer-border"])
525
+ vars["--footer-border"] = isDarkTheme ? adjustLightness(vars["--footer-bg"] ?? "#0f172a", 0.06) : "#1e293b";
526
+ // ── Typography ──
527
+ const headingFont = tokens.fonts.find((f) => f.usage === "heading");
528
+ const bodyFont = tokens.fonts.find((f) => f.usage === "body");
529
+ if (headingFont)
530
+ vars["--font-heading"] = headingFont.family;
531
+ if (bodyFont)
532
+ vars["--font-body"] = bodyFont.family;
533
+ // ── Shapes (from extracted border-radius values) ──
534
+ if (tokens.radii.length > 0) {
535
+ const mainRadius = tokens.radii[0]; // most frequent
536
+ vars["--radius-btn"] = mainRadius;
537
+ vars["--radius-card"] = mainRadius;
538
+ vars["--radius-feature"] = mainRadius;
539
+ }
540
+ return vars;
541
+ }
542
+ // ── Computed style augmentation ──
543
+ /**
544
+ * Override theme variables with actual computed CSS values from section specs.
545
+ * Computed styles (from getComputedStyle in the browser) are more reliable than
546
+ * CSS regex extraction, especially for CMS sites using CSS variables/inline styles.
547
+ */
548
+ export function augmentThemeFromComputedStyles(theme, sectionStyles, hoverStates) {
549
+ const result = { ...theme };
550
+ // Collect actual computed colors from sections
551
+ const containerBgs = [];
552
+ const headingColors = [];
553
+ const headingFonts = [];
554
+ const bodyColors = [];
555
+ const bodyFonts = [];
556
+ const ctaBgs = [];
557
+ const ctaColors = [];
558
+ for (const s of sectionStyles) {
559
+ // Container background — skip transparent
560
+ const bg = s.container.backgroundColor ?? "";
561
+ const bgNorm = normalizeColor(bg);
562
+ if (bgNorm && bgNorm !== "#000000")
563
+ containerBgs.push(bgNorm);
564
+ // Also check shorthand background for solid colors
565
+ const bgFull = s.container.background ?? "";
566
+ if (bgFull && !bgFull.includes("gradient")) {
567
+ const bgFullNorm = normalizeColor(bgFull.split(/\s/)[0]);
568
+ if (bgFullNorm)
569
+ containerBgs.push(bgFullNorm);
570
+ }
571
+ // Heading
572
+ if (s.heading) {
573
+ const hColor = normalizeColor(s.heading.color ?? "");
574
+ if (hColor)
575
+ headingColors.push(hColor);
576
+ const hFont = s.heading.fontFamily?.split(",")[0].trim().replace(/['"]/g, "");
577
+ if (hFont && hFont !== "inherit" && hFont !== "initial")
578
+ headingFonts.push(hFont);
579
+ }
580
+ // Body text
581
+ if (s.bodyText) {
582
+ const bColor = normalizeColor(s.bodyText.color ?? "");
583
+ if (bColor)
584
+ bodyColors.push(bColor);
585
+ const bFont = s.bodyText.fontFamily?.split(",")[0].trim().replace(/['"]/g, "");
586
+ if (bFont && bFont !== "inherit" && bFont !== "initial")
587
+ bodyFonts.push(bFont);
588
+ }
589
+ // CTA
590
+ if (s.cta) {
591
+ const ctaBg = normalizeColor(s.cta.backgroundColor ?? "");
592
+ if (ctaBg)
593
+ ctaBgs.push(ctaBg);
594
+ const ctaColor = normalizeColor(s.cta.color ?? "");
595
+ if (ctaColor)
596
+ ctaColors.push(ctaColor);
597
+ }
598
+ }
599
+ // Detect dark theme from actual computed backgrounds
600
+ // Count dark vs light non-transparent section backgrounds
601
+ const darkBgs = containerBgs.filter(c => hexLightness(c) < 0.3);
602
+ const lightBgs = containerBgs.filter(c => hexLightness(c) > 0.6);
603
+ // Also check heading/body text — on dark themes, text is light
604
+ const lightTexts = [...headingColors, ...bodyColors].filter(c => hexLightness(c) > 0.7);
605
+ const darkTexts = [...headingColors, ...bodyColors].filter(c => hexLightness(c) < 0.3);
606
+ const isDark = (darkBgs.length > lightBgs.length) || (lightTexts.length > darkTexts.length && darkBgs.length > 0);
607
+ // If computed styles reveal a dark theme but the token pipeline didn't detect it,
608
+ // flip the key variables
609
+ if (isDark && hexLightness(result["--bg-0"] ?? "#ffffff") > 0.5) {
610
+ // The token pipeline thought it was light — override with computed data
611
+ const darkBg = mostFrequent(containerBgs.filter(c => hexLightness(c) < 0.3)) ?? "#181818";
612
+ result["--bg-0"] = darkBg;
613
+ result["--bg-100"] = adjustLightness(darkBg, 0.04);
614
+ result["--bg-1"] = result["--bg-100"];
615
+ result["--surface"] = adjustLightness(darkBg, 0.04);
616
+ result["--surface-border"] = adjustLightness(darkBg, 0.12);
617
+ result["--border"] = result["--surface-border"];
618
+ result["--card-bg"] = result["--surface"];
619
+ result["--card-shadow"] = "0 1px 3px rgba(0,0,0,0.3)";
620
+ result["--placeholder-img"] = "#374151";
621
+ result["--hero-bg"] = `var(--bg-0)`;
622
+ result["--cta-bg"] = `var(--bg-100)`;
623
+ }
624
+ // Override text colors with computed values
625
+ if (headingColors.length > 0) {
626
+ const hColor = mostFrequent(headingColors);
627
+ result["--heading"] = hColor;
628
+ result["--text-100"] = hColor;
629
+ // On dark theme, body should also be light
630
+ if (isDark) {
631
+ result["--body"] = mostFrequent(bodyColors) ?? hColor;
632
+ result["--text-200"] = mostFrequent(bodyColors) ?? adjustLightness(hColor, -0.1);
633
+ }
634
+ }
635
+ if (bodyColors.length > 0 && !isDark) {
636
+ result["--body"] = mostFrequent(bodyColors);
637
+ }
638
+ // Override brand with actual CTA color (most reliable source of brand color)
639
+ if (ctaBgs.length > 0) {
640
+ // Filter out near-transparent and near-black/white CTA backgrounds
641
+ const colorfulCtas = ctaBgs.filter(c => {
642
+ const l = hexLightness(c);
643
+ return l > 0.08 && l < 0.92;
644
+ });
645
+ if (colorfulCtas.length > 0) {
646
+ const brand = mostFrequent(colorfulCtas);
647
+ result["--brand"] = brand;
648
+ result["--brand-hover"] = adjustLightness(brand, -0.08);
649
+ result["--brand-subtle"] = isDark
650
+ ? adjustLightness(brand, -0.15)
651
+ : adjustLightness(brand, 0.35);
652
+ result["--brand-fg"] = hexLightness(brand) > 0.5 ? "#000000" : "#ffffff";
653
+ }
654
+ }
655
+ // Override brand-hover with actual hover state color (from interaction sweep)
656
+ if (hoverStates && hoverStates.length > 0) {
657
+ for (const hs of hoverStates) {
658
+ const bgChange = hs.changedStyles.backgroundColor;
659
+ if (bgChange) {
660
+ const hoverColor = normalizeColor(bgChange.after);
661
+ if (hoverColor && hexLightness(hoverColor) > 0.08 && hexLightness(hoverColor) < 0.92) {
662
+ result["--brand-hover"] = hoverColor;
663
+ break; // Use first valid hover color
664
+ }
665
+ }
666
+ }
667
+ }
668
+ // Override fonts with computed values
669
+ if (headingFonts.length > 0) {
670
+ result["--font-heading"] = mostFrequent(headingFonts) + ", sans-serif";
671
+ }
672
+ if (bodyFonts.length > 0) {
673
+ result["--font-body"] = mostFrequent(bodyFonts) + ", sans-serif";
674
+ }
675
+ // Fix footer colors for dark theme
676
+ if (isDark) {
677
+ result["--footer-bg"] = adjustLightness(result["--bg-0"] ?? "#181818", -0.03);
678
+ result["--footer-text"] = result["--heading"] ?? "#f0f0f0";
679
+ result["--footer-heading"] = result["--heading"] ?? "#f0f0f0";
680
+ result["--footer-link"] = result["--body"] ?? result["--heading"] ?? "#cfcfcf";
681
+ result["--footer-link-hover"] = "#ffffff";
682
+ result["--footer-border"] = adjustLightness(result["--footer-bg"], 0.06);
683
+ }
684
+ return result;
685
+ }
686
+ /** Find the most frequent value in an array */
687
+ function mostFrequent(arr) {
688
+ if (arr.length === 0)
689
+ return undefined;
690
+ const counts = new Map();
691
+ for (const v of arr) {
692
+ counts.set(v, (counts.get(v) ?? 0) + 1);
693
+ }
694
+ let best = arr[0];
695
+ let bestCount = 0;
696
+ for (const [v, c] of counts) {
697
+ if (c > bestCount) {
698
+ bestCount = c;
699
+ best = v;
700
+ }
701
+ }
702
+ return best;
703
+ }
704
+ // ── Lightness adjustment helper ──
705
+ function adjustLightness(hex, delta) {
706
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
707
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
708
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
709
+ // Convert to HSL
710
+ const max = Math.max(r, g, b);
711
+ const min = Math.min(r, g, b);
712
+ let h = 0;
713
+ let s = 0;
714
+ let l = (max + min) / 2;
715
+ if (max !== min) {
716
+ const d = max - min;
717
+ s = l > 0.5 ? d / (2 - max - min) : d / (max - min);
718
+ switch (max) {
719
+ case r:
720
+ h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
721
+ break;
722
+ case g:
723
+ h = ((b - r) / d + 2) / 6;
724
+ break;
725
+ case b:
726
+ h = ((r - g) / d + 4) / 6;
727
+ break;
728
+ }
729
+ }
730
+ l = Math.max(0, Math.min(1, l + delta));
731
+ const [nr, ng, nb] = hslToRgb(h, s, l);
732
+ return `#${componentToHex(nr)}${componentToHex(ng)}${componentToHex(nb)}`;
733
+ }