dembrandt 0.12.2 → 0.12.5

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.
@@ -249,3 +249,184 @@ export async function extractGradients(page) {
249
249
  .slice(0, 20);
250
250
  });
251
251
  }
252
+
253
+ export async function extractMotion(page) {
254
+ // Phase 1: static pass — collect durations, easings, animations per semantic context
255
+ const staticMotion = await page.evaluate(() => {
256
+ function getContext(el) {
257
+ const tag = el.tagName.toLowerCase();
258
+ const role = el.getAttribute('role') || '';
259
+ const cls = (typeof el.className === 'string' ? el.className : '').toLowerCase();
260
+ const id = (el.id || '').toLowerCase();
261
+ const hint = cls + ' ' + id + ' ' + role;
262
+ if (tag === 'button' || role === 'button' || hint.includes('btn')) return 'button';
263
+ if (tag === 'a' || role === 'link') return 'link';
264
+ if (tag === 'nav' || role === 'navigation' || hint.includes('nav') || hint.includes('menu')) return 'nav';
265
+ if (hint.includes('modal') || hint.includes('dialog') || hint.includes('overlay') || hint.includes('drawer')) return 'modal';
266
+ if (hint.includes('card') || hint.includes('tile') || hint.includes('item')) return 'card';
267
+ if (hint.includes('hero') || hint.includes('banner') || hint.includes('header')) return 'hero';
268
+ if (hint.includes('tooltip') || hint.includes('popover') || hint.includes('dropdown')) return 'overlay';
269
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') return 'input';
270
+ if (hint.includes('image') || hint.includes('img') || hint.includes('media') || hint.includes('video')) return 'media';
271
+ return 'other';
272
+ }
273
+
274
+ function parseDurationMs(val) {
275
+ if (!val || val === '0s') return 0;
276
+ return val.endsWith('ms') ? parseFloat(val) : parseFloat(val) * 1000;
277
+ }
278
+
279
+ function classifyEasing(val) {
280
+ if (!val) return null;
281
+ if (val === 'linear') return 'linear';
282
+ if (val === 'ease') return 'ease';
283
+ if (val === 'ease-in') return 'ease-in';
284
+ if (val === 'ease-out') return 'ease-out';
285
+ if (val === 'ease-in-out') return 'ease-in-out';
286
+ // detect spring-like: high y1/y2 overshoot
287
+ const m = val.match(/cubic-bezier\(([\d.]+),\s*([\d.-]+),\s*([\d.]+),\s*([\d.-]+)\)/);
288
+ if (m) {
289
+ const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
290
+ if (y1 < 0 || y1 > 1 || y2 < 0 || y2 > 1) return 'spring';
291
+ const x1 = parseFloat(m[1]);
292
+ if (x1 < 0.2) return 'ease-out'; // fast start
293
+ if (x1 > 0.6) return 'ease-in'; // slow start
294
+ return 'custom';
295
+ }
296
+ return 'custom';
297
+ }
298
+
299
+ // per-context motion profiles
300
+ const contexts = {};
301
+ const globalDurations = new Map();
302
+ const globalEasings = new Map();
303
+ const globalAnimations = new Map();
304
+
305
+ const els = document.querySelectorAll('*');
306
+ let checked = 0;
307
+ for (const el of els) {
308
+ if (checked++ > 3000) break;
309
+ if (el.offsetWidth === 0 && el.offsetHeight === 0) continue;
310
+ const s = getComputedStyle(el);
311
+
312
+ const rawDurations = (s.transitionDuration || '').split(',').map(v => v.trim()).filter(v => v && v !== '0s');
313
+ const rawEasings = (s.transitionTimingFunction || '').split(/,(?![^(]*\))/).map(v => v.trim()).filter(Boolean);
314
+ const rawProps = (s.transitionProperty || '').split(',').map(v => v.trim());
315
+ const animName = s.animationName;
316
+
317
+ if (rawDurations.length === 0 && (!animName || animName === 'none')) continue;
318
+
319
+ const ctx = getContext(el);
320
+
321
+ // global tallies
322
+ rawDurations.forEach(d => {
323
+ const ms = parseDurationMs(d);
324
+ if (ms <= 0) return;
325
+ const e = globalDurations.get(d) || { value: d, ms, count: 0 };
326
+ e.count++; globalDurations.set(d, e);
327
+ });
328
+ rawEasings.forEach(e => {
329
+ const entry = globalEasings.get(e) || { value: e, type: classifyEasing(e), count: 0 };
330
+ entry.count++; globalEasings.set(e, entry);
331
+ });
332
+
333
+ // per-context profile
334
+ if (ctx !== 'other' && rawDurations.length > 0) {
335
+ if (!contexts[ctx]) contexts[ctx] = { durations: new Map(), easings: new Map(), props: new Map(), count: 0 };
336
+ const cx = contexts[ctx];
337
+ cx.count++;
338
+ rawDurations.forEach(d => { const e = cx.durations.get(d) || { value: d, ms: parseDurationMs(d), count: 0 }; e.count++; cx.durations.set(d, e); });
339
+ rawEasings.forEach(e => { const entry = cx.easings.get(e) || { value: e, type: classifyEasing(e), count: 0 }; entry.count++; cx.easings.set(e, entry); });
340
+ rawProps.forEach(p => { if (p && p !== 'all' && p !== 'none') { const e = cx.props.get(p) || { value: p, count: 0 }; e.count++; cx.props.set(p, e); } });
341
+ }
342
+
343
+ // animations
344
+ if (animName && animName !== 'none') {
345
+ for (const name of animName.split(',').map(v => v.trim())) {
346
+ if (name === 'none') continue;
347
+ const e = globalAnimations.get(name) || { name, duration: s.animationDuration?.split(',')[0]?.trim(), easing: s.animationTimingFunction?.split(',')[0]?.trim(), count: 0, contexts: new Set() };
348
+ e.count++; e.contexts.add(ctx);
349
+ globalAnimations.set(name, e);
350
+ }
351
+ }
352
+ }
353
+
354
+ // serialize
355
+ const serializeCtx = (cx) => ({
356
+ count: cx.count,
357
+ durations: Array.from(cx.durations.values()).sort((a, b) => b.count - a.count).slice(0, 3).map(d => d.value),
358
+ easing: Array.from(cx.easings.values()).sort((a, b) => b.count - a.count)[0]?.value || null,
359
+ easingType: Array.from(cx.easings.values()).sort((a, b) => b.count - a.count)[0]?.type || null,
360
+ props: Array.from(cx.props.values()).sort((a, b) => b.count - a.count).slice(0, 4).map(p => p.value),
361
+ });
362
+
363
+ const ctxOut = {};
364
+ for (const [k, v] of Object.entries(contexts)) ctxOut[k] = serializeCtx(v);
365
+
366
+ return {
367
+ durations: Array.from(globalDurations.values()).sort((a, b) => a.ms - b.ms),
368
+ easings: Array.from(globalEasings.values()).sort((a, b) => b.count - a.count).slice(0, 8),
369
+ animations: Array.from(globalAnimations.values()).sort((a, b) => b.count - a.count).slice(0, 8).map(a => ({ ...a, contexts: Array.from(a.contexts) })),
370
+ contexts: ctxOut,
371
+ };
372
+ });
373
+
374
+ // Phase 2: hover interaction deltas on a sample of interactive elements
375
+ const interactiveDeltas = [];
376
+ try {
377
+ const els = await page.$$('button, a, [role="button"]');
378
+ const sampled = els.slice(0, 12);
379
+ for (const el of sampled) {
380
+ try {
381
+ const visible = await el.evaluate(e => {
382
+ const r = e.getBoundingClientRect();
383
+ const s = getComputedStyle(e);
384
+ return r.width > 0 && r.height > 0 && s.display !== 'none' && s.visibility !== 'hidden';
385
+ });
386
+ if (!visible) continue;
387
+
388
+ const before = await el.evaluate(e => {
389
+ const s = getComputedStyle(e);
390
+ return { transform: s.transform, opacity: s.opacity, bg: s.backgroundColor, color: s.color, tag: e.tagName.toLowerCase(), text: (e.textContent || '').trim().slice(0, 30) };
391
+ });
392
+
393
+ await el.hover({ timeout: 800 }).catch(() => {});
394
+ await page.waitForTimeout(120);
395
+
396
+ const after = await el.evaluate(e => {
397
+ const s = getComputedStyle(e);
398
+ return { transform: s.transform, opacity: s.opacity, bg: s.backgroundColor, color: s.color };
399
+ }).catch(() => null);
400
+
401
+ if (!after) continue;
402
+
403
+ const delta = {};
404
+ if (after.transform !== before.transform && after.transform !== 'none') delta.transform = after.transform;
405
+ if (after.opacity !== before.opacity) delta.opacity = { from: before.opacity, to: after.opacity };
406
+ if (after.bg !== before.bg) delta.background = { from: before.bg, to: after.bg };
407
+ if (after.color !== before.color) delta.color = { from: before.color, to: after.color };
408
+
409
+ if (Object.keys(delta).length > 0) {
410
+ // classify pattern
411
+ let pattern = 'color-shift';
412
+ if (delta.transform) {
413
+ const t = delta.transform;
414
+ if (/scale\(([\d.]+)/.test(t)) {
415
+ const s = parseFloat(t.match(/scale\(([\d.]+)/)[1]);
416
+ pattern = s > 1 ? 'scale-up' : 'scale-down';
417
+ } else if (/translateY/.test(t)) pattern = 'slide-y';
418
+ else if (/translateX/.test(t)) pattern = 'slide-x';
419
+ else pattern = 'transform';
420
+ } else if (delta.opacity) {
421
+ pattern = parseFloat(delta.opacity.to) > parseFloat(delta.opacity.from) ? 'fade-in' : 'fade-out';
422
+ }
423
+
424
+ interactiveDeltas.push({ tag: before.tag, text: before.text, pattern, delta });
425
+ }
426
+ } catch { /* stale element */ }
427
+ }
428
+ await page.mouse.move(0, 0).catch(() => {});
429
+ } catch { /* skip */ }
430
+
431
+ return { ...staticMotion, interactiveDeltas };
432
+ }
@@ -9,8 +9,10 @@ export async function extractColors(page) {
9
9
  function normalizeColor(color) {
10
10
  if (_colorMemo.has(color)) return _colorMemo.get(color);
11
11
  let result;
12
- const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
12
+ const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
13
13
  if (rgbaMatch) {
14
+ const alpha = rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1;
15
+ if (alpha < 0.05) { result = color.toLowerCase(); _colorMemo.set(color, result); return result; }
14
16
  const r = parseInt(rgbaMatch[1]).toString(16).padStart(2, "0");
15
17
  const g = parseInt(rgbaMatch[2]).toString(16).padStart(2, "0");
16
18
  const b = parseInt(rgbaMatch[3]).toString(16).padStart(2, "0");
@@ -5,7 +5,7 @@ import { extractColors } from './colors.js';
5
5
  import { extractTypography } from './typography.js';
6
6
  import { extractSpacing, extractBorderRadius, extractBorders, extractShadows } from './spacing.js';
7
7
  import { extractButtonStyles, extractInputStyles, extractLinkStyles, extractBadgeStyles } from './components.js';
8
- import { extractBreakpoints, detectIconSystem, detectFrameworks, extractGradients } from './breakpoints.js';
8
+ import { extractBreakpoints, detectIconSystem, detectFrameworks, extractGradients, extractMotion } from './breakpoints.js';
9
9
  import { extractWcagPairs } from './colors.js';
10
10
 
11
11
  /** @typedef {import('../types.js').BrandingResult} BrandingResult */
@@ -185,6 +185,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
185
185
  frameworks,
186
186
  siteName,
187
187
  gradients,
188
+ motion,
188
189
  ] = await Promise.all([
189
190
  extractLogo(page, url),
190
191
  extractColors(page),
@@ -202,6 +203,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
202
203
  detectFrameworks(page),
203
204
  extractSiteName(page),
204
205
  extractGradients(page),
206
+ extractMotion(page),
205
207
  ]);
206
208
 
207
209
  spinner.stop();
@@ -220,6 +222,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
220
222
  console.log(iconSystem.length > 0 ? chalk.hex('#50FA7B')(` ✓ Icon systems: ${iconSystem.length} detected`) : chalk.hex('#FFB86C')(` ⚠ Icon systems: 0 detected`));
221
223
  console.log(frameworks.length > 0 ? chalk.hex('#50FA7B')(` ✓ Frameworks: ${frameworks.length} detected`) : chalk.hex('#FFB86C')(` ⚠ Frameworks: 0 detected`));
222
224
  console.log(gradients.length > 0 ? chalk.hex('#50FA7B')(` ✓ Gradients: ${gradients.length} found`) : chalk.hex('#8BE9FD')(` · Gradients: 0 found`));
225
+ console.log(motion.durations.length > 0 ? chalk.hex('#50FA7B')(` ✓ Motion: ${motion.durations.length} durations, ${motion.easings.length} easings`) : chalk.hex('#8BE9FD')(` · Motion: none detected`));
223
226
  console.log();
224
227
 
225
228
  // Hover/focus state extraction
@@ -244,6 +247,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
244
247
  `);
245
248
 
246
249
  const sampled = interactiveElements.slice(0, 20);
250
+ const interactiveStatePairs = []; // { fg, bg, state, tag } — raw rgb strings, normalized later
247
251
 
248
252
  for (const element of sampled) {
249
253
  try {
@@ -256,17 +260,35 @@ export async function extractBranding(url, spinner, browser, options = {}) {
256
260
  if (!isVisible) continue;
257
261
 
258
262
  const beforeState = await element.evaluate(el => {
263
+ function findBg(node) {
264
+ while (node && node.tagName !== 'HTML') {
265
+ const bg = getComputedStyle(node).backgroundColor;
266
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg;
267
+ node = node.parentElement;
268
+ }
269
+ return null;
270
+ }
259
271
  const computed = getComputedStyle(el);
260
- return { color: computed.color, backgroundColor: computed.backgroundColor, borderColor: computed.borderColor, tag: el.tagName.toLowerCase() };
272
+ return { color: computed.color, backgroundColor: computed.backgroundColor, resolvedBg: findBg(el), borderColor: computed.borderColor, tag: el.tagName.toLowerCase() };
261
273
  });
262
274
 
263
- await element.hover({ timeout: 1000 * timeoutMultiplier }).catch(() => {});
275
+ const hovered = await element.hover({ timeout: 1000 * timeoutMultiplier }).then(() => true).catch(() => false);
264
276
  await page.waitForTimeout(100 * timeoutMultiplier);
265
277
 
266
278
  const afterHover = await element.evaluate(el => {
279
+ function findBg(node) {
280
+ while (node && node.tagName !== 'HTML') {
281
+ const bg = getComputedStyle(node).backgroundColor;
282
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg;
283
+ node = node.parentElement;
284
+ }
285
+ return null;
286
+ }
267
287
  const computed = getComputedStyle(el);
268
- return { color: computed.color, backgroundColor: computed.backgroundColor, borderColor: computed.borderColor };
269
- });
288
+ return { color: computed.color, backgroundColor: computed.backgroundColor, resolvedBg: findBg(el), borderColor: computed.borderColor };
289
+ }).catch(() => null);
290
+
291
+ if (!afterHover) continue;
270
292
 
271
293
  if (afterHover.color !== beforeState.color && afterHover.color !== 'rgba(0, 0, 0, 0)' && afterHover.color !== 'transparent') {
272
294
  hoverFocusColors.push({ color: afterHover.color, property: 'color', state: 'hover', element: beforeState.tag });
@@ -284,13 +306,28 @@ export async function extractBranding(url, spinner, browser, options = {}) {
284
306
  });
285
307
  }
286
308
 
287
- if (['input', 'textarea', 'select', 'button'].includes(beforeState.tag)) {
309
+ // Collect hover contrast pair only if hover actually changed styles
310
+ const hoverFg = afterHover.color;
311
+ const hoverBg = afterHover.resolvedBg;
312
+ if (hovered && hoverFg && hoverBg && (hoverFg !== beforeState.color || hoverBg !== beforeState.resolvedBg)) {
313
+ interactiveStatePairs.push({ fg: hoverFg, bg: hoverBg, state: 'hover', tag: beforeState.tag });
314
+ }
315
+
316
+ if (['input', 'textarea', 'select', 'button', 'a'].includes(beforeState.tag)) {
288
317
  try {
289
318
  await element.focus({ timeout: 500 * timeoutMultiplier });
290
319
  await page.waitForTimeout(100 * timeoutMultiplier);
291
320
  const afterFocus = await element.evaluate(el => {
321
+ function findBg(node) {
322
+ while (node && node.tagName !== 'HTML') {
323
+ const bg = getComputedStyle(node).backgroundColor;
324
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg;
325
+ node = node.parentElement;
326
+ }
327
+ return null;
328
+ }
292
329
  const computed = getComputedStyle(el);
293
- return { color: computed.color, backgroundColor: computed.backgroundColor, borderColor: computed.borderColor, outlineColor: computed.outlineColor };
330
+ return { color: computed.color, backgroundColor: computed.backgroundColor, resolvedBg: findBg(el), borderColor: computed.borderColor, outlineColor: computed.outlineColor };
294
331
  });
295
332
  if (afterFocus.outlineColor && afterFocus.outlineColor !== 'rgba(0, 0, 0, 0)' && afterFocus.outlineColor !== 'transparent' && afterFocus.outlineColor !== beforeState.color) {
296
333
  hoverFocusColors.push({ color: afterFocus.outlineColor, property: 'outline-color', state: 'focus', element: beforeState.tag });
@@ -304,11 +341,44 @@ export async function extractBranding(url, spinner, browser, options = {}) {
304
341
  }
305
342
  });
306
343
  }
344
+
345
+ // Collect focus contrast pair
346
+ const focusFg = afterFocus.color;
347
+ const focusBg = afterFocus.resolvedBg;
348
+ if (focusFg && focusBg && (focusFg !== beforeState.color || focusBg !== beforeState.resolvedBg)) {
349
+ interactiveStatePairs.push({ fg: focusFg, bg: focusBg, state: 'focus', tag: beforeState.tag });
350
+ }
307
351
  } catch (e) {}
308
352
  }
309
353
  } catch (e) {}
310
354
  }
311
355
 
356
+ // Collect disabled element pairs
357
+ try {
358
+ const disabledPairs = await page.evaluate(() => {
359
+ function findBg(node) {
360
+ while (node && node.tagName !== 'HTML') {
361
+ const bg = getComputedStyle(node).backgroundColor;
362
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return bg;
363
+ node = node.parentElement;
364
+ }
365
+ return null;
366
+ }
367
+ const els = document.querySelectorAll('button[disabled], input[disabled], [aria-disabled="true"], [disabled]');
368
+ const pairs = [];
369
+ for (const el of Array.from(els).slice(0, 10)) {
370
+ const rect = el.getBoundingClientRect();
371
+ if (rect.width === 0 || rect.height === 0) continue;
372
+ const s = getComputedStyle(el);
373
+ const fg = s.color;
374
+ const bg = findBg(el);
375
+ if (fg && bg) pairs.push({ fg, bg, state: 'disabled', tag: el.tagName.toLowerCase() });
376
+ }
377
+ return pairs;
378
+ });
379
+ interactiveStatePairs.push(...disabledPairs);
380
+ } catch (e) {}
381
+
312
382
  await page.mouse.move(0, 0).catch(() => {});
313
383
 
314
384
  // Batch-normalize hover/focus colors via browser canvas to handle oklab/oklch/lab
@@ -425,9 +495,44 @@ export async function extractBranding(url, spinner, browser, options = {}) {
425
495
  if (options.wcag) {
426
496
  spinner.start("Analyzing WCAG contrast pairs...");
427
497
  try {
498
+ const { relativeLuminance } = await import('../colors.js');
499
+
500
+ function calcPair(fgRaw, bgRaw, extra = {}) {
501
+ const toHex = (c) => {
502
+ const m = c && c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
503
+ if (!m) return null;
504
+ return `#${parseInt(m[1]).toString(16).padStart(2,'0')}${parseInt(m[2]).toString(16).padStart(2,'0')}${parseInt(m[3]).toString(16).padStart(2,'0')}`;
505
+ };
506
+ const fg = toHex(fgRaw) || fgRaw;
507
+ const bg = toHex(bgRaw) || bgRaw;
508
+ if (!fg || !bg || fg === bg) return null;
509
+ const l1 = relativeLuminance(fg);
510
+ const l2 = relativeLuminance(bg);
511
+ if (l1 === null || l2 === null) return null;
512
+ const lighter = Math.max(l1, l2);
513
+ const darker = Math.min(l1, l2);
514
+ const ratio = Math.round((lighter + 0.05) / (darker + 0.05) * 100) / 100;
515
+ return { fg, bg, ratio, aa: ratio >= 4.5, aaLarge: ratio >= 3, aaa: ratio >= 7, ...extra };
516
+ }
517
+
428
518
  wcag = await extractWcagPairs(page);
519
+
520
+ // Deduplicate and score interactive state pairs
521
+ const seenState = new Set();
522
+ for (const { fg, bg, state, tag } of interactiveStatePairs) {
523
+ const key = `${state}/${fg}/${bg}`;
524
+ if (seenState.has(key)) continue;
525
+ seenState.add(key);
526
+ const pair = calcPair(fg, bg, { state, tag, source: 'state' });
527
+ if (pair) wcag.push(pair);
528
+ }
529
+
429
530
  spinner.stop();
430
- console.log(chalk.hex('#50FA7B')(` ✓ WCAG: ${wcag.filter(p => p.aa).length}/${wcag.length} pairs pass AA`));
531
+ const staticPassing = wcag.filter(p => !p.source && p.aa).length;
532
+ const staticTotal = wcag.filter(p => !p.source).length;
533
+ const statesFailing = wcag.filter(p => p.source === 'state' && !p.aa).length;
534
+ console.log(chalk.hex('#50FA7B')(` ✓ WCAG: ${staticPassing}/${staticTotal} pairs pass AA`) +
535
+ (statesFailing ? chalk.hex('#FFB86C')(` · ${statesFailing} state pair(s) fail`) : ''));
431
536
  } catch {
432
537
  spinner.stop();
433
538
  }
@@ -447,6 +552,7 @@ export async function extractBranding(url, spinner, browser, options = {}) {
447
552
  borders,
448
553
  shadows,
449
554
  gradients,
555
+ motion,
450
556
  components: { buttons, inputs, links, badges },
451
557
  breakpoints,
452
558
  iconSystem,
@@ -146,6 +146,12 @@ export async function extractLogo(page, url) {
146
146
  if (imgSrc.toLowerCase().includes(siteDomain) || altText.includes(siteDomain) || className.includes(siteDomain)) score += 40;
147
147
  if (className.includes('logo') || el.id?.toLowerCase().includes('logo')) score += 30;
148
148
 
149
+ // SVG with aria-label="Homepage" directly in a home anchor — strong signal
150
+ if (el.tagName === 'svg' || el.tagName === 'SVG') {
151
+ const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase();
152
+ if (ariaLabel.includes('home') || ariaLabel.includes('logo')) score += 40;
153
+ }
154
+
149
155
  if (parentLink) {
150
156
  const href = linkHref.toLowerCase();
151
157
  if (href === '/' || href === baseUrl || href.endsWith('://' + new URL(baseUrl).hostname + '/') || href.endsWith('://' + new URL(baseUrl).hostname)) {
@@ -155,9 +161,22 @@ export async function extractLogo(page, url) {
155
161
 
156
162
  if (rect.top < 200) score += 10;
157
163
  if (rect.left < 400) score += 10;
164
+ if (rect.top > 800) score -= 30; // deeply buried elements are unlikely primary logos
165
+
166
+ // Penalize images hosted on a different domain (CDN paths on same domain are fine)
167
+ if (el.tagName === 'IMG') {
168
+ try {
169
+ const srcHost = new URL(el.src).hostname.replace('www.', '')
170
+ const pageHost = new URL(baseUrl).hostname.replace('www.', '')
171
+ const apexOf = h => h.split('.').slice(-2).join('.')
172
+ // Allow same apex domain (e.g. digitalhub.fifa.com and fifa.com share apex "fifa.com")
173
+ if (!srcHost.endsWith(pageHost) && !pageHost.endsWith(srcHost) && apexOf(srcHost) !== apexOf(pageHost)) score -= 60
174
+ } catch {}
175
+ }
158
176
 
159
- const width = el.naturalWidth || el.width?.baseVal?.value || rect.width;
160
- const height = el.naturalHeight || el.height?.baseVal?.value || rect.height;
177
+ // For SVGs use rendered rect — baseVal reflects viewBox coordinates, not display size
178
+ const width = el.tagName === 'IMG' ? (el.naturalWidth || rect.width) : rect.width;
179
+ const height = el.tagName === 'IMG' ? (el.naturalHeight || rect.height) : rect.height;
161
180
  if (width < 20 || height < 20) score -= 30;
162
181
  if (width > 600 || height > 400) score -= 40;
163
182
  if (altText.length > 50) score -= 30;
@@ -188,7 +207,23 @@ export async function extractLogo(page, url) {
188
207
  if (el.tagName === 'IMG') {
189
208
  // Handle picture element -- prefer highest-res source
190
209
  const picture = el.closest('picture');
191
- let src = el.src;
210
+ // Prefer currentSrc (browser-resolved after srcset/lazy), fallback to src attr, then parse srcset manually
211
+ let src = el.currentSrc || el.src;
212
+ const srcAttr = el.getAttribute('src') || '';
213
+ const srcsetAttr = el.getAttribute('srcset') || '';
214
+
215
+ // If src looks broken (just params, or resolves to baseUrl), parse srcset manually
216
+ if (!src || src === baseUrl || src === window.location.href || srcAttr.startsWith('width:') || srcAttr.startsWith('height:')) {
217
+ if (srcsetAttr) {
218
+ const entries = srcsetAttr.split(',').map(s => {
219
+ const parts = s.trim().split(/\s+/);
220
+ return { url: parts[0], w: parseFloat(parts[1]) || 0 };
221
+ }).filter(e => e.url && !e.url.startsWith('width:'));
222
+ entries.sort((a, b) => b.w - a.w);
223
+ if (entries[0]) src = new URL(entries[0].url, baseUrl).href;
224
+ }
225
+ }
226
+
192
227
  if (picture) {
193
228
  const sources = picture.querySelectorAll('source');
194
229
  for (const source of sources) {
@@ -247,7 +282,18 @@ export async function extractLogo(page, url) {
247
282
  if (rect.width === 0 || rect.height === 0) return;
248
283
 
249
284
  const className = (typeof el.className === 'string' ? el.className : el.className.baseVal || '').toLowerCase();
250
- const attrs = (className + ' ' + (el.id || '') + ' ' + (el.getAttribute('alt') || '')).toLowerCase();
285
+ const altText = (el.getAttribute('alt') || '').toLowerCase();
286
+ const attrs = (className + ' ' + (el.id || '') + ' ' + altText).toLowerCase();
287
+
288
+ // Disqualify third-party brand logos: alt like "Notion logo" / "Perplexity logo" / "Figma" where the brand isn't our site
289
+ // These appear in customer/integration/testimonial sections on marketing pages.
290
+ const altBrandMatch = altText.match(/^([a-z][a-z0-9\-\.]{1,30}?)(?:\s+(?:logo|icon|brand|wordmark))?$/i);
291
+ if (altBrandMatch) {
292
+ const altBrand = altBrandMatch[1].replace(/[\s\-\.]/g, '').toLowerCase();
293
+ if (altBrand && altBrand !== 'logo' && altBrand !== 'brand' && altBrand !== 'icon' && altBrand !== siteDomain && !altBrand.includes(siteDomain) && !siteDomain.includes(altBrand)) {
294
+ return; // third-party brand, skip entirely
295
+ }
296
+ }
251
297
 
252
298
  let qualifies = attrs.includes('logo') || attrs.includes('brand');
253
299
 
@@ -257,6 +303,9 @@ export async function extractLogo(page, url) {
257
303
  const href = use.getAttribute('href') || use.getAttribute('xlink:href') || '';
258
304
  if (href.toLowerCase().includes('logo') || href.toLowerCase().includes('brand')) { qualifies = true; break; }
259
305
  }
306
+ // aria-label="Homepage" or similar on the SVG itself
307
+ const ariaLabel = (el.getAttribute('aria-label') || '').toLowerCase();
308
+ if (ariaLabel.includes('home') || ariaLabel.includes('logo')) qualifies = true;
260
309
  }
261
310
 
262
311
  if (!qualifies) {
@@ -47,6 +47,7 @@ export function displayResults(data) {
47
47
  displayBorderRadius(data.borderRadius);
48
48
  displayBorders(data.borders);
49
49
  displayShadows(data.shadows);
50
+ displayGradients(data.gradients);
50
51
  displayButtons(data.components?.buttons);
51
52
  displayBadges(data.components?.badges);
52
53
  displayInputs(data.components?.inputs);
@@ -54,6 +55,7 @@ export function displayResults(data) {
54
55
  displayBreakpoints(data.breakpoints);
55
56
  displayIconSystem(data.iconSystem);
56
57
  displayFrameworks(data.frameworks);
58
+ displayMotion(data.motion);
57
59
  displayWcag(data.wcag);
58
60
 
59
61
  console.log(chalk.dim('│'));
@@ -125,7 +127,7 @@ function displayColors(colors) {
125
127
  // Add semantic colors
126
128
  if (colors.semantic) {
127
129
  Object.entries(colors.semantic)
128
- .filter(([_, color]) => color)
130
+ .filter(([_, color]) => color && color !== 'rgba(0, 0, 0, 0)' && color !== 'transparent')
129
131
  .forEach(([role, color]) => {
130
132
  const formats = normalizeColorFormat(color);
131
133
  allColors.push({
@@ -477,6 +479,25 @@ function displayShadows(shadows) {
477
479
  console.log(chalk.dim('│'));
478
480
  }
479
481
 
482
+ function displayGradients(gradients) {
483
+ if (!gradients || gradients.length === 0) return;
484
+
485
+ console.log(chalk.dim('├─') + ' ' + chalk.bold('Gradients'));
486
+ gradients.slice(0, 5).forEach((g, i) => {
487
+ const isLast = i === Math.min(gradients.length, 5) - 1;
488
+ const branch = isLast ? '└─' : '├─';
489
+ const typeLabel = g.type ? chalk.dim(`${g.type} · `) : '';
490
+ const uniqueStops = [...new Set((g.stopColors || []).map(c => {
491
+ const m = c && c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
492
+ return m ? `#${parseInt(m[1]).toString(16).padStart(2,'0')}${parseInt(m[2]).toString(16).padStart(2,'0')}${parseInt(m[3]).toString(16).padStart(2,'0')}` : null;
493
+ }).filter(Boolean))];
494
+ const stops = uniqueStops.slice(0, 5).map(hex => chalk.bgHex(hex)(' ')).join(' ');
495
+ const countLabel = g.count > 1 ? chalk.dim(` ×${g.count}`) : '';
496
+ console.log(chalk.dim(`│ ${branch}`) + ' ' + typeLabel + (stops || chalk.dim(g.gradient.slice(0, 50) + '…')) + countLabel);
497
+ });
498
+ console.log(chalk.dim('│'));
499
+ }
500
+
480
501
  function displayButtons(buttons) {
481
502
  if (!buttons || buttons.length === 0) return;
482
503
 
@@ -983,18 +1004,84 @@ function displayFrameworks(frameworks) {
983
1004
  console.log(chalk.dim('│'));
984
1005
  }
985
1006
 
1007
+ function displayMotion(motion) {
1008
+ if (!motion || (motion.durations.length === 0 && motion.animations.length === 0)) return;
1009
+
1010
+ console.log(chalk.dim('├─') + ' ' + chalk.bold('Motion'));
1011
+
1012
+ // Duration scale
1013
+ if (motion.durations.length > 0) {
1014
+ const vals = motion.durations.map(d => chalk.bold(d.value)).join(' ');
1015
+ console.log(chalk.dim('│ ├─') + ' ' + chalk.dim('Scale ') + vals);
1016
+ }
1017
+
1018
+ // Dominant easing
1019
+ if (motion.easings.length > 0) {
1020
+ const top = motion.easings[0];
1021
+ const typeLabel = top.type && top.type !== 'custom' ? chalk.dim(` (${top.type})`) : '';
1022
+ console.log(chalk.dim('│ ├─') + ' ' + chalk.dim('Easing ') + top.value + typeLabel);
1023
+ }
1024
+
1025
+ // Per-context profiles
1026
+ const ctxEntries = Object.entries(motion.contexts || {}).filter(([, v]) => v.count > 0);
1027
+ if (ctxEntries.length > 0) {
1028
+ console.log(chalk.dim('│ ├─') + ' ' + chalk.dim('By context'));
1029
+ ctxEntries.forEach(([ctx, v], i) => {
1030
+ const isLast = i === ctxEntries.length - 1 && (motion.interactiveDeltas || []).length === 0 && motion.animations.length === 0;
1031
+ const branch = isLast ? '└─' : '├─';
1032
+ const dur = v.durations.join(' / ');
1033
+ const easingLabel = v.easingType && v.easingType !== 'custom' ? ` · ${v.easingType}` : '';
1034
+ const props = v.props.length > 0 ? chalk.dim(` [${v.props.slice(0, 3).join(', ')}]`) : '';
1035
+ console.log(chalk.dim(`│ │ ${branch}`) + ' ' + chalk.bold(ctx) + chalk.dim(` ${dur}${easingLabel}`) + props);
1036
+ });
1037
+ }
1038
+
1039
+ // Interaction deltas (hover patterns)
1040
+ const deltas = (motion.interactiveDeltas || []);
1041
+ if (deltas.length > 0) {
1042
+ const seen = new Map();
1043
+ deltas.forEach(d => {
1044
+ const key = `${d.tag}:${d.pattern}`;
1045
+ if (!seen.has(key)) seen.set(key, d);
1046
+ });
1047
+ const unique = Array.from(seen.values());
1048
+ console.log(chalk.dim('│ ├─') + ' ' + chalk.dim('Hover patterns'));
1049
+ unique.slice(0, 6).forEach((d, i) => {
1050
+ const isLast = i === Math.min(unique.length, 6) - 1 && motion.animations.length === 0;
1051
+ const branch = isLast ? '└─' : '├─';
1052
+ const label = d.text ? chalk.dim(` "${d.text}"`) : '';
1053
+ console.log(chalk.dim(`│ │ ${branch}`) + ' ' + chalk.bold(d.pattern) + chalk.dim(` ${d.tag}`) + label);
1054
+ });
1055
+ }
1056
+
1057
+ // Keyframe animations
1058
+ if (motion.animations.length > 0) {
1059
+ console.log(chalk.dim('│ └─') + ' ' + chalk.dim('Keyframes'));
1060
+ motion.animations.slice(0, 6).forEach((a, i) => {
1061
+ const isLast = i === Math.min(motion.animations.length, 6) - 1;
1062
+ const branch = isLast ? '└─' : '├─';
1063
+ const dur = a.duration ? chalk.dim(` ${a.duration}`) : '';
1064
+ const ctx = a.contexts?.length ? chalk.dim(` [${a.contexts.join(', ')}]`) : '';
1065
+ console.log(chalk.dim(`│ ${branch}`) + ' ' + a.name + dur + ctx);
1066
+ });
1067
+ }
1068
+
1069
+ console.log(chalk.dim('│'));
1070
+ }
1071
+
986
1072
  function displayWcag(wcag) {
987
1073
  if (!wcag || wcag.length === 0) return;
988
1074
 
989
1075
  console.log(chalk.dim('├─') + ' ' + chalk.bold('WCAG Contrast'));
990
1076
 
991
- const passing = wcag.filter(p => p.aa);
992
- const failing = wcag.filter(p => !p.aa);
1077
+ const staticPairs = wcag.filter(p => !p.source);
1078
+ const statePairs = wcag.filter(p => p.source === 'state');
1079
+
1080
+ const passing = staticPairs.filter(p => p.aa);
1081
+ const failing = staticPairs.filter(p => !p.aa);
993
1082
  const all = [...passing.slice(0, 5), ...failing.slice(0, 3)];
994
1083
 
995
- all.forEach((pair, i) => {
996
- const isLast = i === all.length - 1;
997
- const branch = isLast ? '└─' : '├─';
1084
+ function renderPair(pair, branch) {
998
1085
  const fgSwatch = chalk.bgHex(pair.fg)(' ');
999
1086
  const bgSwatch = chalk.bgHex(pair.bg)(' ');
1000
1087
  const grade = pair.aaa
@@ -1005,7 +1092,23 @@ function displayWcag(wcag) {
1005
1092
  ? chalk.hex('#FFB86C')('AA-Large')
1006
1093
  : chalk.hex('#FF5555')('fail');
1007
1094
  const ratio = chalk.bold(`${pair.ratio}:1`);
1008
- console.log(chalk.dim(`│ ${branch}`) + ' ' + `${fgSwatch} ${bgSwatch} ${ratio} ${grade} ${chalk.dim(pair.fg + ' / ' + pair.bg)}`);
1095
+ const stateTag = pair.state ? chalk.dim(` [${pair.state}]`) : '';
1096
+ console.log(chalk.dim(`│ ${branch}`) + ' ' + `${fgSwatch} ${bgSwatch} ${ratio} ${grade}${stateTag} ${chalk.dim(pair.fg + ' / ' + pair.bg)}`);
1097
+ }
1098
+
1099
+ all.forEach((pair, i) => {
1100
+ const isLast = i === all.length - 1 && statePairs.length === 0;
1101
+ renderPair(pair, isLast ? '└─' : '├─');
1009
1102
  });
1103
+
1104
+ if (statePairs.length > 0) {
1105
+ console.log(chalk.dim('│ ├─') + ' ' + chalk.bold('Interactive states'));
1106
+ const stateFailingFirst = [...statePairs.filter(p => !p.aa), ...statePairs.filter(p => p.aa)].slice(0, 8);
1107
+ stateFailingFirst.forEach((pair, i) => {
1108
+ const isLast = i === stateFailingFirst.length - 1;
1109
+ renderPair(pair, isLast ? '└─' : '├─');
1110
+ });
1111
+ }
1112
+
1010
1113
  console.log(chalk.dim('│'));
1011
1114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dembrandt",
3
- "version": "0.12.2",
3
+ "version": "0.12.5",
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",