@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.
- package/LICENSE +201 -0
- package/dist/block-codegen.d.ts +5 -0
- package/dist/block-codegen.js +224 -0
- package/dist/design-tokens.d.ts +26 -0
- package/dist/design-tokens.js +733 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/scraper.d.ts +27 -0
- package/dist/scraper.js +1088 -0
- package/dist/section-extractor.d.ts +50 -0
- package/dist/section-extractor.js +911 -0
- package/dist/section-spec.d.ts +22 -0
- package/dist/section-spec.js +526 -0
- package/dist/types.d.ts +403 -0
- package/dist/types.js +2 -0
- package/package.json +47 -0
|
@@ -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
|
+
}
|