dembrandt 0.12.3 → 0.12.6

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
@@ -45,11 +45,12 @@ Or add to your project's `.mcp.json`:
45
45
 
46
46
  ## What to expect from extraction?
47
47
 
48
- - Colors (semantic, palette, CSS variables)
48
+ - Colors (semantic, palette, CSS variables, gradients)
49
49
  - Typography (fonts, sizes, weights, sources)
50
50
  - Spacing (margin/padding scales)
51
51
  - Borders (radius, widths, styles, colors)
52
52
  - Shadows
53
+ - Motion (duration scale, easing curves, hover patterns per component type)
53
54
  - Components (buttons, badges, inputs, links)
54
55
  - Breakpoints
55
56
  - Icons & frameworks
@@ -134,13 +135,15 @@ The DTCG format is an industry-standard JSON schema that can be consumed by desi
134
135
 
135
136
  ### DESIGN.md
136
137
 
137
- Use `--design-md` to generate a [DESIGN.md](https://stitch.withgoogle.com/docs/design-md) file, a plain-text design system document readable by AI agents.
138
+ Use `--design-md` to generate a [DESIGN.md](https://stitch.withgoogle.com/docs/design-md) file, a plain-text design system document readable by AI agents. The export follows Google's DESIGN.md draft format: YAML design tokens in front matter plus ordered Markdown guidance sections.
138
139
 
139
140
  ```bash
140
141
  dembrandt example.com --design-md
141
142
  # Saves to: output/example.com/DESIGN.md
142
143
  ```
143
144
 
145
+ DESIGN.md reports only what Dembrandt observed on the source site. Exact values (colors, typography, spacing, radii, shadows) live in the YAML front matter when available, and the Markdown body adds human-readable context. Sections with no extracted evidence are omitted rather than filled with invented defaults. For example, the elevation section is dropped when the site uses no box-shadow tokens.
146
+
144
147
  ### WCAG Contrast Analysis
145
148
 
146
149
  Use `--wcag` to check accessibility contrast ratios across the page. Unlike palette-based checkers, dembrandt walks the actual DOM and finds what color is rendered on top of what background — per element.
@@ -151,6 +154,24 @@ dembrandt stripe.com --wcag
151
154
 
152
155
  Returns every text/background pair with contrast ratio and WCAG 2.1 grade (AA, AA-Large, AAA, or fail), sorted by how often each pair appears. Results are shown in terminal and included in JSON output as `wcag`.
153
156
 
157
+ Also captures **interactive state contrast**: dembrandt simulates hover, focus, and disabled states on buttons, links, and inputs and checks contrast on each state. State pairs are tagged `[hover]`, `[focus]`, or `[disabled]` in output so you can catch contrast failures that only appear on interaction.
158
+
159
+ ### Motion Tokens
160
+
161
+ Motion tokens are extracted automatically on every run — no flag needed. Dembrandt analyzes CSS transitions and animations across the page and returns a structured motion profile.
162
+
163
+ ```bash
164
+ dembrandt stripe.com
165
+ ```
166
+
167
+ Returns:
168
+ - **Duration scale**: all unique animation durations found on the page
169
+ - **Easing curves**: named easing types (ease-out, spring, custom cubic-bezier) with usage counts
170
+ - **Per-context profiles**: motion behavior by component type (button, nav, card, modal, hero)
171
+ - **Hover interaction deltas**: which properties animate on hover (transform, opacity, background, color) and the pattern (scale-up, fade-in, color-shift, slide-y)
172
+
173
+ Motion data is included in JSON output as `motion` and printed in terminal under a dedicated Motion section.
174
+
154
175
  ### Brand Guide PDF
155
176
 
156
177
  Use `--brand-guide` to generate a printable PDF summarizing the extracted design system: colors, typography, components, and logo on a single document.
@@ -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,
@@ -161,14 +161,16 @@ 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
165
 
165
166
  // Penalize images hosted on a different domain (CDN paths on same domain are fine)
166
167
  if (el.tagName === 'IMG') {
167
168
  try {
168
169
  const srcHost = new URL(el.src).hostname.replace('www.', '')
169
170
  const pageHost = new URL(baseUrl).hostname.replace('www.', '')
170
- // If neither is a subdomain/CDN of the other, it's third-party
171
- if (!srcHost.endsWith(pageHost) && !pageHost.endsWith(srcHost)) score -= 60
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
172
174
  } catch {}
173
175
  }
174
176
 
@@ -205,7 +207,23 @@ export async function extractLogo(page, url) {
205
207
  if (el.tagName === 'IMG') {
206
208
  // Handle picture element -- prefer highest-res source
207
209
  const picture = el.closest('picture');
208
- 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
+
209
227
  if (picture) {
210
228
  const sources = picture.querySelectorAll('source');
211
229
  for (const source of sources) {