dembrandt 0.12.8 → 0.12.10
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/README.md +5 -5
- package/index.js +69 -52
- package/lib/extractors/colors.js +147 -6
- package/lib/extractors/index.js +1 -1
- package/lib/extractors/logo.js +118 -6
- package/lib/extractors/typography.js +10 -5
- package/lib/formatters/terminal.js +19 -8
- package/lib/merger.js +52 -0
- package/mcp-server.js +150 -21
- package/package.json +9 -4
package/README.md
CHANGED
|
@@ -67,9 +67,9 @@ dembrandt example.com --mobile # Use mobile viewport (390x844) for respo
|
|
|
67
67
|
dembrandt example.com --slow # 3x longer timeouts (24s hydration) for JavaScript-heavy sites
|
|
68
68
|
dembrandt example.com --brand-guide # Generate a brand guide PDF
|
|
69
69
|
dembrandt example.com --design-md # Generate a DESIGN.md file for AI agents
|
|
70
|
-
dembrandt example.com --
|
|
70
|
+
dembrandt example.com --crawl 5 # Analyze 5 pages (homepage + 4 discovered pages), merges results
|
|
71
71
|
dembrandt example.com --sitemap # Discover pages from sitemap.xml instead of DOM links
|
|
72
|
-
dembrandt example.com --
|
|
72
|
+
dembrandt example.com --crawl 10 --sitemap # Combine: up to 10 pages discovered via sitemap
|
|
73
73
|
dembrandt example.com --no-sandbox # Disable Chromium sandbox (required for Docker/CI)
|
|
74
74
|
dembrandt example.com --browser=firefox # Use Firefox instead of Chromium (better for Cloudflare bypass)
|
|
75
75
|
dembrandt example.com --wcag # WCAG 2.1 contrast analysis — real DOM pairs, AA/AAA grades
|
|
@@ -83,13 +83,13 @@ Analyze multiple pages to get a more complete picture of a site's design system.
|
|
|
83
83
|
|
|
84
84
|
```bash
|
|
85
85
|
# Analyze homepage + 4 auto-discovered pages (default: 5 total)
|
|
86
|
-
dembrandt example.com --
|
|
86
|
+
dembrandt example.com --crawl 5
|
|
87
87
|
|
|
88
88
|
# Use sitemap.xml for page discovery instead of DOM link scraping
|
|
89
89
|
dembrandt example.com --sitemap
|
|
90
90
|
|
|
91
91
|
# Combine both: up to 10 pages from sitemap
|
|
92
|
-
dembrandt example.com --
|
|
92
|
+
dembrandt example.com --crawl 10 --sitemap
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
**Page discovery** works two ways:
|
|
@@ -197,7 +197,7 @@ dembrandt braintree.com --save-output
|
|
|
197
197
|
|
|
198
198
|
**Multi-page audit** — get a fuller picture across the whole site
|
|
199
199
|
```bash
|
|
200
|
-
dembrandt stripe.com --
|
|
200
|
+
dembrandt stripe.com --crawl 10 --sitemap --save-output
|
|
201
201
|
```
|
|
202
202
|
|
|
203
203
|
**Spot-check a value** — verify a specific token fast
|
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,80 @@ 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,
|
|
135
|
+
includeRawColors: opts.rawColors,
|
|
126
136
|
});
|
|
127
137
|
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
// Build list of additional URLs to extract
|
|
139
|
+
let additionalUrls = [];
|
|
140
|
+
|
|
141
|
+
if (hasExplicitPaths) {
|
|
142
|
+
// Explicit paths: resolve against base URL
|
|
143
|
+
const base = new URL(result.url);
|
|
144
|
+
additionalUrls = paths.map(p => {
|
|
145
|
+
if (p.startsWith('http')) return p;
|
|
146
|
+
return `${base.protocol}//${base.host}${p.startsWith('/') ? p : '/' + p}`;
|
|
147
|
+
});
|
|
148
|
+
} else if (opts.sitemap) {
|
|
149
|
+
if (!opts.jsonOnly) spinner.start("Fetching sitemap...");
|
|
150
|
+
const max = crawlN ? crawlN - 1 : 20;
|
|
151
|
+
additionalUrls = await parseSitemap(result.url, max);
|
|
152
|
+
if (additionalUrls.length === 0 && result.url !== url) {
|
|
153
|
+
additionalUrls = await parseSitemap(url, max);
|
|
142
154
|
}
|
|
155
|
+
} else if (isAutoCrawl) {
|
|
156
|
+
additionalUrls = result._discoveredLinks || [];
|
|
157
|
+
}
|
|
143
158
|
|
|
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
|
-
}
|
|
159
|
+
delete result._discoveredLinks;
|
|
175
160
|
|
|
176
|
-
|
|
177
|
-
|
|
161
|
+
if (additionalUrls.length === 0) {
|
|
162
|
+
if ((hasExplicitPaths || opts.sitemap || isAutoCrawl) && !opts.jsonOnly) {
|
|
163
|
+
spinner.warn("No additional pages discovered");
|
|
178
164
|
}
|
|
179
165
|
} else {
|
|
166
|
+
spinner.stop();
|
|
167
|
+
if (!opts.jsonOnly) console.log(chalk.dim(` Found ${additionalUrls.length} page(s) to analyze`));
|
|
168
|
+
|
|
169
|
+
const allResults = [result];
|
|
170
|
+
for (let i = 0; i < additionalUrls.length; i++) {
|
|
171
|
+
const pageUrl = additionalUrls[i];
|
|
172
|
+
const pageNum = i + 2;
|
|
173
|
+
const total = additionalUrls.length + 1;
|
|
174
|
+
if (!opts.jsonOnly) spinner.start(`Extracting page ${pageNum}/${total}: ${new URL(pageUrl).pathname}`);
|
|
175
|
+
|
|
176
|
+
await new Promise(r => setTimeout(r, 1500 + Math.random() * 1500));
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const pageResult = await extractBranding(pageUrl, spinner, browser, {
|
|
180
|
+
navigationTimeout: 90000,
|
|
181
|
+
darkMode: opts.darkMode,
|
|
182
|
+
mobile: opts.mobile,
|
|
183
|
+
slow: opts.slow,
|
|
184
|
+
});
|
|
185
|
+
delete pageResult._discoveredLinks;
|
|
186
|
+
allResults.push(pageResult);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (!opts.jsonOnly) spinner.warn(`Skipping ${pageUrl}: ${String(err?.message || err).slice(0, 80)}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
spinner.stop();
|
|
193
|
+
result = mergeResults(allResults);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!hasExplicitPaths && !opts.sitemap && !isAutoCrawl) {
|
|
180
197
|
delete result._discoveredLinks;
|
|
181
198
|
}
|
|
182
199
|
|
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/index.js
CHANGED
|
@@ -168,7 +168,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
|
|
|
168
168
|
spinner.stop();
|
|
169
169
|
console.log(color.info("\n Extracting design tokens...\n"));
|
|
170
170
|
|
|
171
|
-
spinner.start("Analyzing design system (
|
|
171
|
+
spinner.start("Analyzing design system (17 parallel tasks)...");
|
|
172
172
|
const [
|
|
173
173
|
{ logo, instances: logoInstances, favicons },
|
|
174
174
|
colors,
|
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/lib/merger.js
CHANGED
|
@@ -275,6 +275,56 @@ function mergeByName(results, getter) {
|
|
|
275
275
|
return out;
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
function mergeGradients(results) {
|
|
279
|
+
const map = new Map();
|
|
280
|
+
results.forEach(r => {
|
|
281
|
+
(r.gradients || []).forEach(g => {
|
|
282
|
+
const key = g.gradient;
|
|
283
|
+
if (!map.has(key)) {
|
|
284
|
+
map.set(key, { ...g });
|
|
285
|
+
} else {
|
|
286
|
+
map.get(key).count += (g.count || 1);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
return [...map.values()].sort((a, b) => b.count - a.count);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function mergeMotion(results) {
|
|
294
|
+
const base = results[0].motion || { durations: [], easings: [], animations: [], contexts: {}, interactiveDeltas: [] };
|
|
295
|
+
|
|
296
|
+
const durationMap = new Map();
|
|
297
|
+
results.forEach(r => {
|
|
298
|
+
(r.motion?.durations || []).forEach(d => {
|
|
299
|
+
if (!durationMap.has(d.value)) durationMap.set(d.value, { ...d });
|
|
300
|
+
else durationMap.get(d.value).count += (d.count || 1);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const easingMap = new Map();
|
|
305
|
+
results.forEach(r => {
|
|
306
|
+
(r.motion?.easings || []).forEach(e => {
|
|
307
|
+
if (!easingMap.has(e.value)) easingMap.set(e.value, { ...e });
|
|
308
|
+
else easingMap.get(e.value).count += (e.count || 1);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const animMap = new Map();
|
|
313
|
+
results.forEach(r => {
|
|
314
|
+
(r.motion?.animations || []).forEach(a => {
|
|
315
|
+
if (!animMap.has(a.name || a.value)) animMap.set(a.name || a.value, { ...a });
|
|
316
|
+
else animMap.get(a.name || a.value).count += (a.count || 1);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
...base,
|
|
322
|
+
durations: [...durationMap.values()].sort((a, b) => a.ms - b.ms),
|
|
323
|
+
easings: [...easingMap.values()].sort((a, b) => b.count - a.count).slice(0, 8),
|
|
324
|
+
animations: [...animMap.values()].sort((a, b) => b.count - a.count).slice(0, 8),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
278
328
|
/**
|
|
279
329
|
* Merge an array of per-page result objects into a single unified result.
|
|
280
330
|
* @param {Object[]} results - Array of extractBranding() result objects
|
|
@@ -298,6 +348,8 @@ export function mergeResults(results) {
|
|
|
298
348
|
borderRadius: mergeBorderRadius(results),
|
|
299
349
|
borders: mergeBorders(results),
|
|
300
350
|
shadows: mergeShadows(results),
|
|
351
|
+
gradients: mergeGradients(results),
|
|
352
|
+
motion: mergeMotion(results),
|
|
301
353
|
components: mergeComponents(results),
|
|
302
354
|
breakpoints: [
|
|
303
355
|
...new Map(
|
package/mcp-server.js
CHANGED
|
@@ -99,16 +99,119 @@ function errorResult(message) {
|
|
|
99
99
|
return { content: [{ type: "text", text: message }], isError: true };
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// ── Job Queue ──────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
class JobQueue {
|
|
105
|
+
#jobs = new Map();
|
|
106
|
+
#queue = [];
|
|
107
|
+
#running = new Set();
|
|
108
|
+
#maxConcurrent = 2;
|
|
109
|
+
|
|
110
|
+
enqueue(url, opts, pick) {
|
|
111
|
+
const id = `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
112
|
+
this.#jobs.set(id, {
|
|
113
|
+
status: "queued",
|
|
114
|
+
url,
|
|
115
|
+
opts,
|
|
116
|
+
pick,
|
|
117
|
+
createdAt: Date.now(),
|
|
118
|
+
startedAt: undefined,
|
|
119
|
+
completedAt: undefined,
|
|
120
|
+
result: undefined,
|
|
121
|
+
error: undefined,
|
|
122
|
+
});
|
|
123
|
+
this.#queue.push(id);
|
|
124
|
+
void this.#drain();
|
|
125
|
+
return id;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
get(id) {
|
|
129
|
+
return this.#jobs.get(id) ?? null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
cancel(id) {
|
|
133
|
+
const job = this.#jobs.get(id);
|
|
134
|
+
if (!job || job.status !== "queued") return false;
|
|
135
|
+
job.status = "cancelled";
|
|
136
|
+
job.completedAt = Date.now();
|
|
137
|
+
const idx = this.#queue.indexOf(id);
|
|
138
|
+
if (idx !== -1) this.#queue.splice(idx, 1);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async #drain() {
|
|
143
|
+
while (this.#queue.length > 0 && this.#running.size < this.#maxConcurrent) {
|
|
144
|
+
const id = this.#queue.shift();
|
|
145
|
+
const job = this.#jobs.get(id);
|
|
146
|
+
if (!job || job.status === "cancelled") continue;
|
|
147
|
+
|
|
148
|
+
job.status = "running";
|
|
149
|
+
job.startedAt = Date.now();
|
|
150
|
+
this.#running.add(id);
|
|
151
|
+
|
|
152
|
+
runExtraction(job.url, job.opts)
|
|
153
|
+
.then((result) => {
|
|
154
|
+
if (job.status === "cancelled") return;
|
|
155
|
+
if (result.ok) {
|
|
156
|
+
job.status = "completed";
|
|
157
|
+
job.result = job.pick(result.data);
|
|
158
|
+
} else {
|
|
159
|
+
job.status = "failed";
|
|
160
|
+
job.error = result.error;
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
.catch((err) => {
|
|
164
|
+
if (job.status !== "cancelled") {
|
|
165
|
+
job.status = "failed";
|
|
166
|
+
job.error = err.message || String(err);
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
.finally(() => {
|
|
170
|
+
job.completedAt = Date.now();
|
|
171
|
+
this.#running.delete(id);
|
|
172
|
+
void this.#drain();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Remove completed/failed/cancelled jobs older than 1 hour
|
|
178
|
+
cleanup() {
|
|
179
|
+
const cutoff = Date.now() - 3_600_000;
|
|
180
|
+
for (const [id, job] of this.#jobs) {
|
|
181
|
+
if (
|
|
182
|
+
["completed", "failed", "cancelled"].includes(job.status) &&
|
|
183
|
+
job.completedAt !== undefined &&
|
|
184
|
+
job.completedAt < cutoff
|
|
185
|
+
) {
|
|
186
|
+
this.#jobs.delete(id);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const jobQueue = new JobQueue();
|
|
193
|
+
setInterval(() => jobQueue.cleanup(), 600_000);
|
|
194
|
+
|
|
195
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
102
197
|
/**
|
|
103
|
-
* Wrapper
|
|
104
|
-
*
|
|
198
|
+
* Wrapper for extraction tools.
|
|
199
|
+
* Async by default: enqueues and returns a job_id immediately.
|
|
200
|
+
* Pass sync: true to block and return the result directly.
|
|
105
201
|
*/
|
|
106
202
|
function toolHandler(pick, extraOptions = {}) {
|
|
107
203
|
return async (params) => {
|
|
108
|
-
const { url, slow, darkMode } = params;
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
204
|
+
const { url, slow, darkMode, sync } = params;
|
|
205
|
+
const opts = { slow, darkMode, ...extraOptions };
|
|
206
|
+
|
|
207
|
+
if (sync) {
|
|
208
|
+
const result = await runExtraction(url, opts);
|
|
209
|
+
if (!result.ok) return errorResult(result.error);
|
|
210
|
+
return jsonResult(pick(result.data));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const jobId = jobQueue.enqueue(url, opts, pick);
|
|
214
|
+
return jsonResult({ job_id: jobId, status: "queued" });
|
|
112
215
|
};
|
|
113
216
|
}
|
|
114
217
|
|
|
@@ -116,21 +219,22 @@ function toolHandler(pick, extraOptions = {}) {
|
|
|
116
219
|
|
|
117
220
|
const url = z.string().describe("Website URL (e.g. example.com)");
|
|
118
221
|
const slow = z.boolean().optional().default(false).describe("3x timeouts for heavy SPAs");
|
|
222
|
+
const sync = z.boolean().optional().default(false).describe("Wait for result directly instead of returning a job_id (blocks 15-40s)");
|
|
119
223
|
|
|
120
|
-
// ──
|
|
224
|
+
// ── Extraction tools ───────────────────────────────────────────────────
|
|
121
225
|
|
|
122
226
|
server.tool(
|
|
123
227
|
"get_design_tokens",
|
|
124
|
-
"Extract the full design system from a live website. Launches a real browser, navigates to the site, and returns production-ready design tokens: color palette (hex, RGB, LCH, OKLCH) with semantic roles and CSS custom properties, typography scale (families, fallbacks, sizes, weights, line heights, letter spacing by context), spacing system with grid detection, border radii, border patterns, box shadows for elevation, component styles (buttons with hover/focus states, inputs, links, badges), responsive breakpoints, logo and favicons, site name, detected CSS frameworks, and icon systems.
|
|
125
|
-
{ url, slow },
|
|
228
|
+
"Extract the full design system from a live website. Launches a real browser, navigates to the site, and returns production-ready design tokens: color palette (hex, RGB, LCH, OKLCH) with semantic roles and CSS custom properties, typography scale (families, fallbacks, sizes, weights, line heights, letter spacing by context), spacing system with grid detection, border radii, border patterns, box shadows for elevation, component styles (buttons with hover/focus states, inputs, links, badges), responsive breakpoints, logo and favicons, site name, detected CSS frameworks, and icon systems. Returns a job_id by default — use get_job_status to poll for the result.",
|
|
229
|
+
{ url, slow, sync },
|
|
126
230
|
toolHandler((d) => d),
|
|
127
231
|
);
|
|
128
232
|
|
|
129
233
|
server.tool(
|
|
130
234
|
"get_color_palette",
|
|
131
|
-
"Extract brand colors from a live website. Returns semantic colors (primary, secondary, accent), full palette ranked by usage frequency and confidence (high/medium/low), CSS custom properties with their design-system names, and hover/focus state colors discovered by simulating real user interactions. Each color in hex, RGB, LCH, and OKLCH.",
|
|
235
|
+
"Extract brand colors from a live website. Returns semantic colors (primary, secondary, accent), full palette ranked by usage frequency and confidence (high/medium/low), CSS custom properties with their design-system names, and hover/focus state colors discovered by simulating real user interactions. Each color in hex, RGB, LCH, and OKLCH. Returns a job_id by default — use get_job_status to poll for the result.",
|
|
132
236
|
{
|
|
133
|
-
url, slow,
|
|
237
|
+
url, slow, sync,
|
|
134
238
|
darkMode: z.boolean().optional().default(false).describe("Also extract dark mode palette"),
|
|
135
239
|
},
|
|
136
240
|
toolHandler((d) => ({ url: d.url, colors: d.colors })),
|
|
@@ -138,22 +242,22 @@ server.tool(
|
|
|
138
242
|
|
|
139
243
|
server.tool(
|
|
140
244
|
"get_typography",
|
|
141
|
-
"Extract typography from a live website. Returns every font family with its fallback stack, the complete type scale grouped by context (heading, body, button, link, caption) with pixel and rem sizes, weights, line heights, letter spacing, and text transforms. Also reports font sources: Google Fonts URLs, Adobe Fonts usage, and variable font detection.",
|
|
142
|
-
{ url, slow },
|
|
245
|
+
"Extract typography from a live website. Returns every font family with its fallback stack, the complete type scale grouped by context (heading, body, button, link, caption) with pixel and rem sizes, weights, line heights, letter spacing, and text transforms. Also reports font sources: Google Fonts URLs, Adobe Fonts usage, and variable font detection. Returns a job_id by default — use get_job_status to poll for the result.",
|
|
246
|
+
{ url, slow, sync },
|
|
143
247
|
toolHandler((d) => ({ url: d.url, typography: d.typography })),
|
|
144
248
|
);
|
|
145
249
|
|
|
146
250
|
server.tool(
|
|
147
251
|
"get_component_styles",
|
|
148
|
-
"Extract UI component styles from a live website. Returns button variants with default, hover, active, and focus states (background, text color, padding, border radius, border, shadow, outline, opacity), input field styles (border, focus ring, padding, placeholder), link styles (color, text decoration, hover changes), and badge/tag styles.",
|
|
149
|
-
{ url, slow },
|
|
252
|
+
"Extract UI component styles from a live website. Returns button variants with default, hover, active, and focus states (background, text color, padding, border radius, border, shadow, outline, opacity), input field styles (border, focus ring, padding, placeholder), link styles (color, text decoration, hover changes), and badge/tag styles. Returns a job_id by default — use get_job_status to poll for the result.",
|
|
253
|
+
{ url, slow, sync },
|
|
150
254
|
toolHandler((d) => ({ url: d.url, components: d.components })),
|
|
151
255
|
);
|
|
152
256
|
|
|
153
257
|
server.tool(
|
|
154
258
|
"get_surfaces",
|
|
155
|
-
"Extract surface treatment tokens from a live website: border radii with element context (which radii are used on buttons vs cards vs inputs vs modals), border patterns (width + style + color combinations), and box shadow elevation levels.",
|
|
156
|
-
{ url, slow },
|
|
259
|
+
"Extract surface treatment tokens from a live website: border radii with element context (which radii are used on buttons vs cards vs inputs vs modals), border patterns (width + style + color combinations), and box shadow elevation levels. Returns a job_id by default — use get_job_status to poll for the result.",
|
|
260
|
+
{ url, slow, sync },
|
|
157
261
|
toolHandler((d) => ({
|
|
158
262
|
url: d.url,
|
|
159
263
|
borderRadius: d.borderRadius,
|
|
@@ -164,15 +268,15 @@ server.tool(
|
|
|
164
268
|
|
|
165
269
|
server.tool(
|
|
166
270
|
"get_spacing",
|
|
167
|
-
"Extract the spacing system from a live website: common margin and padding values sorted by frequency, pixel and rem values, and grid system detection (4px, 8px, or custom scale).",
|
|
168
|
-
{ url, slow },
|
|
271
|
+
"Extract the spacing system from a live website: common margin and padding values sorted by frequency, pixel and rem values, and grid system detection (4px, 8px, or custom scale). Returns a job_id by default — use get_job_status to poll for the result.",
|
|
272
|
+
{ url, slow, sync },
|
|
169
273
|
toolHandler((d) => ({ url: d.url, spacing: d.spacing })),
|
|
170
274
|
);
|
|
171
275
|
|
|
172
276
|
server.tool(
|
|
173
277
|
"get_brand_identity",
|
|
174
|
-
"Extract brand identity from a live website: site name, logo (source, dimensions, safe zone), all favicon variants (icon, apple-touch-icon, og:image, twitter:image with sizes and URLs), detected CSS frameworks (Tailwind, Bootstrap, MUI, etc.), icon systems (Font Awesome, Material Icons, SVG), and responsive breakpoints.",
|
|
175
|
-
{ url, slow },
|
|
278
|
+
"Extract brand identity from a live website: site name, logo (source, dimensions, safe zone), all favicon variants (icon, apple-touch-icon, og:image, twitter:image with sizes and URLs), detected CSS frameworks (Tailwind, Bootstrap, MUI, etc.), icon systems (Font Awesome, Material Icons, SVG), and responsive breakpoints. Returns a job_id by default — use get_job_status to poll for the result.",
|
|
279
|
+
{ url, slow, sync },
|
|
176
280
|
toolHandler((d) => ({
|
|
177
281
|
url: d.url,
|
|
178
282
|
siteName: d.siteName,
|
|
@@ -184,6 +288,31 @@ server.tool(
|
|
|
184
288
|
})),
|
|
185
289
|
);
|
|
186
290
|
|
|
291
|
+
// ── Job management tools ───────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
server.tool(
|
|
294
|
+
"get_job_status",
|
|
295
|
+
"Poll for the result of an async extraction job. Returns status (queued/running/completed/failed/cancelled) and the full result once completed. Call this after any extraction tool that returned a job_id.",
|
|
296
|
+
{ job_id: z.string().describe("The job_id returned by an extraction tool") },
|
|
297
|
+
({ job_id }) => {
|
|
298
|
+
const job = jobQueue.get(job_id);
|
|
299
|
+
if (!job) return errorResult(`No job found with id: ${job_id}`);
|
|
300
|
+
if (job.status === "completed") return jsonResult({ job_id, status: "completed", result: job.result });
|
|
301
|
+
if (job.status === "failed") return errorResult(`Job failed: ${job.error}`);
|
|
302
|
+
return jsonResult({ job_id, status: job.status });
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
server.tool(
|
|
307
|
+
"cancel_job",
|
|
308
|
+
"Cancel a queued extraction job. Has no effect on jobs that are already running.",
|
|
309
|
+
{ job_id: z.string().describe("The job_id to cancel") },
|
|
310
|
+
({ job_id }) => {
|
|
311
|
+
const cancelled = jobQueue.cancel(job_id);
|
|
312
|
+
return jsonResult({ job_id, cancelled });
|
|
313
|
+
},
|
|
314
|
+
);
|
|
315
|
+
|
|
187
316
|
// ── Start ──────────────────────────────────────────────────────────────
|
|
188
317
|
|
|
189
318
|
const transport = new StdioServerTransport();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dembrandt",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.10",
|
|
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
|
},
|