dembrandt 0.12.3 → 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.
- 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/terminal.js +110 -7
- package/package.json +1 -1
|
@@ -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) {
|
|
@@ -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
|
|
992
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|