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 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 --pages 5 # Analyze 5 pages (homepage + 4 discovered pages), merges results
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 --pages 10 --sitemap # Combine: up to 10 pages discovered via sitemap
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 --pages 5
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 --pages 10 --sitemap
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 --pages 10 --sitemap --save-output
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("--pages <n>", "Analyze up to N total pages including start URL (default: 5)", (v) => {
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 isMultiPage = opts.pages || opts.sitemap;
117
- const maxPages = (opts.pages || 5) - 1; // -1 because homepage counts
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: isMultiPage && !opts.sitemap ? maxPages : null,
133
+ discoverLinks: isAutoCrawl ? crawlN - 1 : null,
125
134
  wcag: opts.wcag,
135
+ includeRawColors: opts.rawColors,
126
136
  });
127
137
 
128
- // Multi-page crawl
129
- if (isMultiPage && maxPages > 0) {
130
- if (!opts.jsonOnly) spinner.start("Discovering pages...");
131
-
132
- let additionalUrls;
133
- if (opts.sitemap) {
134
- // Try post-redirect URL first, fall back to user-provided URL
135
- // (some sites redirect to a subdomain while the sitemap stays on www)
136
- additionalUrls = await parseSitemap(result.url, maxPages);
137
- if (additionalUrls.length === 0 && result.url !== url) {
138
- additionalUrls = await parseSitemap(url, maxPages);
139
- }
140
- } else {
141
- additionalUrls = result._discoveredLinks || [];
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
- delete result._discoveredLinks;
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
- spinner.stop();
177
- result = mergeResults(allResults);
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
 
@@ -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
- if (
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
- semanticColors.primary = bgColor !== "rgba(0, 0, 0, 0)" && bgColor !== "transparent" ? bgColor : textColor;
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 {
@@ -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 (16 parallel tasks)...");
171
+ spinner.start("Analyzing design system (17 parallel tasks)...");
172
172
  const [
173
173
  { logo, instances: logoInstances, favicons },
174
174
  colors,
@@ -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 > 800) score -= 30; // deeply buried elements are unlikely primary logos
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 > 600 || height > 400) score -= 40;
182
- if (altText.length > 50) score -= 30;
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
- const headerEl = document.querySelector('header, [role="banner"], [class*="header"], [id*="header"]');
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 = findLogosInZone(headerEl, 'header');
485
+ const headerCandidates = firstChildrenZone(headerEl, 'header');
379
486
  const navCandidates = (navEl && !headerEl?.contains(navEl))
380
- ? findLogosInZone(navEl, 'header') // same score weight as header
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 = "button";
83
+ context = "ui";
84
84
  } else if (el.tagName === "A" && el.href) {
85
85
  context = "link";
86
86
  } else if (headingMatch) {
87
- context = `heading-${headingMatch[1]}`;
88
- } else if (size <= 14) {
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 (size >= 20) {
91
- context = "display";
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('●'); // low confidence
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
- const rawLabel = label || '';
240
- const truncated = rawLabel.length > 22 ? rawLabel.slice(0, 21) + '' : rawLabel;
241
- const labelText = chalk.dim(truncated.padEnd(23));
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
- chalk.dim(oklch || '')
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 that handles extraction + error formatting for all tools.
104
- * `pick` receives the full result and returns the filtered subset.
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 result = await runExtraction(url, { slow, darkMode, ...extraOptions });
110
- if (!result.ok) return errorResult(result.error);
111
- return jsonResult(pick(result.data));
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
- // ── Tools ──────────────────────────────────────────────────────────────
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. Takes 15-40 seconds depending on site complexity.",
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.8",
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
  },