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.
Files changed (82) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +46 -4
  3. package/bin/design-extract.js +28 -2
  4. package/package.json +1 -1
  5. package/src/config.js +4 -1
  6. package/src/crawler.js +376 -6
  7. package/src/extractors/accessibility.js +44 -1
  8. package/src/extractors/colors.js +50 -12
  9. package/src/extractors/interaction-states.js +57 -0
  10. package/src/extractors/modern-css.js +100 -0
  11. package/src/extractors/scoring.js +49 -30
  12. package/src/extractors/token-sources.js +65 -0
  13. package/src/extractors/wide-gamut.js +47 -0
  14. package/src/formatters/routes-reconciliation.js +160 -0
  15. package/src/index.js +29 -0
  16. package/src/utils/color-gamut.js +82 -0
  17. package/.github/FUNDING.yml +0 -1
  18. package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -62
  19. package/.github/ISSUE_TEMPLATE/config.yml +0 -8
  20. package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -28
  21. package/chrome-extension/README.md +0 -41
  22. package/chrome-extension/icons/favicon.svg +0 -7
  23. package/chrome-extension/icons/icon-128.png +0 -0
  24. package/chrome-extension/icons/icon-16.png +0 -0
  25. package/chrome-extension/icons/icon-32.png +0 -0
  26. package/chrome-extension/icons/icon-48.png +0 -0
  27. package/chrome-extension/manifest.json +0 -26
  28. package/chrome-extension/popup.html +0 -167
  29. package/chrome-extension/popup.js +0 -59
  30. package/docs/superpowers/plans/2026-04-18-designlang-v7.md +0 -1121
  31. package/docs/superpowers/specs/2026-04-18-designlang-v7-design.md +0 -150
  32. package/docs/superpowers/specs/2026-04-18-website-redesign-design.md +0 -120
  33. package/docs/superpowers/specs/2026-04-19-designlang-v7-1-design.md +0 -111
  34. package/tests/cli.test.js +0 -84
  35. package/tests/cookies.test.js +0 -98
  36. package/tests/extractors.test.js +0 -792
  37. package/tests/formatters.test.js +0 -709
  38. package/tests/mcp.test.js +0 -68
  39. package/tests/utils.test.js +0 -413
  40. package/website/.claude/launch.json +0 -11
  41. package/website/AGENTS.md +0 -5
  42. package/website/CLAUDE.md +0 -1
  43. package/website/README.md +0 -36
  44. package/website/app/api/extract/route.js +0 -245
  45. package/website/app/components/A11ySlider.js +0 -369
  46. package/website/app/components/Comparison.js +0 -286
  47. package/website/app/components/CssHealth.js +0 -243
  48. package/website/app/components/Extractor.js +0 -184
  49. package/website/app/components/HeroExtractor.js +0 -455
  50. package/website/app/components/Marginalia.js +0 -3
  51. package/website/app/components/McpSection.js +0 -223
  52. package/website/app/components/PlatformTabs.js +0 -250
  53. package/website/app/components/RegionsComponents.js +0 -429
  54. package/website/app/components/Rule.js +0 -13
  55. package/website/app/components/Specimens.js +0 -237
  56. package/website/app/components/StructuredData.js +0 -144
  57. package/website/app/components/TokenBrowser.js +0 -344
  58. package/website/app/components/token-browser-sample.js +0 -65
  59. package/website/app/globals.css +0 -505
  60. package/website/app/icon.svg +0 -7
  61. package/website/app/layout.js +0 -126
  62. package/website/app/opengraph-image.js +0 -170
  63. package/website/app/page.js +0 -352
  64. package/website/app/robots.js +0 -15
  65. package/website/app/seo-config.js +0 -82
  66. package/website/app/sitemap.js +0 -18
  67. package/website/jsconfig.json +0 -7
  68. package/website/lib/cache.js +0 -73
  69. package/website/lib/rate-limit.js +0 -30
  70. package/website/lib/rate-limit.test.js +0 -55
  71. package/website/lib/specimens.json +0 -86
  72. package/website/lib/token-helpers.js +0 -70
  73. package/website/lib/url-safety.js +0 -103
  74. package/website/lib/url-safety.test.js +0 -116
  75. package/website/lib/zip-files.js +0 -15
  76. package/website/next.config.mjs +0 -15
  77. package/website/package-lock.json +0 -1353
  78. package/website/package.json +0 -19
  79. package/website/public/favicon.svg +0 -7
  80. package/website/public/logo-specimen.svg +0 -76
  81. package/website/public/mark.svg +0 -12
  82. 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
- const lightData = await extractPageData(page, ignore);
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 extractPageData(page, ignoreSelectors) {
232
- const data = await page.evaluate(({ maxElements, ignoreSelectors }) => {
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 || bg.a === 0) continue;
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)) {
@@ -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
- // Classify roles
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
- if (isSaturated(cluster.representative)) {
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
- const primary = chromatic[0] || null;
65
- const secondary = chromatic[1] || null;
66
- const accent = chromatic.find(c => {
67
- const pct = c.count / allColors.reduce((s, a) => s + a.count, 0);
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
- }) || chromatic[2] || null;
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,