dembrandt 0.12.7 → 0.12.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -181,37 +181,6 @@ dembrandt example.com --brand-guide
181
181
  # Saves to: output/example.com/TIMESTAMP.brand-guide.pdf
182
182
  ```
183
183
 
184
- ## Local UI
185
-
186
- Browse your extractions in a visual interface.
187
-
188
- ### Setup
189
-
190
- ```bash
191
- cd local-ui
192
- npm install
193
- ```
194
-
195
- ### Running
196
-
197
- ```bash
198
- npm start
199
- ```
200
-
201
- Opens http://localhost:5173 with API on port 3002.
202
-
203
- ### Features
204
-
205
- - Visual grid of all extractions
206
- - Color palettes with click-to-copy
207
- - Typography specimens
208
- - Spacing, shadows, border radius visualization
209
- - Button and link component previews
210
- - Dark/light theme toggle
211
- - Section nav links on extraction pages, jump directly to Colors, Typography, Shadows, etc. via a sticky sidebar
212
-
213
- Extractions are performed via CLI (`dembrandt <url> --save-output`) and automatically appear in the UI.
214
-
215
184
  ## Recipes
216
185
 
217
186
  **Quick brand scan**
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,79 @@ 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,
126
135
  });
127
136
 
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 || [];
137
+ // Build list of additional URLs to extract
138
+ let additionalUrls = [];
139
+
140
+ if (hasExplicitPaths) {
141
+ // Explicit paths: resolve against base URL
142
+ const base = new URL(result.url);
143
+ additionalUrls = paths.map(p => {
144
+ if (p.startsWith('http')) return p;
145
+ return `${base.protocol}//${base.host}${p.startsWith('/') ? p : '/' + p}`;
146
+ });
147
+ } else if (opts.sitemap) {
148
+ if (!opts.jsonOnly) spinner.start("Fetching sitemap...");
149
+ const max = crawlN ? crawlN - 1 : 20;
150
+ additionalUrls = await parseSitemap(result.url, max);
151
+ if (additionalUrls.length === 0 && result.url !== url) {
152
+ additionalUrls = await parseSitemap(url, max);
142
153
  }
154
+ } else if (isAutoCrawl) {
155
+ additionalUrls = result._discoveredLinks || [];
156
+ }
143
157
 
144
- 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
- }
158
+ delete result._discoveredLinks;
175
159
 
176
- spinner.stop();
177
- result = mergeResults(allResults);
160
+ if (additionalUrls.length === 0) {
161
+ if ((hasExplicitPaths || opts.sitemap || isAutoCrawl) && !opts.jsonOnly) {
162
+ spinner.warn("No additional pages discovered");
178
163
  }
179
164
  } else {
165
+ spinner.stop();
166
+ if (!opts.jsonOnly) console.log(chalk.dim(` Found ${additionalUrls.length} page(s) to analyze`));
167
+
168
+ const allResults = [result];
169
+ for (let i = 0; i < additionalUrls.length; i++) {
170
+ const pageUrl = additionalUrls[i];
171
+ const pageNum = i + 2;
172
+ const total = additionalUrls.length + 1;
173
+ if (!opts.jsonOnly) spinner.start(`Extracting page ${pageNum}/${total}: ${new URL(pageUrl).pathname}`);
174
+
175
+ await new Promise(r => setTimeout(r, 1500 + Math.random() * 1500));
176
+
177
+ try {
178
+ const pageResult = await extractBranding(pageUrl, spinner, browser, {
179
+ navigationTimeout: 90000,
180
+ darkMode: opts.darkMode,
181
+ mobile: opts.mobile,
182
+ slow: opts.slow,
183
+ });
184
+ delete pageResult._discoveredLinks;
185
+ allResults.push(pageResult);
186
+ } catch (err) {
187
+ if (!opts.jsonOnly) spinner.warn(`Skipping ${pageUrl}: ${String(err?.message || err).slice(0, 80)}`);
188
+ }
189
+ }
190
+
191
+ spinner.stop();
192
+ result = mergeResults(allResults);
193
+ }
194
+
195
+ if (!hasExplicitPaths && !opts.sitemap && !isAutoCrawl) {
180
196
  delete result._discoveredLinks;
181
197
  }
182
198
 
@@ -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 {
@@ -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}`;
@@ -157,6 +157,7 @@ function buildTypographyTokens(result) {
157
157
  fontWeight: normalizeFontWeight(style.fontWeight ?? style.weight),
158
158
  lineHeight: normalizeLineHeight(style.lineHeight),
159
159
  letterSpacing: normalizeDimension(style.letterSpacing),
160
+ fontFeature: normalizeFontFeature(style.fontFeatures ?? style.fontFeature),
160
161
  });
161
162
 
162
163
  if (!token || !token.fontFamily && !token.fontSize) continue;
@@ -408,6 +409,12 @@ function normalizeFontFamily(value) {
408
409
  return String(value).trim().replace(/\s+/g, ' ');
409
410
  }
410
411
 
412
+ function normalizeFontFeature(value) {
413
+ if (!value) return null;
414
+ const raw = String(value).trim();
415
+ return raw && raw !== 'normal' ? raw : null;
416
+ }
417
+
411
418
  function normalizeFontWeight(value) {
412
419
  if (value == null) return null;
413
420
  const parsed = parseInt(value, 10);
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dembrandt",
3
- "version": "0.12.7",
3
+ "version": "0.12.9",
4
4
  "description": "Extract design tokens and publicly visible CSS information from any website",
5
5
  "mcpName": "io.github.dembrandt/dembrandt",
6
6
  "main": "index.js",
@@ -17,10 +17,15 @@
17
17
  "scripts": {
18
18
  "start": "node index.js",
19
19
  "install-browser": "npx playwright install chromium firefox || echo 'Playwright browser installation failed. You may need to install system dependencies manually.'",
20
- "local-ui": "cd local-ui && npm start",
21
20
  "qa:baseline": "node test/qa.mjs --baseline",
22
21
  "qa:diff": "node test/qa.mjs --diff",
23
- "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"
24
29
  },
25
30
  "keywords": [
26
31
  "design-tokens",
@@ -42,11 +47,10 @@
42
47
  "license": "MIT",
43
48
  "dependencies": {
44
49
  "@modelcontextprotocol/sdk": "1.29.0",
45
- "@playwright/browser-chromium": "^1.57.0",
46
- "@playwright/browser-firefox": "^1.57.0",
47
50
  "chalk": "^5.3.0",
48
51
  "commander": "^11.1.0",
49
52
  "ora": "^7.0.1",
53
+ "playwright": "^1.57.0",
50
54
  "playwright-core": "^1.57.0",
51
55
  "zod": "4.3.6"
52
56
  },