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.
- 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 +53 -4
- 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
|
@@ -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
|
-
|
|
160
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|