designlang 7.1.0 → 8.0.0
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/CHANGELOG.md +49 -0
- package/README.md +46 -4
- package/bin/design-extract.js +28 -2
- package/package.json +1 -1
- package/src/config.js +4 -1
- package/src/crawler.js +376 -6
- package/src/extractors/accessibility.js +44 -1
- package/src/extractors/colors.js +50 -12
- package/src/extractors/interaction-states.js +57 -0
- package/src/extractors/modern-css.js +100 -0
- package/src/extractors/scoring.js +49 -30
- package/src/extractors/token-sources.js +65 -0
- package/src/extractors/wide-gamut.js +47 -0
- package/src/formatters/routes-reconciliation.js +160 -0
- package/src/index.js +29 -0
- package/src/utils/color-gamut.js +82 -0
- package/.github/FUNDING.yml +0 -1
- package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -62
- package/.github/ISSUE_TEMPLATE/config.yml +0 -8
- package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -28
- package/chrome-extension/README.md +0 -41
- package/chrome-extension/icons/favicon.svg +0 -7
- package/chrome-extension/icons/icon-128.png +0 -0
- package/chrome-extension/icons/icon-16.png +0 -0
- package/chrome-extension/icons/icon-32.png +0 -0
- package/chrome-extension/icons/icon-48.png +0 -0
- package/chrome-extension/manifest.json +0 -26
- package/chrome-extension/popup.html +0 -167
- package/chrome-extension/popup.js +0 -59
- package/docs/superpowers/plans/2026-04-18-designlang-v7.md +0 -1121
- package/docs/superpowers/specs/2026-04-18-designlang-v7-design.md +0 -150
- package/docs/superpowers/specs/2026-04-18-website-redesign-design.md +0 -120
- package/docs/superpowers/specs/2026-04-19-designlang-v7-1-design.md +0 -111
- package/tests/cli.test.js +0 -84
- package/tests/cookies.test.js +0 -98
- package/tests/extractors.test.js +0 -792
- package/tests/formatters.test.js +0 -709
- package/tests/mcp.test.js +0 -68
- package/tests/utils.test.js +0 -413
- package/website/.claude/launch.json +0 -11
- package/website/AGENTS.md +0 -5
- package/website/CLAUDE.md +0 -1
- package/website/README.md +0 -36
- package/website/app/api/extract/route.js +0 -245
- package/website/app/components/A11ySlider.js +0 -369
- package/website/app/components/Comparison.js +0 -286
- package/website/app/components/CssHealth.js +0 -243
- package/website/app/components/Extractor.js +0 -184
- package/website/app/components/HeroExtractor.js +0 -455
- package/website/app/components/Marginalia.js +0 -3
- package/website/app/components/McpSection.js +0 -223
- package/website/app/components/PlatformTabs.js +0 -250
- package/website/app/components/RegionsComponents.js +0 -429
- package/website/app/components/Rule.js +0 -13
- package/website/app/components/Specimens.js +0 -237
- package/website/app/components/StructuredData.js +0 -144
- package/website/app/components/TokenBrowser.js +0 -344
- package/website/app/components/token-browser-sample.js +0 -65
- package/website/app/globals.css +0 -505
- package/website/app/icon.svg +0 -7
- package/website/app/layout.js +0 -126
- package/website/app/opengraph-image.js +0 -170
- package/website/app/page.js +0 -352
- package/website/app/robots.js +0 -15
- package/website/app/seo-config.js +0 -82
- package/website/app/sitemap.js +0 -18
- package/website/jsconfig.json +0 -7
- package/website/lib/cache.js +0 -73
- package/website/lib/rate-limit.js +0 -30
- package/website/lib/rate-limit.test.js +0 -55
- package/website/lib/specimens.json +0 -86
- package/website/lib/token-helpers.js +0 -70
- package/website/lib/url-safety.js +0 -103
- package/website/lib/url-safety.test.js +0 -116
- package/website/lib/zip-files.js +0 -15
- package/website/next.config.mjs +0 -15
- package/website/package-lock.json +0 -1353
- package/website/package.json +0 -19
- package/website/public/favicon.svg +0 -7
- package/website/public/logo-specimen.svg +0 -76
- package/website/public/mark.svg +0 -12
- package/website/public/site.webmanifest +0 -13
package/src/crawler.js
CHANGED
|
@@ -23,6 +23,9 @@ export async function crawlPage(url, options = {}) {
|
|
|
23
23
|
cookies, headers, ignore,
|
|
24
24
|
insecure = false,
|
|
25
25
|
userAgent,
|
|
26
|
+
deepInteract = false,
|
|
27
|
+
selector,
|
|
28
|
+
channel,
|
|
26
29
|
} = options;
|
|
27
30
|
|
|
28
31
|
const launchArgs = [
|
|
@@ -38,6 +41,9 @@ export async function crawlPage(url, options = {}) {
|
|
|
38
41
|
const browser = await chromium.launch({
|
|
39
42
|
headless: true,
|
|
40
43
|
...(executablePath && { executablePath }),
|
|
44
|
+
// channel: 'chrome' forces Playwright to use the system Chrome install
|
|
45
|
+
// instead of the 150MB bundled Chromium — see --system-chrome.
|
|
46
|
+
...(channel && { channel }),
|
|
41
47
|
args: launchArgs,
|
|
42
48
|
});
|
|
43
49
|
try {
|
|
@@ -89,8 +95,16 @@ export async function crawlPage(url, options = {}) {
|
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
const title = await page.title();
|
|
92
|
-
|
|
98
|
+
|
|
99
|
+
// Auto-interact pass (Tier 2): scroll, open menus, hover, open accordions & a first modal.
|
|
100
|
+
let interactState = null;
|
|
101
|
+
if (deepInteract) {
|
|
102
|
+
interactState = await runInteractionPass(page).catch(() => null);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const lightData = await extractPageData(page, ignore, selector);
|
|
93
106
|
lightData.cssCoverage = cssCoverage;
|
|
107
|
+
if (interactState) lightData.interactState = interactState;
|
|
94
108
|
|
|
95
109
|
// Component screenshots
|
|
96
110
|
let componentScreenshots = {};
|
|
@@ -100,7 +114,17 @@ export async function crawlPage(url, options = {}) {
|
|
|
100
114
|
|
|
101
115
|
// Multi-page crawl: discover internal links and extract from them
|
|
102
116
|
let additionalPages = [];
|
|
117
|
+
const routes = [];
|
|
103
118
|
if (depth > 0) {
|
|
119
|
+
// Seed routes with the primary page
|
|
120
|
+
try {
|
|
121
|
+
const u0 = new URL(url);
|
|
122
|
+
routes.push({
|
|
123
|
+
url,
|
|
124
|
+
path: u0.pathname || '/',
|
|
125
|
+
computedStylesSample: (lightData.computedStyles || []).slice(0, 2000),
|
|
126
|
+
});
|
|
127
|
+
} catch { /* ignore */ }
|
|
104
128
|
const internalLinks = await discoverInternalLinks(page, url, depth);
|
|
105
129
|
for (const link of internalLinks) {
|
|
106
130
|
try {
|
|
@@ -109,6 +133,14 @@ export async function crawlPage(url, options = {}) {
|
|
|
109
133
|
await page.evaluate(() => document.fonts.ready).catch(() => {});
|
|
110
134
|
const pageData = await extractPageData(page);
|
|
111
135
|
additionalPages.push({ url: link, data: pageData });
|
|
136
|
+
try {
|
|
137
|
+
const u = new URL(link);
|
|
138
|
+
routes.push({
|
|
139
|
+
url: link,
|
|
140
|
+
path: u.pathname || '/',
|
|
141
|
+
computedStylesSample: (pageData.computedStyles || []).slice(0, 2000),
|
|
142
|
+
});
|
|
143
|
+
} catch { /* ignore */ }
|
|
112
144
|
} catch { /* skip failed pages */ }
|
|
113
145
|
}
|
|
114
146
|
}
|
|
@@ -153,6 +185,8 @@ export async function crawlPage(url, options = {}) {
|
|
|
153
185
|
url, title,
|
|
154
186
|
light: lightData,
|
|
155
187
|
dark: darkData,
|
|
188
|
+
interactState,
|
|
189
|
+
routes: routes.length > 0 ? routes : undefined,
|
|
156
190
|
pagesAnalyzed: 1 + additionalPages.length,
|
|
157
191
|
componentScreenshots,
|
|
158
192
|
};
|
|
@@ -228,8 +262,139 @@ export async function captureComponentScreenshots(page, outDir) {
|
|
|
228
262
|
return result;
|
|
229
263
|
}
|
|
230
264
|
|
|
231
|
-
async function
|
|
232
|
-
|
|
265
|
+
async function snapshotSelector(page, selector) {
|
|
266
|
+
try {
|
|
267
|
+
return await page.evaluate((sel) => {
|
|
268
|
+
const el = document.querySelector(sel);
|
|
269
|
+
if (!el) return null;
|
|
270
|
+
const cs = getComputedStyle(el);
|
|
271
|
+
return {
|
|
272
|
+
color: cs.color, backgroundColor: cs.backgroundColor,
|
|
273
|
+
borderColor: cs.borderColor, boxShadow: cs.boxShadow,
|
|
274
|
+
transform: cs.transform, opacity: cs.opacity,
|
|
275
|
+
outline: cs.outline, textDecoration: cs.textDecoration,
|
|
276
|
+
};
|
|
277
|
+
}, selector);
|
|
278
|
+
} catch { return null; }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function runInteractionPass(page) {
|
|
282
|
+
const state = {
|
|
283
|
+
scrollSettled: false,
|
|
284
|
+
menusOpened: 0,
|
|
285
|
+
hoverSamples: [],
|
|
286
|
+
accordionsOpened: 0,
|
|
287
|
+
modals: [],
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// 1) Full-page scroll in 4 steps to trigger lazy-load + scroll-linked animations
|
|
291
|
+
try {
|
|
292
|
+
for (let i = 1; i <= 4; i++) {
|
|
293
|
+
await page.evaluate((step) => {
|
|
294
|
+
const h = document.body.scrollHeight;
|
|
295
|
+
window.scrollTo(0, (h * step) / 4);
|
|
296
|
+
}, i).catch(() => {});
|
|
297
|
+
await page.waitForTimeout(300);
|
|
298
|
+
await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
|
|
299
|
+
}
|
|
300
|
+
await page.evaluate(() => window.scrollTo(0, 0)).catch(() => {});
|
|
301
|
+
await page.waitForTimeout(200);
|
|
302
|
+
state.scrollSettled = true;
|
|
303
|
+
} catch { /* ignore */ }
|
|
304
|
+
|
|
305
|
+
// 2) Open menus / dropdowns
|
|
306
|
+
try {
|
|
307
|
+
const triggers = await page.$$('nav [aria-haspopup], [aria-expanded="false"], .menu-toggle, .hamburger, [data-menu]');
|
|
308
|
+
for (const t of triggers.slice(0, 5)) {
|
|
309
|
+
try {
|
|
310
|
+
await t.click({ timeout: 1000, trial: false });
|
|
311
|
+
state.menusOpened++;
|
|
312
|
+
} catch { /* ignore */ }
|
|
313
|
+
}
|
|
314
|
+
await page.waitForTimeout(400);
|
|
315
|
+
} catch { /* ignore */ }
|
|
316
|
+
|
|
317
|
+
// 3) Hover up to 6 buttons + 6 links with style diffs
|
|
318
|
+
try {
|
|
319
|
+
const btnSelectors = await page.evaluate(() => {
|
|
320
|
+
const arr = [];
|
|
321
|
+
const btns = Array.from(document.querySelectorAll('button')).slice(0, 6);
|
|
322
|
+
btns.forEach((el, i) => arr.push(`button:nth-of-type(${i + 1})`));
|
|
323
|
+
return arr;
|
|
324
|
+
});
|
|
325
|
+
const linkSelectors = await page.evaluate(() => {
|
|
326
|
+
const arr = [];
|
|
327
|
+
const links = Array.from(document.querySelectorAll('a[href]')).slice(0, 6);
|
|
328
|
+
links.forEach((el, i) => arr.push(`a[href]:nth-of-type(${i + 1})`));
|
|
329
|
+
return arr;
|
|
330
|
+
});
|
|
331
|
+
const samples = [...(btnSelectors || []), ...(linkSelectors || [])].slice(0, 12);
|
|
332
|
+
for (const sel of samples) {
|
|
333
|
+
const before = await snapshotSelector(page, sel);
|
|
334
|
+
if (!before) continue;
|
|
335
|
+
try {
|
|
336
|
+
await page.hover(sel, { timeout: 500 });
|
|
337
|
+
await page.waitForTimeout(100);
|
|
338
|
+
const after = await snapshotSelector(page, sel);
|
|
339
|
+
if (after) state.hoverSamples.push({ selector: sel, before, after });
|
|
340
|
+
} catch { /* ignore */ }
|
|
341
|
+
}
|
|
342
|
+
} catch { /* ignore */ }
|
|
343
|
+
|
|
344
|
+
// 4) Accordions / details
|
|
345
|
+
try {
|
|
346
|
+
const accs = await page.$$('details, [role="tab"], [data-accordion]');
|
|
347
|
+
for (const a of accs.slice(0, 6)) {
|
|
348
|
+
try {
|
|
349
|
+
await a.click({ timeout: 800 });
|
|
350
|
+
state.accordionsOpened++;
|
|
351
|
+
} catch { /* ignore */ }
|
|
352
|
+
}
|
|
353
|
+
await page.waitForTimeout(200);
|
|
354
|
+
} catch { /* ignore */ }
|
|
355
|
+
|
|
356
|
+
// 5) First triggerable modal / dialog
|
|
357
|
+
try {
|
|
358
|
+
const candidates = await page.$$('button, a[role="button"]');
|
|
359
|
+
let triggered = false;
|
|
360
|
+
for (const c of candidates.slice(0, 30)) {
|
|
361
|
+
if (triggered) break;
|
|
362
|
+
try {
|
|
363
|
+
const txt = (await c.innerText({ timeout: 500 }).catch(() => '')) || '';
|
|
364
|
+
if (!/sign\s*in|log\s*in|menu|open|subscribe/i.test(txt)) continue;
|
|
365
|
+
await c.click({ timeout: 2000 });
|
|
366
|
+
await page.waitForTimeout(600);
|
|
367
|
+
const snapshot = await page.evaluate(() => {
|
|
368
|
+
const dlg = document.querySelector('dialog[open], [role="dialog"], [aria-modal="true"]');
|
|
369
|
+
if (!dlg) return null;
|
|
370
|
+
const cs = getComputedStyle(dlg);
|
|
371
|
+
const r = dlg.getBoundingClientRect();
|
|
372
|
+
return {
|
|
373
|
+
tag: dlg.tagName.toLowerCase(),
|
|
374
|
+
role: dlg.getAttribute('role') || '',
|
|
375
|
+
bg: cs.backgroundColor,
|
|
376
|
+
color: cs.color,
|
|
377
|
+
boxShadow: cs.boxShadow,
|
|
378
|
+
borderRadius: cs.borderRadius,
|
|
379
|
+
width: r.width,
|
|
380
|
+
height: r.height,
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
if (snapshot) {
|
|
384
|
+
state.modals.push({ trigger: txt.slice(0, 60), snapshot });
|
|
385
|
+
triggered = true;
|
|
386
|
+
}
|
|
387
|
+
await page.keyboard.press('Escape').catch(() => {});
|
|
388
|
+
await page.waitForTimeout(200);
|
|
389
|
+
} catch { /* ignore */ }
|
|
390
|
+
}
|
|
391
|
+
} catch { /* ignore */ }
|
|
392
|
+
|
|
393
|
+
return state;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function extractPageData(page, ignoreSelectors, scopeSelector) {
|
|
397
|
+
const data = await page.evaluate(({ maxElements, ignoreSelectors, scopeSelector }) => {
|
|
233
398
|
// Remove ignored elements before extraction
|
|
234
399
|
if (ignoreSelectors && ignoreSelectors.length > 0) {
|
|
235
400
|
for (const sel of ignoreSelectors) {
|
|
@@ -260,8 +425,85 @@ async function extractPageData(page, ignoreSelectors) {
|
|
|
260
425
|
}
|
|
261
426
|
return collected;
|
|
262
427
|
}
|
|
263
|
-
const elements = collectElements(document, []);
|
|
264
428
|
|
|
429
|
+
// If --selector was provided, scope element collection to the matching
|
|
430
|
+
// subtrees only. Falls back to the full document if the selector is
|
|
431
|
+
// invalid or returns no matches.
|
|
432
|
+
let scopeRoots = [document];
|
|
433
|
+
if (scopeSelector) {
|
|
434
|
+
try {
|
|
435
|
+
const matches = Array.from(document.querySelectorAll(scopeSelector));
|
|
436
|
+
if (matches.length > 0) scopeRoots = matches;
|
|
437
|
+
} catch { /* invalid selector → use document */ }
|
|
438
|
+
}
|
|
439
|
+
const elements = [];
|
|
440
|
+
for (const root of scopeRoots) {
|
|
441
|
+
if (root !== document && root.nodeType === 1) elements.push(root);
|
|
442
|
+
collectElements(root, elements);
|
|
443
|
+
if (elements.length >= maxElements) break;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Build a lightweight index: stylesheet URL + their top selectors.
|
|
447
|
+
// Used to attribute each element's primary source stylesheet.
|
|
448
|
+
const sheetIndex = [];
|
|
449
|
+
try {
|
|
450
|
+
for (const sheet of document.styleSheets) {
|
|
451
|
+
const entry = { url: sheet.href || '', mediaText: sheet.media ? sheet.media.mediaText : '', selectors: [] };
|
|
452
|
+
try {
|
|
453
|
+
let cap = 0;
|
|
454
|
+
for (const rule of sheet.cssRules) {
|
|
455
|
+
if (cap >= 200) break;
|
|
456
|
+
if (rule && rule.selectorText) {
|
|
457
|
+
entry.selectors.push(rule.selectorText);
|
|
458
|
+
cap++;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
} catch { /* cross-origin */ }
|
|
462
|
+
if (entry.url || entry.selectors.length > 0) sheetIndex.push(entry);
|
|
463
|
+
}
|
|
464
|
+
} catch { /* no access */ }
|
|
465
|
+
|
|
466
|
+
function findSourceFor(el) {
|
|
467
|
+
// Try to find the first stylesheet that has a selector matching this element.
|
|
468
|
+
for (const sheet of sheetIndex) {
|
|
469
|
+
for (const sel of sheet.selectors) {
|
|
470
|
+
try {
|
|
471
|
+
// selectorText can contain multiple comma-separated selectors
|
|
472
|
+
if (el.matches(sel)) {
|
|
473
|
+
return { url: sheet.url, mediaText: sheet.mediaText };
|
|
474
|
+
}
|
|
475
|
+
} catch { /* invalid or unsupported selector */ }
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function readPseudo(el, which) {
|
|
482
|
+
try {
|
|
483
|
+
const ps = getComputedStyle(el, which);
|
|
484
|
+
const content = ps.getPropertyValue('content');
|
|
485
|
+
if (!content || content === 'none' || content === 'normal') return null;
|
|
486
|
+
return {
|
|
487
|
+
content,
|
|
488
|
+
display: ps.display,
|
|
489
|
+
position: ps.position,
|
|
490
|
+
top: ps.top,
|
|
491
|
+
left: ps.left,
|
|
492
|
+
right: ps.right,
|
|
493
|
+
bottom: ps.bottom,
|
|
494
|
+
width: ps.width,
|
|
495
|
+
height: ps.height,
|
|
496
|
+
background: ps.background,
|
|
497
|
+
color: ps.color,
|
|
498
|
+
border: ps.border,
|
|
499
|
+
transform: ps.transform,
|
|
500
|
+
mask: ps.mask || ps.getPropertyValue('-webkit-mask') || '',
|
|
501
|
+
clipPath: ps.clipPath || ps.getPropertyValue('-webkit-clip-path') || '',
|
|
502
|
+
};
|
|
503
|
+
} catch { return null; }
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
let sourceAttrBudget = 500;
|
|
265
507
|
for (const el of elements) {
|
|
266
508
|
const cs = getComputedStyle(el);
|
|
267
509
|
const tag = el.tagName.toLowerCase();
|
|
@@ -270,8 +512,27 @@ async function extractPageData(page, ignoreSelectors) {
|
|
|
270
512
|
const rect = el.getBoundingClientRect();
|
|
271
513
|
const area = rect.width * rect.height;
|
|
272
514
|
|
|
515
|
+
const before = readPseudo(el, '::before');
|
|
516
|
+
const after = readPseudo(el, '::after');
|
|
517
|
+
const pseudo = (before || after) ? { before, after } : null;
|
|
518
|
+
|
|
519
|
+
let sources = null;
|
|
520
|
+
if (sourceAttrBudget > 0) {
|
|
521
|
+
const s = findSourceFor(el);
|
|
522
|
+
if (s) sources = [s];
|
|
523
|
+
sourceAttrBudget--;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// hasText: at least one direct text-node child with visible characters —
|
|
527
|
+
// lets downstream extractors filter decorative spans/divs out of WCAG
|
|
528
|
+
// contrast accounting.
|
|
529
|
+
let hasText = false;
|
|
530
|
+
for (const node of el.childNodes) {
|
|
531
|
+
if (node.nodeType === 3 && node.textContent && node.textContent.trim()) { hasText = true; break; }
|
|
532
|
+
}
|
|
533
|
+
|
|
273
534
|
results.computedStyles.push({
|
|
274
|
-
tag, classList, role, area,
|
|
535
|
+
tag, classList, role, area, hasText,
|
|
275
536
|
color: cs.color,
|
|
276
537
|
backgroundColor: cs.backgroundColor,
|
|
277
538
|
backgroundImage: cs.backgroundImage,
|
|
@@ -307,6 +568,14 @@ async function extractPageData(page, ignoreSelectors) {
|
|
|
307
568
|
gridTemplateColumns: cs.gridTemplateColumns,
|
|
308
569
|
gridTemplateRows: cs.gridTemplateRows,
|
|
309
570
|
maxWidth: cs.maxWidth,
|
|
571
|
+
fontVariationSettings: cs.fontVariationSettings || cs.getPropertyValue('font-variation-settings') || 'normal',
|
|
572
|
+
fontFeatureSettings: cs.fontFeatureSettings || cs.getPropertyValue('font-feature-settings') || 'normal',
|
|
573
|
+
textWrap: cs.textWrap || cs.getPropertyValue('text-wrap') || '',
|
|
574
|
+
textDecorationStyle: cs.textDecorationStyle || '',
|
|
575
|
+
textDecorationThickness: cs.textDecorationThickness || '',
|
|
576
|
+
textUnderlineOffset: cs.textUnderlineOffset || '',
|
|
577
|
+
pseudo,
|
|
578
|
+
sources,
|
|
310
579
|
});
|
|
311
580
|
}
|
|
312
581
|
|
|
@@ -366,6 +635,82 @@ async function extractPageData(page, ignoreSelectors) {
|
|
|
366
635
|
}
|
|
367
636
|
} catch { /* no access */ }
|
|
368
637
|
|
|
638
|
+
// Container queries (@container rules), env() usage, and modern colors
|
|
639
|
+
results.containerQueries = [];
|
|
640
|
+
results.envUsage = [];
|
|
641
|
+
results.modernColors = [];
|
|
642
|
+
const MODERN_COLOR_RE = /(oklch\([^)]+\)|oklab\([^)]+\)|color-mix\([^)]+\)|light-dark\([^)]+\)|color\(\s*display-p3[^)]+\)|color\(\s*rec2020[^)]+\))/gi;
|
|
643
|
+
function walkRulesForContainersAndEnv(rules) {
|
|
644
|
+
for (const rule of rules) {
|
|
645
|
+
try {
|
|
646
|
+
// Scan declarations for modern color functions
|
|
647
|
+
if (rule.style && rule.cssText) {
|
|
648
|
+
const css = rule.cssText;
|
|
649
|
+
for (const m of css.matchAll(MODERN_COLOR_RE)) {
|
|
650
|
+
const raw = m[1];
|
|
651
|
+
let type = 'other';
|
|
652
|
+
if (/^oklch/i.test(raw)) type = 'oklch';
|
|
653
|
+
else if (/^oklab/i.test(raw)) type = 'oklab';
|
|
654
|
+
else if (/^color-mix/i.test(raw)) type = 'color-mix';
|
|
655
|
+
else if (/^light-dark/i.test(raw)) type = 'light-dark';
|
|
656
|
+
else if (/display-p3/i.test(raw)) type = 'display-p3';
|
|
657
|
+
else if (/rec2020/i.test(raw)) type = 'rec2020';
|
|
658
|
+
// Try to infer property
|
|
659
|
+
let property = '';
|
|
660
|
+
for (let i = 0; i < rule.style.length; i++) {
|
|
661
|
+
const p = rule.style[i];
|
|
662
|
+
if ((rule.style.getPropertyValue(p) || '').includes(raw)) { property = p; break; }
|
|
663
|
+
}
|
|
664
|
+
results.modernColors.push({ raw, type, property, selector: rule.selectorText || '' });
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
// Container query
|
|
668
|
+
if (typeof CSSContainerRule !== 'undefined' && rule instanceof CSSContainerRule) {
|
|
669
|
+
const inner = [];
|
|
670
|
+
try {
|
|
671
|
+
for (const inr of rule.cssRules) {
|
|
672
|
+
if (inr.selectorText) inner.push(inr.selectorText);
|
|
673
|
+
}
|
|
674
|
+
} catch {}
|
|
675
|
+
results.containerQueries.push({
|
|
676
|
+
condition: rule.conditionText || rule.containerQuery || '',
|
|
677
|
+
selectorText: inner.join(', '),
|
|
678
|
+
declarationCount: inner.length,
|
|
679
|
+
});
|
|
680
|
+
} else if (rule.cssText && rule.cssText.startsWith('@container')) {
|
|
681
|
+
results.containerQueries.push({
|
|
682
|
+
condition: rule.conditionText || '',
|
|
683
|
+
selectorText: '',
|
|
684
|
+
declarationCount: 0,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
// env() scan on declaration text
|
|
688
|
+
if (rule.style) {
|
|
689
|
+
const css = rule.cssText || '';
|
|
690
|
+
const envMatches = css.match(/env\(\s*(safe-area-inset-[a-z]+|viewport-[a-z-]+|[a-z-]+)/gi);
|
|
691
|
+
if (envMatches) {
|
|
692
|
+
for (const m of envMatches) {
|
|
693
|
+
results.envUsage.push(m.replace(/^env\(\s*/, '').trim());
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
// Recurse into grouping rules (media, supports, container)
|
|
698
|
+
if (rule.cssRules) {
|
|
699
|
+
walkRulesForContainersAndEnv(rule.cssRules);
|
|
700
|
+
}
|
|
701
|
+
} catch { /* ignore per-rule errors */ }
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
try {
|
|
705
|
+
for (const sheet of document.styleSheets) {
|
|
706
|
+
try {
|
|
707
|
+
walkRulesForContainersAndEnv(sheet.cssRules);
|
|
708
|
+
} catch { /* cross-origin — already tracked */ }
|
|
709
|
+
}
|
|
710
|
+
} catch { /* no access */ }
|
|
711
|
+
// dedupe envUsage
|
|
712
|
+
results.envUsage = [...new Set(results.envUsage)];
|
|
713
|
+
|
|
369
714
|
// Component clusters (v7): per-element features for similarity-based grouping.
|
|
370
715
|
function colorToChannels(str) {
|
|
371
716
|
if (!str) return [0, 0, 0, 0];
|
|
@@ -524,7 +869,7 @@ async function extractPageData(page, ignoreSelectors) {
|
|
|
524
869
|
}
|
|
525
870
|
|
|
526
871
|
return results;
|
|
527
|
-
}, { maxElements: MAX_ELEMENTS, ignoreSelectors: ignoreSelectors || [] });
|
|
872
|
+
}, { maxElements: MAX_ELEMENTS, ignoreSelectors: ignoreSelectors || [], scopeSelector: scopeSelector || null });
|
|
528
873
|
|
|
529
874
|
// Fetch and parse cross-origin stylesheets
|
|
530
875
|
if (data.crossOriginSheets && data.crossOriginSheets.length > 0) {
|
|
@@ -551,6 +896,31 @@ function parseCrossOriginCSS(cssText, data) {
|
|
|
551
896
|
for (const m of cssText.matchAll(/@media\s*([^{]+)\{/g)) {
|
|
552
897
|
data.mediaQueries.push(m[1].trim());
|
|
553
898
|
}
|
|
899
|
+
// Container queries
|
|
900
|
+
if (!data.containerQueries) data.containerQueries = [];
|
|
901
|
+
for (const m of cssText.matchAll(/@container\s*([^{]*)\{/g)) {
|
|
902
|
+
data.containerQueries.push({ condition: m[1].trim(), selectorText: '', declarationCount: 0 });
|
|
903
|
+
}
|
|
904
|
+
// env() usage
|
|
905
|
+
if (!data.envUsage) data.envUsage = [];
|
|
906
|
+
for (const m of cssText.matchAll(/env\(\s*(safe-area-inset-[a-z]+|viewport-[a-z-]+)/gi)) {
|
|
907
|
+
data.envUsage.push(m[1]);
|
|
908
|
+
}
|
|
909
|
+
data.envUsage = [...new Set(data.envUsage)];
|
|
910
|
+
// Modern colors
|
|
911
|
+
if (!data.modernColors) data.modernColors = [];
|
|
912
|
+
const modernRe = /(oklch\([^)]+\)|oklab\([^)]+\)|color-mix\([^)]+\)|light-dark\([^)]+\)|color\(\s*display-p3[^)]+\)|color\(\s*rec2020[^)]+\))/gi;
|
|
913
|
+
for (const m of cssText.matchAll(modernRe)) {
|
|
914
|
+
const raw = m[1];
|
|
915
|
+
let type = 'other';
|
|
916
|
+
if (/^oklch/i.test(raw)) type = 'oklch';
|
|
917
|
+
else if (/^oklab/i.test(raw)) type = 'oklab';
|
|
918
|
+
else if (/^color-mix/i.test(raw)) type = 'color-mix';
|
|
919
|
+
else if (/^light-dark/i.test(raw)) type = 'light-dark';
|
|
920
|
+
else if (/display-p3/i.test(raw)) type = 'display-p3';
|
|
921
|
+
else if (/rec2020/i.test(raw)) type = 'rec2020';
|
|
922
|
+
data.modernColors.push({ raw, type, property: '', selector: '' });
|
|
923
|
+
}
|
|
554
924
|
// Keyframes
|
|
555
925
|
for (const m of cssText.matchAll(/@keyframes\s+([\w-]+)\s*\{([\s\S]*?)\n\}/g)) {
|
|
556
926
|
const steps = [];
|
|
@@ -28,16 +28,59 @@ function wcagLevel(ratio, isLargeText) {
|
|
|
28
28
|
return 'FAIL';
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Tags where "foreground vs background" contrast is *not* a WCAG text concern —
|
|
32
|
+
// SVG/icon glyphs, media, form primitives, and structural containers without
|
|
33
|
+
// direct text. Filtering these removes the overlay/decorative false-positives
|
|
34
|
+
// that used to crater scores on dark-themed sites.
|
|
35
|
+
const NON_TEXT_TAGS = new Set([
|
|
36
|
+
'svg', 'path', 'circle', 'rect', 'polygon', 'polyline', 'line', 'ellipse',
|
|
37
|
+
'use', 'defs', 'g', 'clippath', 'mask', 'filter', 'symbol', 'stop', 'lineargradient', 'radialgradient',
|
|
38
|
+
'img', 'picture', 'video', 'audio', 'canvas', 'iframe', 'source', 'track',
|
|
39
|
+
'br', 'hr', 'wbr',
|
|
40
|
+
'input', 'select', 'textarea', 'progress', 'meter', 'option', 'optgroup',
|
|
41
|
+
'script', 'style', 'link', 'meta', 'head', 'html', 'body',
|
|
42
|
+
'main', 'section', 'article', 'aside', 'header', 'footer', 'nav',
|
|
43
|
+
'div', 'figure', 'form', 'fieldset', 'ul', 'ol', 'dl',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const TEXT_BEARING_TAGS = new Set([
|
|
47
|
+
'p', 'a', 'button', 'label', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
48
|
+
'td', 'th', 'code', 'pre', 'em', 'strong', 'small', 'b', 'i', 'u',
|
|
49
|
+
'time', 'summary', 'figcaption', 'blockquote', 'q', 'mark', 'cite', 'abbr',
|
|
50
|
+
'dt', 'dd', 'kbd', 'samp', 'var', 'sub', 'sup', 'del', 'ins', 'caption', 'legend',
|
|
51
|
+
// span is a high-noise/high-signal tag — it wraps both real text and
|
|
52
|
+
// decorative glyphs. Include it but require an explicit background (the
|
|
53
|
+
// opacity filter downstream still removes the decorative transparent ones).
|
|
54
|
+
'span',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
function isContrastRelevant(el) {
|
|
58
|
+
const tag = (el.tag || '').toLowerCase();
|
|
59
|
+
if (NON_TEXT_TAGS.has(tag)) return false;
|
|
60
|
+
if (!TEXT_BEARING_TAGS.has(tag)) return false;
|
|
61
|
+
// If the crawler captured hasText, trust it — filters decorative
|
|
62
|
+
// span/link/button wrappers that hold no real glyphs. If hasText wasn't
|
|
63
|
+
// captured (older fixtures, unit tests) fall back to inclusion.
|
|
64
|
+
if (el.hasText === false) return false;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
31
68
|
export function extractAccessibility(computedStyles) {
|
|
32
69
|
const pairs = new Map(); // "fg|bg" -> { fg, bg, count, elements }
|
|
33
70
|
|
|
34
71
|
for (const el of computedStyles) {
|
|
72
|
+
if (!isContrastRelevant(el)) continue;
|
|
73
|
+
|
|
35
74
|
const fg = parseColor(el.color);
|
|
36
75
|
const bg = parseColor(el.backgroundColor);
|
|
37
|
-
if (!fg || !bg
|
|
76
|
+
if (!fg || !bg) continue;
|
|
77
|
+
// Skip transparent/semi-transparent — real contrast depends on the parent
|
|
78
|
+
// stack which we don't composite. Counting these as "fails" is noise.
|
|
79
|
+
if (bg.a < 0.9 || fg.a < 0.9) continue;
|
|
38
80
|
|
|
39
81
|
const fgHex = rgbToHex(fg);
|
|
40
82
|
const bgHex = rgbToHex(bg);
|
|
83
|
+
if (fgHex === bgHex) continue;
|
|
41
84
|
const key = `${fgHex}|${bgHex}`;
|
|
42
85
|
|
|
43
86
|
if (!pairs.has(key)) {
|
package/src/extractors/colors.js
CHANGED
|
@@ -1,25 +1,39 @@
|
|
|
1
|
-
import { parseColor, rgbToHex, rgbToHsl, clusterColors, isSaturated } from '../utils.js';
|
|
1
|
+
import { parseColor, rgbToHex, rgbToHsl, clusterColors, isSaturated, colorDistance } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
const INTERACTIVE_TAGS = new Set(['a', 'button']);
|
|
4
|
+
const INTERACTIVE_ROLES = new Set(['button', 'link', 'menuitem', 'tab']);
|
|
5
|
+
const INTERACTIVE_CLASS_RE = /\b(btn|button|cta|primary|action)\b/i;
|
|
6
|
+
|
|
7
|
+
function isInteractive(el) {
|
|
8
|
+
if (!el) return false;
|
|
9
|
+
if (INTERACTIVE_TAGS.has(el.tag)) return true;
|
|
10
|
+
if (el.role && INTERACTIVE_ROLES.has(el.role)) return true;
|
|
11
|
+
if (el.classList && INTERACTIVE_CLASS_RE.test(el.classList)) return true;
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
2
14
|
|
|
3
15
|
export function extractColors(computedStyles) {
|
|
4
|
-
const colorMap = new Map(); // hex -> { hex, parsed, count, contexts: Set }
|
|
16
|
+
const colorMap = new Map(); // hex -> { hex, parsed, count, contexts: Set, interactiveBg: number }
|
|
5
17
|
|
|
6
|
-
function addColor(value, context) {
|
|
18
|
+
function addColor(value, context, { interactive = false } = {}) {
|
|
7
19
|
const parsed = parseColor(value);
|
|
8
20
|
if (!parsed || parsed.a === 0) return;
|
|
9
21
|
const hex = rgbToHex(parsed);
|
|
10
22
|
if (!colorMap.has(hex)) {
|
|
11
|
-
colorMap.set(hex, { hex, parsed, count: 0, contexts: new Set() });
|
|
23
|
+
colorMap.set(hex, { hex, parsed, count: 0, contexts: new Set(), interactiveBg: 0 });
|
|
12
24
|
}
|
|
13
25
|
const entry = colorMap.get(hex);
|
|
14
26
|
entry.count++;
|
|
15
27
|
entry.contexts.add(context);
|
|
28
|
+
if (interactive && context === 'background') entry.interactiveBg++;
|
|
16
29
|
}
|
|
17
30
|
|
|
18
31
|
const gradients = new Set();
|
|
19
32
|
|
|
20
33
|
for (const el of computedStyles) {
|
|
34
|
+
const interactive = isInteractive(el);
|
|
21
35
|
addColor(el.color, 'text');
|
|
22
|
-
addColor(el.backgroundColor, 'background');
|
|
36
|
+
addColor(el.backgroundColor, 'background', { interactive });
|
|
23
37
|
addColor(el.borderColor, 'border');
|
|
24
38
|
|
|
25
39
|
if (el.backgroundImage && el.backgroundImage !== 'none' && el.backgroundImage.includes('gradient')) {
|
|
@@ -30,12 +44,21 @@ export function extractColors(computedStyles) {
|
|
|
30
44
|
const allColors = Array.from(colorMap.values());
|
|
31
45
|
const clusters = clusterColors(allColors, 15);
|
|
32
46
|
|
|
33
|
-
//
|
|
47
|
+
// Aggregate interactive-bg score per cluster (sum across members)
|
|
48
|
+
for (const cluster of clusters) {
|
|
49
|
+
cluster.interactiveBg = cluster.members.reduce((s, m) => s + (m.interactiveBg || 0), 0);
|
|
50
|
+
const { s: sat, l: lit } = rgbToHsl(cluster.representative);
|
|
51
|
+
cluster.saturation = sat;
|
|
52
|
+
cluster.lightness = lit;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Classify roles — tighten chromatic threshold so pale grays (hsl sat < 25) don't qualify
|
|
34
56
|
const neutrals = [];
|
|
35
57
|
const chromatic = [];
|
|
36
58
|
|
|
37
59
|
for (const cluster of clusters) {
|
|
38
|
-
|
|
60
|
+
const chromaticEnough = cluster.saturation > 25 && cluster.lightness > 5 && cluster.lightness < 95;
|
|
61
|
+
if (chromaticEnough || (isSaturated(cluster.representative) && cluster.interactiveBg > 0)) {
|
|
39
62
|
chromatic.push(cluster);
|
|
40
63
|
} else {
|
|
41
64
|
neutrals.push(cluster);
|
|
@@ -61,12 +84,27 @@ export function extractColors(computedStyles) {
|
|
|
61
84
|
}
|
|
62
85
|
}
|
|
63
86
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
87
|
+
// Rank chromatic clusters by brand-likelihood:
|
|
88
|
+
// interactiveBg carries the most signal (it's a CTA color)
|
|
89
|
+
// saturation comes next (brand colors are usually punchy)
|
|
90
|
+
// raw usage count is a weak tiebreaker (avoids neutral-heavy sites dominating)
|
|
91
|
+
function brandScore(c) {
|
|
92
|
+
return c.interactiveBg * 100 + c.saturation * 2 + Math.log10(Math.max(1, c.count));
|
|
93
|
+
}
|
|
94
|
+
const ranked = [...chromatic].sort((a, b) => brandScore(b) - brandScore(a));
|
|
95
|
+
|
|
96
|
+
const primary = ranked[0] || null;
|
|
97
|
+
// secondary: distinct hue from primary
|
|
98
|
+
const secondary = ranked.find(c => {
|
|
99
|
+
if (!primary || c === primary) return false;
|
|
100
|
+
return colorDistance(c.representative, primary.representative) > 60;
|
|
101
|
+
}) || ranked[1] || null;
|
|
102
|
+
// accent: sparse chromatic, prefers background context
|
|
103
|
+
const accent = ranked.find(c => {
|
|
104
|
+
if (c === primary || c === secondary) return false;
|
|
105
|
+
const pct = c.count / Math.max(1, allColors.reduce((s, a) => s + a.count, 0));
|
|
68
106
|
return pct < 0.05 && c.members.some(m => m.contexts.has('background'));
|
|
69
|
-
}) ||
|
|
107
|
+
}) || ranked.find(c => c !== primary && c !== secondary) || null;
|
|
70
108
|
|
|
71
109
|
return {
|
|
72
110
|
primary: primary ? { hex: primary.hex, rgb: primary.representative, hsl: rgbToHsl(primary.representative), count: primary.count } : null,
|