dembrandt 0.12.8 → 0.12.9
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/index.js +68 -52
- package/lib/extractors/colors.js +147 -6
- package/lib/extractors/logo.js +118 -6
- package/lib/extractors/typography.js +10 -5
- package/lib/formatters/terminal.js +19 -8
- package/package.json +9 -4
package/index.js
CHANGED
|
@@ -32,6 +32,7 @@ program
|
|
|
32
32
|
.description("Extract design tokens from any website")
|
|
33
33
|
.version(version)
|
|
34
34
|
.argument("<url>")
|
|
35
|
+
.argument("[paths...]", "Additional paths to extract and merge, e.g. /pricing /docs")
|
|
35
36
|
.option("--browser <type>", "Browser to use (chromium|firefox); set BROWSER_CDP_ENDPOINT env var to connect to an existing Chromium instance via CDP", "chromium")
|
|
36
37
|
.option("--json-only", "Output raw JSON")
|
|
37
38
|
.option("--save-output", "Save JSON file to output folder")
|
|
@@ -45,13 +46,19 @@ program
|
|
|
45
46
|
.option("--raw-colors", "Include pre-filter raw colors in JSON output")
|
|
46
47
|
.option("--screenshot <path>", "Save a screenshot of the page")
|
|
47
48
|
.option("--wcag", "Analyze WCAG contrast ratios between palette colors")
|
|
48
|
-
.option("--
|
|
49
|
+
.option("--crawl [n]", "Auto-discover and extract up to N pages via DOM links (default: 5)", (v) => {
|
|
50
|
+
if (v === undefined || v === true) return 5;
|
|
51
|
+
const n = parseInt(v, 10);
|
|
52
|
+
if (isNaN(n) || n < 1) throw new Error(`--crawl must be a positive integer, got: ${v}`);
|
|
53
|
+
return n;
|
|
54
|
+
})
|
|
55
|
+
.option("--pages <n>", "Alias for --crawl (deprecated)", (v) => {
|
|
49
56
|
const n = parseInt(v, 10);
|
|
50
57
|
if (isNaN(n) || n < 1) throw new Error(`--pages must be a positive integer, got: ${v}`);
|
|
51
58
|
return n;
|
|
52
59
|
})
|
|
53
60
|
.option("--sitemap", "Discover pages from sitemap.xml instead of DOM links")
|
|
54
|
-
.action(async (input, opts) => {
|
|
61
|
+
.action(async (input, paths, opts) => {
|
|
55
62
|
let url = input;
|
|
56
63
|
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
57
64
|
url = "https://" + url;
|
|
@@ -113,70 +120,79 @@ program
|
|
|
113
120
|
}
|
|
114
121
|
|
|
115
122
|
try {
|
|
116
|
-
const
|
|
117
|
-
const
|
|
123
|
+
const crawlN = opts.crawl ?? opts.pages ?? null;
|
|
124
|
+
const isAutoCrawl = crawlN && !opts.sitemap && (!paths || paths.length === 0);
|
|
125
|
+
const hasExplicitPaths = paths && paths.length > 0;
|
|
126
|
+
|
|
118
127
|
result = await extractBranding(url, spinner, browser, {
|
|
119
128
|
navigationTimeout: 90000,
|
|
120
129
|
darkMode: opts.darkMode,
|
|
121
130
|
mobile: opts.mobile,
|
|
122
131
|
slow: opts.slow,
|
|
123
132
|
screenshotPath: opts.screenshot,
|
|
124
|
-
discoverLinks:
|
|
133
|
+
discoverLinks: isAutoCrawl ? crawlN - 1 : null,
|
|
125
134
|
wcag: opts.wcag,
|
|
126
135
|
});
|
|
127
136
|
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
137
|
+
// Build list of additional URLs to extract
|
|
138
|
+
let additionalUrls = [];
|
|
139
|
+
|
|
140
|
+
if (hasExplicitPaths) {
|
|
141
|
+
// Explicit paths: resolve against base URL
|
|
142
|
+
const base = new URL(result.url);
|
|
143
|
+
additionalUrls = paths.map(p => {
|
|
144
|
+
if (p.startsWith('http')) return p;
|
|
145
|
+
return `${base.protocol}//${base.host}${p.startsWith('/') ? p : '/' + p}`;
|
|
146
|
+
});
|
|
147
|
+
} else if (opts.sitemap) {
|
|
148
|
+
if (!opts.jsonOnly) spinner.start("Fetching sitemap...");
|
|
149
|
+
const max = crawlN ? crawlN - 1 : 20;
|
|
150
|
+
additionalUrls = await parseSitemap(result.url, max);
|
|
151
|
+
if (additionalUrls.length === 0 && result.url !== url) {
|
|
152
|
+
additionalUrls = await parseSitemap(url, max);
|
|
142
153
|
}
|
|
154
|
+
} else if (isAutoCrawl) {
|
|
155
|
+
additionalUrls = result._discoveredLinks || [];
|
|
156
|
+
}
|
|
143
157
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (additionalUrls.length === 0) {
|
|
147
|
-
if (!opts.jsonOnly) spinner.warn("No additional pages discovered");
|
|
148
|
-
} else {
|
|
149
|
-
spinner.stop();
|
|
150
|
-
if (!opts.jsonOnly) console.log(chalk.dim(` Found ${additionalUrls.length} page(s) to analyze`));
|
|
151
|
-
|
|
152
|
-
const allResults = [result];
|
|
153
|
-
for (let i = 0; i < additionalUrls.length; i++) {
|
|
154
|
-
const pageUrl = additionalUrls[i];
|
|
155
|
-
const pageNum = i + 2;
|
|
156
|
-
const total = additionalUrls.length + 1;
|
|
157
|
-
if (!opts.jsonOnly) spinner.start(`Extracting page ${pageNum}/${total}: ${new URL(pageUrl).pathname}`);
|
|
158
|
-
|
|
159
|
-
// Polite delay between pages
|
|
160
|
-
await new Promise(r => setTimeout(r, 2000 + Math.random() * 3000));
|
|
161
|
-
|
|
162
|
-
try {
|
|
163
|
-
const pageResult = await extractBranding(pageUrl, spinner, browser, {
|
|
164
|
-
navigationTimeout: 90000,
|
|
165
|
-
darkMode: opts.darkMode,
|
|
166
|
-
mobile: opts.mobile,
|
|
167
|
-
slow: opts.slow,
|
|
168
|
-
});
|
|
169
|
-
delete pageResult._discoveredLinks;
|
|
170
|
-
allResults.push(pageResult);
|
|
171
|
-
} catch (err) {
|
|
172
|
-
if (!opts.jsonOnly) spinner.warn(`Skipping ${pageUrl}: ${String(err?.message || err).slice(0, 80)}`);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
158
|
+
delete result._discoveredLinks;
|
|
175
159
|
|
|
176
|
-
|
|
177
|
-
|
|
160
|
+
if (additionalUrls.length === 0) {
|
|
161
|
+
if ((hasExplicitPaths || opts.sitemap || isAutoCrawl) && !opts.jsonOnly) {
|
|
162
|
+
spinner.warn("No additional pages discovered");
|
|
178
163
|
}
|
|
179
164
|
} else {
|
|
165
|
+
spinner.stop();
|
|
166
|
+
if (!opts.jsonOnly) console.log(chalk.dim(` Found ${additionalUrls.length} page(s) to analyze`));
|
|
167
|
+
|
|
168
|
+
const allResults = [result];
|
|
169
|
+
for (let i = 0; i < additionalUrls.length; i++) {
|
|
170
|
+
const pageUrl = additionalUrls[i];
|
|
171
|
+
const pageNum = i + 2;
|
|
172
|
+
const total = additionalUrls.length + 1;
|
|
173
|
+
if (!opts.jsonOnly) spinner.start(`Extracting page ${pageNum}/${total}: ${new URL(pageUrl).pathname}`);
|
|
174
|
+
|
|
175
|
+
await new Promise(r => setTimeout(r, 1500 + Math.random() * 1500));
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const pageResult = await extractBranding(pageUrl, spinner, browser, {
|
|
179
|
+
navigationTimeout: 90000,
|
|
180
|
+
darkMode: opts.darkMode,
|
|
181
|
+
mobile: opts.mobile,
|
|
182
|
+
slow: opts.slow,
|
|
183
|
+
});
|
|
184
|
+
delete pageResult._discoveredLinks;
|
|
185
|
+
allResults.push(pageResult);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if (!opts.jsonOnly) spinner.warn(`Skipping ${pageUrl}: ${String(err?.message || err).slice(0, 80)}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
spinner.stop();
|
|
192
|
+
result = mergeResults(allResults);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!hasExplicitPaths && !opts.sitemap && !isAutoCrawl) {
|
|
180
196
|
delete result._discoveredLinks;
|
|
181
197
|
}
|
|
182
198
|
|
package/lib/extractors/colors.js
CHANGED
|
@@ -45,6 +45,12 @@ export async function extractColors(page) {
|
|
|
45
45
|
return result;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function colorAlpha(color) {
|
|
49
|
+
if (!color) return 1;
|
|
50
|
+
const m = color.match(/rgba?\(\d+,\s*\d+,\s*\d+,\s*([\d.]+)\)/);
|
|
51
|
+
return m ? parseFloat(m[1]) : 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
function isValidColorValue(value) {
|
|
49
55
|
if (!value) return false;
|
|
50
56
|
if (value.includes("calc(") || value.includes("clamp(") || value.includes("var(")) {
|
|
@@ -105,6 +111,7 @@ export async function extractColors(page) {
|
|
|
105
111
|
|
|
106
112
|
const elements = document.querySelectorAll("*");
|
|
107
113
|
const totalElements = elements.length;
|
|
114
|
+
const ctaPrimaryMap = new Map(); // normalized hex → original color for CTA backgrounds
|
|
108
115
|
|
|
109
116
|
const contextScores = {
|
|
110
117
|
logo: 5, brand: 5, primary: 4, cta: 4, hero: 3, button: 3, link: 2, header: 2, nav: 1,
|
|
@@ -133,12 +140,20 @@ export async function extractColors(page) {
|
|
|
133
140
|
if (context.includes(keyword)) score = Math.max(score, weight);
|
|
134
141
|
}
|
|
135
142
|
|
|
136
|
-
|
|
137
|
-
(context.includes('button') || context.includes('btn') || context.includes('cta')) &&
|
|
143
|
+
const isCta = (context.includes('button') || context.includes('btn') || context.includes('cta')) &&
|
|
138
144
|
bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' &&
|
|
139
|
-
bgColor !== 'rgb(255, 255, 255)' && bgColor !== 'rgb(0, 0, 0)' && bgColor !== 'rgb(239, 239, 239)'
|
|
140
|
-
|
|
145
|
+
bgColor !== 'rgb(255, 255, 255)' && bgColor !== 'rgb(0, 0, 0)' && bgColor !== 'rgb(239, 239, 239)' &&
|
|
146
|
+
colorAlpha(bgColor) >= 0.7;
|
|
147
|
+
|
|
148
|
+
if (isCta) {
|
|
141
149
|
score = Math.max(score, 25);
|
|
150
|
+
// CTA background is a strong primary signal — count occurrences
|
|
151
|
+
const ctaNorm = normalizeColor(bgColor);
|
|
152
|
+
if (ctaNorm) {
|
|
153
|
+
const existing = ctaPrimaryMap.get(ctaNorm) || { original: bgColor, count: 0 };
|
|
154
|
+
existing.count++;
|
|
155
|
+
ctaPrimaryMap.set(ctaNorm, existing);
|
|
156
|
+
}
|
|
142
157
|
}
|
|
143
158
|
|
|
144
159
|
function extractColorsFromValue(colorValue) {
|
|
@@ -159,7 +174,7 @@ export async function extractColors(page) {
|
|
|
159
174
|
];
|
|
160
175
|
|
|
161
176
|
allColors.forEach((color) => {
|
|
162
|
-
if (color && color !== "rgba(0, 0, 0, 0)" && color !== "transparent") {
|
|
177
|
+
if (color && color !== "rgba(0, 0, 0, 0)" && color !== "transparent" && colorAlpha(color) >= 0.3) {
|
|
163
178
|
const normalized = normalizeColor(color);
|
|
164
179
|
const existing = colorMap.get(normalized) || { original: color, count: 0, bgCount: 0, score: 0, sources: new Set() };
|
|
165
180
|
existing.count++;
|
|
@@ -174,11 +189,26 @@ export async function extractColors(page) {
|
|
|
174
189
|
});
|
|
175
190
|
|
|
176
191
|
if (context.includes("primary") || el.matches('[class*="primary"]')) {
|
|
177
|
-
|
|
192
|
+
const candidate = bgColor !== "rgba(0, 0, 0, 0)" && bgColor !== "transparent" ? bgColor : textColor;
|
|
193
|
+
if (colorAlpha(candidate) >= 0.7) semanticColors.primary = candidate;
|
|
178
194
|
}
|
|
179
195
|
if (context.includes("secondary")) semanticColors.secondary = bgColor;
|
|
180
196
|
});
|
|
181
197
|
|
|
198
|
+
// Use most-common CTA background as primary if class-based detection missed it
|
|
199
|
+
// Require at least 2 CTA occurrences to avoid single "Sign up" buttons dominating
|
|
200
|
+
if (!semanticColors.primary && ctaPrimaryMap.size > 0) {
|
|
201
|
+
let bestScore = -1;
|
|
202
|
+
for (const [norm, entry] of ctaPrimaryMap) {
|
|
203
|
+
if (entry.count < 2) continue;
|
|
204
|
+
const data = colorMap.get(norm);
|
|
205
|
+
if (data && data.score > bestScore) {
|
|
206
|
+
bestScore = data.score;
|
|
207
|
+
semanticColors.primary = entry.original;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
182
212
|
const threshold = Math.max(3, Math.floor(totalElements * 0.01));
|
|
183
213
|
|
|
184
214
|
function isStructuralColor(data, totalElements) {
|
|
@@ -295,6 +325,27 @@ export async function extractColors(page) {
|
|
|
295
325
|
if (converted) return { ...colorItem, lch: converted.lch, oklch: converted.oklch };
|
|
296
326
|
return colorItem;
|
|
297
327
|
});
|
|
328
|
+
|
|
329
|
+
// Cluster alpha/lightness variants of the same hue under one token
|
|
330
|
+
// e.g. rgba(99,91,255,0.1) is a variant of #635bff, not a separate brand color
|
|
331
|
+
const primaryHex = result.semantic?.primary
|
|
332
|
+
? hexToRgb(toOpaque(result.semantic.primary)) : null;
|
|
333
|
+
|
|
334
|
+
result.palette = result.palette.map((colorItem) => {
|
|
335
|
+
const hex = colorItem.normalized || colorItem.color;
|
|
336
|
+
const role = colorRole(hex, colorItem);
|
|
337
|
+
const onColor = bestOnColor(hex);
|
|
338
|
+
const hover = hoverVariant(hex);
|
|
339
|
+
// Mark alpha/lightness variants of primary
|
|
340
|
+
let variantOf = null;
|
|
341
|
+
if (primaryHex && role !== 'surface') {
|
|
342
|
+
const rgb = hexToRgb(hex);
|
|
343
|
+
if (rgb && isSameHueFamily(rgb, primaryHex)) {
|
|
344
|
+
variantOf = 'primary';
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return { ...colorItem, role, onColor, hover, ...(variantOf ? { variantOf } : {}) };
|
|
348
|
+
});
|
|
298
349
|
}
|
|
299
350
|
|
|
300
351
|
if (result && result.cssVariables) {
|
|
@@ -313,6 +364,96 @@ export async function extractColors(page) {
|
|
|
313
364
|
return result;
|
|
314
365
|
}
|
|
315
366
|
|
|
367
|
+
function hexToRgb(hex) {
|
|
368
|
+
const m = /^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
369
|
+
if (!m) return null;
|
|
370
|
+
return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function relativeLuminance({ r, g, b }) {
|
|
374
|
+
const lin = c => { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
|
|
375
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function contrastRatio(hex1, hex2) {
|
|
379
|
+
const rgb1 = hexToRgb(hex1);
|
|
380
|
+
const rgb2 = hexToRgb(hex2);
|
|
381
|
+
if (!rgb1 || !rgb2) return 1;
|
|
382
|
+
const l1 = relativeLuminance(rgb1);
|
|
383
|
+
const l2 = relativeLuminance(rgb2);
|
|
384
|
+
const lighter = Math.max(l1, l2);
|
|
385
|
+
const darker = Math.min(l1, l2);
|
|
386
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function bestOnColor(hex) {
|
|
390
|
+
const onWhite = contrastRatio(hex, '#ffffff');
|
|
391
|
+
const onBlack = contrastRatio(hex, '#000000');
|
|
392
|
+
return onWhite >= onBlack ? '#ffffff' : '#000000';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function colorRole(hex, colorItem) {
|
|
396
|
+
const rgb = hexToRgb(hex);
|
|
397
|
+
if (!rgb) return 'unknown';
|
|
398
|
+
const lum = relativeLuminance(rgb);
|
|
399
|
+
// Surface: near-pure white or black only
|
|
400
|
+
if (lum > 0.9 || lum < 0.005) return 'surface';
|
|
401
|
+
// Neutral: low HSL saturation (grays, off-whites, taupes)
|
|
402
|
+
const max = Math.max(rgb.r, rgb.g, rgb.b) / 255;
|
|
403
|
+
const min = Math.min(rgb.r, rgb.g, rgb.b) / 255;
|
|
404
|
+
const saturation = max === 0 ? 0 : (max - min) / max;
|
|
405
|
+
if (saturation < 0.12) return 'neutral';
|
|
406
|
+
// Accent: anything chromatic with meaningful confidence
|
|
407
|
+
if (colorItem.confidence === 'high' || colorItem.confidence === 'medium') return 'accent';
|
|
408
|
+
return 'supporting';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function hoverVariant(hex) {
|
|
412
|
+
const rgb = hexToRgb(hex);
|
|
413
|
+
if (!rgb) return null;
|
|
414
|
+
const lum = relativeLuminance(rgb);
|
|
415
|
+
// Lighten dark colors, darken light ones — always move toward better contrast
|
|
416
|
+
const factor = lum < 0.18 ? 1.2 : 0.85;
|
|
417
|
+
const clamp = v => Math.min(255, Math.max(0, Math.round(v)));
|
|
418
|
+
const r = clamp(rgb.r * factor);
|
|
419
|
+
const g = clamp(rgb.g * factor);
|
|
420
|
+
const b = clamp(rgb.b * factor);
|
|
421
|
+
return `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function toOpaque(color) {
|
|
425
|
+
if (!color) return null;
|
|
426
|
+
if (color.startsWith('#')) return color;
|
|
427
|
+
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
|
|
428
|
+
if (!m) return null;
|
|
429
|
+
return `#${(+m[1]).toString(16).padStart(2,'0')}${(+m[2]).toString(16).padStart(2,'0')}${(+m[3]).toString(16).padStart(2,'0')}`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function isSameHueFamily(rgb, primaryRgb, threshold = 30) {
|
|
433
|
+
// Compare hue in HSL space — same hue ±threshold degrees = variant
|
|
434
|
+
function toHsl({ r, g, b }) {
|
|
435
|
+
r /= 255; g /= 255; b /= 255;
|
|
436
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
437
|
+
const l = (max + min) / 2;
|
|
438
|
+
if (max === min) return { h: 0, s: 0, l };
|
|
439
|
+
const d = max - min;
|
|
440
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
441
|
+
let h;
|
|
442
|
+
switch (max) {
|
|
443
|
+
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
|
444
|
+
case g: h = ((b - r) / d + 2) / 6; break;
|
|
445
|
+
default: h = ((r - g) / d + 4) / 6;
|
|
446
|
+
}
|
|
447
|
+
return { h: h * 360, s, l };
|
|
448
|
+
}
|
|
449
|
+
const h1 = toHsl(rgb);
|
|
450
|
+
const h2 = toHsl(primaryRgb);
|
|
451
|
+
// Low saturation colors are not hue-family variants
|
|
452
|
+
if (h1.s < 0.1 || h2.s < 0.1) return false;
|
|
453
|
+
const diff = Math.abs(h1.h - h2.h);
|
|
454
|
+
return Math.min(diff, 360 - diff) < threshold;
|
|
455
|
+
}
|
|
456
|
+
|
|
316
457
|
export async function extractWcagPairs(page) {
|
|
317
458
|
let rawPairs = [];
|
|
318
459
|
try {
|
package/lib/extractors/logo.js
CHANGED
|
@@ -161,7 +161,7 @@ export async function extractLogo(page, url) {
|
|
|
161
161
|
|
|
162
162
|
if (rect.top < 200) score += 10;
|
|
163
163
|
if (rect.left < 400) score += 10;
|
|
164
|
-
if (rect.top >
|
|
164
|
+
if (rect.top > 500) score -= 50; // anything below fold is not a primary logo
|
|
165
165
|
|
|
166
166
|
// Penalize images hosted on a different domain (CDN paths on same domain are fine)
|
|
167
167
|
if (el.tagName === 'IMG') {
|
|
@@ -178,8 +178,9 @@ export async function extractLogo(page, url) {
|
|
|
178
178
|
const width = el.tagName === 'IMG' ? (el.naturalWidth || rect.width) : rect.width;
|
|
179
179
|
const height = el.tagName === 'IMG' ? (el.naturalHeight || rect.height) : rect.height;
|
|
180
180
|
if (width < 20 || height < 20) score -= 30;
|
|
181
|
-
if (width >
|
|
182
|
-
if (
|
|
181
|
+
if (width > 800 || height > 500) score -= 80; // large editorial/hero images are never logos
|
|
182
|
+
else if (width > 600 || height > 400) score -= 40;
|
|
183
|
+
if (altText.length > 50) score -= 40; // long alt text = content image, not logo
|
|
183
184
|
if (width > height && width < 400 && width > 40 && height > 10 && height < 120) score += 15;
|
|
184
185
|
|
|
185
186
|
return score;
|
|
@@ -360,7 +361,24 @@ export async function extractLogo(page, url) {
|
|
|
360
361
|
return candidates;
|
|
361
362
|
}
|
|
362
363
|
|
|
363
|
-
|
|
364
|
+
// SPA/PWA apps often render into a root div — treat it as transparent wrapper
|
|
365
|
+
const spaRoot = document.querySelector('#app, #root, #__next, #__nuxt, [data-reactroot]');
|
|
366
|
+
|
|
367
|
+
const headerEl = document.querySelector(
|
|
368
|
+
'header, [role="banner"], [class*="header"], [id*="header"]'
|
|
369
|
+
) || (() => {
|
|
370
|
+
// Fallback: find first visually top element in SPA root or body that spans full width
|
|
371
|
+
const root = spaRoot || document.body;
|
|
372
|
+
const children = Array.from(root.children);
|
|
373
|
+
for (const child of children) {
|
|
374
|
+
const rect = child.getBoundingClientRect();
|
|
375
|
+
const s = getComputedStyle(child);
|
|
376
|
+
if (rect.top < 100 && rect.width > window.innerWidth * 0.5 && s.display !== 'none') {
|
|
377
|
+
return child;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
})();
|
|
364
382
|
const navEl = document.querySelector('nav, [role="navigation"]');
|
|
365
383
|
const footerEl = document.querySelector('footer, [role="contentinfo"], [class*="footer"], [id*="footer"]');
|
|
366
384
|
|
|
@@ -374,15 +392,109 @@ export async function extractLogo(page, url) {
|
|
|
374
392
|
return null;
|
|
375
393
|
})();
|
|
376
394
|
|
|
395
|
+
// Sidebar: fixed/sticky left-edge containers and semantic sidebar elements
|
|
396
|
+
// Logo is typically in the first 1-3 children of a sidebar
|
|
397
|
+
const sidebarEl = (() => {
|
|
398
|
+
// Semantic sidebar selectors
|
|
399
|
+
const candidates = document.querySelectorAll(
|
|
400
|
+
'aside, [role="complementary"], [class*="sidebar"], [class*="side-nav"], [class*="sidenav"], [class*="side-bar"], [id*="sidebar"]'
|
|
401
|
+
);
|
|
402
|
+
for (const el of candidates) {
|
|
403
|
+
const rect = el.getBoundingClientRect();
|
|
404
|
+
if (rect.width > 0 && rect.height > 100) return el;
|
|
405
|
+
}
|
|
406
|
+
// Fallback: fixed/sticky element pinned to left edge
|
|
407
|
+
const all = document.querySelectorAll('*');
|
|
408
|
+
for (const el of all) {
|
|
409
|
+
try {
|
|
410
|
+
const s = getComputedStyle(el);
|
|
411
|
+
if ((s.position === 'fixed' || s.position === 'sticky') && s.display !== 'none') {
|
|
412
|
+
const rect = el.getBoundingClientRect();
|
|
413
|
+
if (rect.left < 100 && rect.height > 200 && rect.width < 400) return el;
|
|
414
|
+
}
|
|
415
|
+
} catch {}
|
|
416
|
+
}
|
|
417
|
+
return null;
|
|
418
|
+
})();
|
|
419
|
+
|
|
420
|
+
// For header: scan first 3 children and their direct children only
|
|
421
|
+
function firstChildrenZone(container, context) {
|
|
422
|
+
if (!container) return [];
|
|
423
|
+
const candidates = [];
|
|
424
|
+
const children = Array.from(container.children).slice(0, 3);
|
|
425
|
+
for (const child of children) {
|
|
426
|
+
const shallow = document.createElement('div');
|
|
427
|
+
// Scan the child itself + its direct children
|
|
428
|
+
const els = [child, ...Array.from(child.children)];
|
|
429
|
+
for (const el of els) {
|
|
430
|
+
if (el.matches('img, svg')) {
|
|
431
|
+
const found = findLogosInZone(el.parentElement || container, context);
|
|
432
|
+
candidates.push(...found.filter(c => c.el === el || c.cssBackground));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// Also run full zone scan on the first child subtree (logo is often nested 1-2 levels)
|
|
436
|
+
candidates.push(...findLogosInZone(child, context));
|
|
437
|
+
}
|
|
438
|
+
return candidates;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// For sidebar: scan first 3 children and their direct children
|
|
442
|
+
function sidebarZone(container) {
|
|
443
|
+
if (!container) return [];
|
|
444
|
+
const children = Array.from(container.children).slice(0, 3);
|
|
445
|
+
const candidates = [];
|
|
446
|
+
for (const child of children) {
|
|
447
|
+
candidates.push(...findLogosInZone(child, 'header'));
|
|
448
|
+
}
|
|
449
|
+
return candidates;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Global pre-scan: strong semantic signals that identify a logo anywhere in the document
|
|
453
|
+
const globalLogoCandidates = (() => {
|
|
454
|
+
const results = [];
|
|
455
|
+
const selectors = [
|
|
456
|
+
'img[class*="logo"]',
|
|
457
|
+
'img[id*="logo"]',
|
|
458
|
+
'a[class*="logo"] img',
|
|
459
|
+
'a[id*="logo"] img',
|
|
460
|
+
'[class*="logo-link"] img',
|
|
461
|
+
'[class*="logo-wrap"] img',
|
|
462
|
+
'[class*="logo-container"] img',
|
|
463
|
+
'a[href="/"] img',
|
|
464
|
+
'a[href="./"] img',
|
|
465
|
+
];
|
|
466
|
+
const seen = new Set();
|
|
467
|
+
for (const sel of selectors) {
|
|
468
|
+
try {
|
|
469
|
+
document.querySelectorAll(sel).forEach(el => {
|
|
470
|
+
if (seen.has(el)) return;
|
|
471
|
+
seen.add(el);
|
|
472
|
+
const rect = el.getBoundingClientRect();
|
|
473
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
474
|
+
if (rect.top > 500) return; // must be near top
|
|
475
|
+
if (rect.width > 600 || rect.height > 200) return; // too large
|
|
476
|
+
const score = scoreLogo(el, 'header') + 20; // bonus for semantic match
|
|
477
|
+
results.push({ el, score, context: 'header' });
|
|
478
|
+
});
|
|
479
|
+
} catch {}
|
|
480
|
+
}
|
|
481
|
+
return results;
|
|
482
|
+
})();
|
|
483
|
+
|
|
377
484
|
// Deduplicate zones — nav might be inside header, avoid double-scanning
|
|
378
|
-
const headerCandidates =
|
|
485
|
+
const headerCandidates = firstChildrenZone(headerEl, 'header');
|
|
379
486
|
const navCandidates = (navEl && !headerEl?.contains(navEl))
|
|
380
|
-
?
|
|
487
|
+
? firstChildrenZone(navEl, 'header')
|
|
488
|
+
: [];
|
|
489
|
+
const sidebarCandidates = (sidebarEl && !headerEl?.contains(sidebarEl))
|
|
490
|
+
? sidebarZone(sidebarEl)
|
|
381
491
|
: [];
|
|
382
492
|
|
|
383
493
|
const allCandidates = [
|
|
494
|
+
...globalLogoCandidates,
|
|
384
495
|
...headerCandidates,
|
|
385
496
|
...navCandidates,
|
|
497
|
+
...sidebarCandidates,
|
|
386
498
|
...findLogosInZone(footerEl, 'footer'),
|
|
387
499
|
...findLogosInZone(heroEl, 'hero'),
|
|
388
500
|
];
|
|
@@ -80,15 +80,20 @@ export async function extractTypography(page) {
|
|
|
80
80
|
el.getAttribute("role") === "button" ||
|
|
81
81
|
className.includes("btn")
|
|
82
82
|
) {
|
|
83
|
-
context = "
|
|
83
|
+
context = "ui";
|
|
84
84
|
} else if (el.tagName === "A" && el.href) {
|
|
85
85
|
context = "link";
|
|
86
86
|
} else if (headingMatch) {
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
const level = parseInt(headingMatch[1]);
|
|
88
|
+
// h1 at very large size = display, otherwise heading
|
|
89
|
+
context = (level === 1 && size >= 56) ? "display" : `heading-${level}`;
|
|
90
|
+
} else if (size >= 56) {
|
|
91
|
+
context = "display"; // non-heading super-sized text (hero, marketing)
|
|
92
|
+
} else if (size <= 12) {
|
|
89
93
|
context = "caption";
|
|
90
|
-
} else if (
|
|
91
|
-
|
|
94
|
+
} else if (el.tagName === "LABEL" || el.tagName === "SMALL" ||
|
|
95
|
+
className.includes("label") || className.includes("caption") || className.includes("badge")) {
|
|
96
|
+
context = "ui";
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
const key = `${family}|${size}|${weight}|${context}|${letterSpacing}|${textTransform}`;
|
|
@@ -185,7 +185,10 @@ function displayColors(colors) {
|
|
|
185
185
|
hasAlpha: formats.hasAlpha,
|
|
186
186
|
label: '',
|
|
187
187
|
type: 'palette',
|
|
188
|
-
confidence: c.confidence
|
|
188
|
+
confidence: c.confidence,
|
|
189
|
+
role: c.role,
|
|
190
|
+
onColor: c.onColor,
|
|
191
|
+
hover: c.hover,
|
|
189
192
|
});
|
|
190
193
|
});
|
|
191
194
|
}
|
|
@@ -220,14 +223,14 @@ function displayColors(colors) {
|
|
|
220
223
|
|
|
221
224
|
// Display each color on a single line: swatch, hex, role, rgb, oklch.
|
|
222
225
|
// lch is omitted here for compactness but remains in JSON output.
|
|
223
|
-
uniqueColors.forEach(({ hex, rgb, oklch, label, confidence }, index) => {
|
|
226
|
+
uniqueColors.forEach(({ hex, rgb, oklch, label, confidence, role, onColor, hover }, index) => {
|
|
224
227
|
const isLast = index === uniqueColors.length - 1;
|
|
225
228
|
const branch = isLast ? '└─' : '├─';
|
|
226
229
|
|
|
227
230
|
let conf;
|
|
228
231
|
if (confidence === 'high') conf = color.success('●');
|
|
229
232
|
else if (confidence === 'medium') conf = color.warning('●');
|
|
230
|
-
else conf = chalk.gray('●');
|
|
233
|
+
else conf = chalk.gray('●');
|
|
231
234
|
|
|
232
235
|
let swatch;
|
|
233
236
|
try {
|
|
@@ -236,17 +239,25 @@ function displayColors(colors) {
|
|
|
236
239
|
swatch = ' ';
|
|
237
240
|
}
|
|
238
241
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
+
let onSwatch = '';
|
|
243
|
+
if (onColor && role === 'accent') {
|
|
244
|
+
try { onSwatch = ' on:' + chalk.bgHex(hex)(chalk.hex(onColor)(' Aa ')); } catch {}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const rawLabel = label || (role && role !== 'palette' ? role : '');
|
|
248
|
+
const truncated = rawLabel.length > 14 ? rawLabel.slice(0, 13) + '…' : rawLabel;
|
|
249
|
+
const labelText = chalk.dim(truncated.padEnd(15));
|
|
242
250
|
const rgbText = chalk.dim((rgb || '').padEnd(20));
|
|
243
251
|
|
|
252
|
+
const hoverText = (hover && role === 'accent') ? chalk.dim(` hover:${hover}`) : '';
|
|
253
|
+
|
|
244
254
|
console.log(
|
|
245
255
|
chalk.dim(`│ ${branch}`) + ' ' +
|
|
246
256
|
`${conf} ${swatch} ${hex} ` +
|
|
247
257
|
labelText + ' ' +
|
|
248
|
-
rgbText +
|
|
249
|
-
|
|
258
|
+
rgbText +
|
|
259
|
+
onSwatch +
|
|
260
|
+
hoverText
|
|
250
261
|
);
|
|
251
262
|
});
|
|
252
263
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dembrandt",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.9",
|
|
4
4
|
"description": "Extract design tokens and publicly visible CSS information from any website",
|
|
5
5
|
"mcpName": "io.github.dembrandt/dembrandt",
|
|
6
6
|
"main": "index.js",
|
|
@@ -19,7 +19,13 @@
|
|
|
19
19
|
"install-browser": "npx playwright install chromium firefox || echo 'Playwright browser installation failed. You may need to install system dependencies manually.'",
|
|
20
20
|
"qa:baseline": "node test/qa.mjs --baseline",
|
|
21
21
|
"qa:diff": "node test/qa.mjs --diff",
|
|
22
|
-
"qa:site": "node test/qa.mjs --site"
|
|
22
|
+
"qa:site": "node test/qa.mjs --site",
|
|
23
|
+
"gold:label": "node tools/gold-label.mjs",
|
|
24
|
+
"gold:run": "node tools/gold-run.mjs",
|
|
25
|
+
"gold:judge": "node tools/gold-ai-judge.mjs",
|
|
26
|
+
"gold:tune": "node tools/gold-tune.mjs",
|
|
27
|
+
"gold:cluster": "node tools/gold-cluster.mjs",
|
|
28
|
+
"gold:analyze": "node tools/gold-analyze.mjs"
|
|
23
29
|
},
|
|
24
30
|
"keywords": [
|
|
25
31
|
"design-tokens",
|
|
@@ -41,11 +47,10 @@
|
|
|
41
47
|
"license": "MIT",
|
|
42
48
|
"dependencies": {
|
|
43
49
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
44
|
-
"@playwright/browser-chromium": "^1.57.0",
|
|
45
|
-
"@playwright/browser-firefox": "^1.57.0",
|
|
46
50
|
"chalk": "^5.3.0",
|
|
47
51
|
"commander": "^11.1.0",
|
|
48
52
|
"ora": "^7.0.1",
|
|
53
|
+
"playwright": "^1.57.0",
|
|
49
54
|
"playwright-core": "^1.57.0",
|
|
50
55
|
"zod": "4.3.6"
|
|
51
56
|
},
|