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 +23 -2
- package/lib/extractors/breakpoints.js +181 -0
- package/lib/extractors/colors.js +3 -1
- package/lib/extractors/index.js +114 -8
- package/lib/extractors/logo.js +21 -3
- package/lib/formatters/markdown.js +527 -202
- package/lib/formatters/terminal.js +110 -7
- package/package.json +1 -1
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
|
+
}
|
package/lib/extractors/colors.js
CHANGED
|
@@ -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");
|
package/lib/extractors/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
package/lib/extractors/logo.js
CHANGED
|
@@ -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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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) {
|