designlang 7.0.0 → 7.2.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 (77) hide show
  1. package/.github/og-preview.png +0 -0
  2. package/.github/workflows/manavarya-bot.yml +17 -0
  3. package/.vercel/README.txt +11 -0
  4. package/.vercel/project.json +1 -0
  5. package/CHANGELOG.md +29 -0
  6. package/CONTRIBUTING.md +25 -0
  7. package/README.md +38 -11
  8. package/bin/design-extract.js +41 -2
  9. package/chrome-extension/README.md +41 -0
  10. package/chrome-extension/icons/favicon.svg +7 -0
  11. package/chrome-extension/icons/icon-128.png +0 -0
  12. package/chrome-extension/icons/icon-16.png +0 -0
  13. package/chrome-extension/icons/icon-32.png +0 -0
  14. package/chrome-extension/icons/icon-48.png +0 -0
  15. package/chrome-extension/manifest.json +26 -0
  16. package/chrome-extension/popup.html +167 -0
  17. package/chrome-extension/popup.js +59 -0
  18. package/docs/superpowers/specs/2026-04-18-website-redesign-design.md +120 -0
  19. package/docs/superpowers/specs/2026-04-19-designlang-v7-1-design.md +111 -0
  20. package/package.json +1 -1
  21. package/src/config.js +5 -1
  22. package/src/crawler.js +361 -2
  23. package/src/extractors/interaction-states.js +57 -0
  24. package/src/extractors/modern-css.js +100 -0
  25. package/src/extractors/token-sources.js +65 -0
  26. package/src/extractors/wide-gamut.js +47 -0
  27. package/src/formatters/routes-reconciliation.js +160 -0
  28. package/src/index.js +29 -0
  29. package/src/utils/color-gamut.js +82 -0
  30. package/src/utils-cookies.js +73 -0
  31. package/tests/cookies.test.js +98 -0
  32. package/tests/interaction-states.test.js +62 -0
  33. package/tests/modern-css.test.js +104 -0
  34. package/tests/routes-reconciliation.test.js +120 -0
  35. package/tests/wide-gamut.test.js +90 -0
  36. package/website/app/api/extract/route.js +216 -56
  37. package/website/app/components/A11ySlider.js +369 -0
  38. package/website/app/components/Comparison.js +286 -0
  39. package/website/app/components/CssHealth.js +243 -0
  40. package/website/app/components/HeroExtractor.js +455 -0
  41. package/website/app/components/Marginalia.js +3 -0
  42. package/website/app/components/McpSection.js +223 -0
  43. package/website/app/components/PlatformTabs.js +250 -0
  44. package/website/app/components/RegionsComponents.js +429 -0
  45. package/website/app/components/Rule.js +13 -0
  46. package/website/app/components/Specimens.js +237 -0
  47. package/website/app/components/StructuredData.js +144 -0
  48. package/website/app/components/TokenBrowser.js +344 -0
  49. package/website/app/components/token-browser-sample.js +65 -0
  50. package/website/app/globals.css +415 -633
  51. package/website/app/icon.svg +7 -0
  52. package/website/app/layout.js +113 -6
  53. package/website/app/opengraph-image.js +170 -0
  54. package/website/app/page.js +372 -148
  55. package/website/app/robots.js +15 -0
  56. package/website/app/seo-config.js +82 -0
  57. package/website/app/sitemap.js +18 -0
  58. package/website/lib/cache.js +73 -0
  59. package/website/lib/rate-limit.js +30 -0
  60. package/website/lib/rate-limit.test.js +55 -0
  61. package/website/lib/specimens.json +86 -0
  62. package/website/lib/token-helpers.js +70 -0
  63. package/website/lib/url-safety.js +103 -0
  64. package/website/lib/url-safety.test.js +116 -0
  65. package/website/lib/zip-files.js +15 -0
  66. package/website/package-lock.json +85 -0
  67. package/website/package.json +1 -0
  68. package/website/public/favicon.svg +7 -0
  69. package/website/public/logo-specimen.svg +76 -0
  70. package/website/public/mark.svg +12 -0
  71. package/website/public/site.webmanifest +13 -0
  72. package/website/app/favicon.ico +0 -0
  73. package/website/public/file.svg +0 -1
  74. package/website/public/globe.svg +0 -1
  75. package/website/public/next.svg +0 -1
  76. package/website/public/vercel.svg +0 -1
  77. package/website/public/window.svg +0 -1
package/src/crawler.js CHANGED
@@ -17,17 +17,36 @@ async function gotoWithRetry(page, url, opts, retries = 3) {
17
17
  }
18
18
 
19
19
  export async function crawlPage(url, options = {}) {
20
- const { width = 1280, height = 800, wait = 0, dark = false, depth = 0, screenshots = false, outDir = '', executablePath, browserArgs, cookies, headers, ignore } = options;
20
+ const {
21
+ width = 1280, height = 800, wait = 0, dark = false, depth = 0,
22
+ screenshots = false, outDir = '', executablePath, browserArgs,
23
+ cookies, headers, ignore,
24
+ insecure = false,
25
+ userAgent,
26
+ deepInteract = false,
27
+ } = options;
28
+
29
+ const launchArgs = [
30
+ ...(browserArgs || []),
31
+ // Common flags that help with dev environments and CI. Insecure-only flags
32
+ // are added below when the user opts in.
33
+ '--disable-dev-shm-usage',
34
+ ];
35
+ if (insecure) {
36
+ launchArgs.push('--ignore-certificate-errors', '--ignore-ssl-errors');
37
+ }
21
38
 
22
39
  const browser = await chromium.launch({
23
40
  headless: true,
24
41
  ...(executablePath && { executablePath }),
25
- ...(browserArgs && { args: browserArgs }),
42
+ args: launchArgs,
26
43
  });
27
44
  try {
28
45
  const context = await browser.newContext({
29
46
  viewport: { width, height },
30
47
  colorScheme: 'light',
48
+ ignoreHTTPSErrors: insecure,
49
+ ...(userAgent && { userAgent }),
31
50
  ...(headers && { extraHTTPHeaders: headers }),
32
51
  });
33
52
 
@@ -71,8 +90,16 @@ export async function crawlPage(url, options = {}) {
71
90
  }
72
91
 
73
92
  const title = await page.title();
93
+
94
+ // Auto-interact pass (Tier 2): scroll, open menus, hover, open accordions & a first modal.
95
+ let interactState = null;
96
+ if (deepInteract) {
97
+ interactState = await runInteractionPass(page).catch(() => null);
98
+ }
99
+
74
100
  const lightData = await extractPageData(page, ignore);
75
101
  lightData.cssCoverage = cssCoverage;
102
+ if (interactState) lightData.interactState = interactState;
76
103
 
77
104
  // Component screenshots
78
105
  let componentScreenshots = {};
@@ -82,7 +109,17 @@ export async function crawlPage(url, options = {}) {
82
109
 
83
110
  // Multi-page crawl: discover internal links and extract from them
84
111
  let additionalPages = [];
112
+ const routes = [];
85
113
  if (depth > 0) {
114
+ // Seed routes with the primary page
115
+ try {
116
+ const u0 = new URL(url);
117
+ routes.push({
118
+ url,
119
+ path: u0.pathname || '/',
120
+ computedStylesSample: (lightData.computedStyles || []).slice(0, 2000),
121
+ });
122
+ } catch { /* ignore */ }
86
123
  const internalLinks = await discoverInternalLinks(page, url, depth);
87
124
  for (const link of internalLinks) {
88
125
  try {
@@ -91,6 +128,14 @@ export async function crawlPage(url, options = {}) {
91
128
  await page.evaluate(() => document.fonts.ready).catch(() => {});
92
129
  const pageData = await extractPageData(page);
93
130
  additionalPages.push({ url: link, data: pageData });
131
+ try {
132
+ const u = new URL(link);
133
+ routes.push({
134
+ url: link,
135
+ path: u.pathname || '/',
136
+ computedStylesSample: (pageData.computedStyles || []).slice(0, 2000),
137
+ });
138
+ } catch { /* ignore */ }
94
139
  } catch { /* skip failed pages */ }
95
140
  }
96
141
  }
@@ -135,6 +180,8 @@ export async function crawlPage(url, options = {}) {
135
180
  url, title,
136
181
  light: lightData,
137
182
  dark: darkData,
183
+ interactState,
184
+ routes: routes.length > 0 ? routes : undefined,
138
185
  pagesAnalyzed: 1 + additionalPages.length,
139
186
  componentScreenshots,
140
187
  };
@@ -210,6 +257,137 @@ export async function captureComponentScreenshots(page, outDir) {
210
257
  return result;
211
258
  }
212
259
 
260
+ async function snapshotSelector(page, selector) {
261
+ try {
262
+ return await page.evaluate((sel) => {
263
+ const el = document.querySelector(sel);
264
+ if (!el) return null;
265
+ const cs = getComputedStyle(el);
266
+ return {
267
+ color: cs.color, backgroundColor: cs.backgroundColor,
268
+ borderColor: cs.borderColor, boxShadow: cs.boxShadow,
269
+ transform: cs.transform, opacity: cs.opacity,
270
+ outline: cs.outline, textDecoration: cs.textDecoration,
271
+ };
272
+ }, selector);
273
+ } catch { return null; }
274
+ }
275
+
276
+ async function runInteractionPass(page) {
277
+ const state = {
278
+ scrollSettled: false,
279
+ menusOpened: 0,
280
+ hoverSamples: [],
281
+ accordionsOpened: 0,
282
+ modals: [],
283
+ };
284
+
285
+ // 1) Full-page scroll in 4 steps to trigger lazy-load + scroll-linked animations
286
+ try {
287
+ for (let i = 1; i <= 4; i++) {
288
+ await page.evaluate((step) => {
289
+ const h = document.body.scrollHeight;
290
+ window.scrollTo(0, (h * step) / 4);
291
+ }, i).catch(() => {});
292
+ await page.waitForTimeout(300);
293
+ await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
294
+ }
295
+ await page.evaluate(() => window.scrollTo(0, 0)).catch(() => {});
296
+ await page.waitForTimeout(200);
297
+ state.scrollSettled = true;
298
+ } catch { /* ignore */ }
299
+
300
+ // 2) Open menus / dropdowns
301
+ try {
302
+ const triggers = await page.$$('nav [aria-haspopup], [aria-expanded="false"], .menu-toggle, .hamburger, [data-menu]');
303
+ for (const t of triggers.slice(0, 5)) {
304
+ try {
305
+ await t.click({ timeout: 1000, trial: false });
306
+ state.menusOpened++;
307
+ } catch { /* ignore */ }
308
+ }
309
+ await page.waitForTimeout(400);
310
+ } catch { /* ignore */ }
311
+
312
+ // 3) Hover up to 6 buttons + 6 links with style diffs
313
+ try {
314
+ const btnSelectors = await page.evaluate(() => {
315
+ const arr = [];
316
+ const btns = Array.from(document.querySelectorAll('button')).slice(0, 6);
317
+ btns.forEach((el, i) => arr.push(`button:nth-of-type(${i + 1})`));
318
+ return arr;
319
+ });
320
+ const linkSelectors = await page.evaluate(() => {
321
+ const arr = [];
322
+ const links = Array.from(document.querySelectorAll('a[href]')).slice(0, 6);
323
+ links.forEach((el, i) => arr.push(`a[href]:nth-of-type(${i + 1})`));
324
+ return arr;
325
+ });
326
+ const samples = [...(btnSelectors || []), ...(linkSelectors || [])].slice(0, 12);
327
+ for (const sel of samples) {
328
+ const before = await snapshotSelector(page, sel);
329
+ if (!before) continue;
330
+ try {
331
+ await page.hover(sel, { timeout: 500 });
332
+ await page.waitForTimeout(100);
333
+ const after = await snapshotSelector(page, sel);
334
+ if (after) state.hoverSamples.push({ selector: sel, before, after });
335
+ } catch { /* ignore */ }
336
+ }
337
+ } catch { /* ignore */ }
338
+
339
+ // 4) Accordions / details
340
+ try {
341
+ const accs = await page.$$('details, [role="tab"], [data-accordion]');
342
+ for (const a of accs.slice(0, 6)) {
343
+ try {
344
+ await a.click({ timeout: 800 });
345
+ state.accordionsOpened++;
346
+ } catch { /* ignore */ }
347
+ }
348
+ await page.waitForTimeout(200);
349
+ } catch { /* ignore */ }
350
+
351
+ // 5) First triggerable modal / dialog
352
+ try {
353
+ const candidates = await page.$$('button, a[role="button"]');
354
+ let triggered = false;
355
+ for (const c of candidates.slice(0, 30)) {
356
+ if (triggered) break;
357
+ try {
358
+ const txt = (await c.innerText({ timeout: 500 }).catch(() => '')) || '';
359
+ if (!/sign\s*in|log\s*in|menu|open|subscribe/i.test(txt)) continue;
360
+ await c.click({ timeout: 2000 });
361
+ await page.waitForTimeout(600);
362
+ const snapshot = await page.evaluate(() => {
363
+ const dlg = document.querySelector('dialog[open], [role="dialog"], [aria-modal="true"]');
364
+ if (!dlg) return null;
365
+ const cs = getComputedStyle(dlg);
366
+ const r = dlg.getBoundingClientRect();
367
+ return {
368
+ tag: dlg.tagName.toLowerCase(),
369
+ role: dlg.getAttribute('role') || '',
370
+ bg: cs.backgroundColor,
371
+ color: cs.color,
372
+ boxShadow: cs.boxShadow,
373
+ borderRadius: cs.borderRadius,
374
+ width: r.width,
375
+ height: r.height,
376
+ };
377
+ });
378
+ if (snapshot) {
379
+ state.modals.push({ trigger: txt.slice(0, 60), snapshot });
380
+ triggered = true;
381
+ }
382
+ await page.keyboard.press('Escape').catch(() => {});
383
+ await page.waitForTimeout(200);
384
+ } catch { /* ignore */ }
385
+ }
386
+ } catch { /* ignore */ }
387
+
388
+ return state;
389
+ }
390
+
213
391
  async function extractPageData(page, ignoreSelectors) {
214
392
  const data = await page.evaluate(({ maxElements, ignoreSelectors }) => {
215
393
  // Remove ignored elements before extraction
@@ -244,6 +422,67 @@ async function extractPageData(page, ignoreSelectors) {
244
422
  }
245
423
  const elements = collectElements(document, []);
246
424
 
425
+ // Build a lightweight index: stylesheet URL + their top selectors.
426
+ // Used to attribute each element's primary source stylesheet.
427
+ const sheetIndex = [];
428
+ try {
429
+ for (const sheet of document.styleSheets) {
430
+ const entry = { url: sheet.href || '', mediaText: sheet.media ? sheet.media.mediaText : '', selectors: [] };
431
+ try {
432
+ let cap = 0;
433
+ for (const rule of sheet.cssRules) {
434
+ if (cap >= 200) break;
435
+ if (rule && rule.selectorText) {
436
+ entry.selectors.push(rule.selectorText);
437
+ cap++;
438
+ }
439
+ }
440
+ } catch { /* cross-origin */ }
441
+ if (entry.url || entry.selectors.length > 0) sheetIndex.push(entry);
442
+ }
443
+ } catch { /* no access */ }
444
+
445
+ function findSourceFor(el) {
446
+ // Try to find the first stylesheet that has a selector matching this element.
447
+ for (const sheet of sheetIndex) {
448
+ for (const sel of sheet.selectors) {
449
+ try {
450
+ // selectorText can contain multiple comma-separated selectors
451
+ if (el.matches(sel)) {
452
+ return { url: sheet.url, mediaText: sheet.mediaText };
453
+ }
454
+ } catch { /* invalid or unsupported selector */ }
455
+ }
456
+ }
457
+ return null;
458
+ }
459
+
460
+ function readPseudo(el, which) {
461
+ try {
462
+ const ps = getComputedStyle(el, which);
463
+ const content = ps.getPropertyValue('content');
464
+ if (!content || content === 'none' || content === 'normal') return null;
465
+ return {
466
+ content,
467
+ display: ps.display,
468
+ position: ps.position,
469
+ top: ps.top,
470
+ left: ps.left,
471
+ right: ps.right,
472
+ bottom: ps.bottom,
473
+ width: ps.width,
474
+ height: ps.height,
475
+ background: ps.background,
476
+ color: ps.color,
477
+ border: ps.border,
478
+ transform: ps.transform,
479
+ mask: ps.mask || ps.getPropertyValue('-webkit-mask') || '',
480
+ clipPath: ps.clipPath || ps.getPropertyValue('-webkit-clip-path') || '',
481
+ };
482
+ } catch { return null; }
483
+ }
484
+
485
+ let sourceAttrBudget = 500;
247
486
  for (const el of elements) {
248
487
  const cs = getComputedStyle(el);
249
488
  const tag = el.tagName.toLowerCase();
@@ -252,6 +491,17 @@ async function extractPageData(page, ignoreSelectors) {
252
491
  const rect = el.getBoundingClientRect();
253
492
  const area = rect.width * rect.height;
254
493
 
494
+ const before = readPseudo(el, '::before');
495
+ const after = readPseudo(el, '::after');
496
+ const pseudo = (before || after) ? { before, after } : null;
497
+
498
+ let sources = null;
499
+ if (sourceAttrBudget > 0) {
500
+ const s = findSourceFor(el);
501
+ if (s) sources = [s];
502
+ sourceAttrBudget--;
503
+ }
504
+
255
505
  results.computedStyles.push({
256
506
  tag, classList, role, area,
257
507
  color: cs.color,
@@ -289,6 +539,14 @@ async function extractPageData(page, ignoreSelectors) {
289
539
  gridTemplateColumns: cs.gridTemplateColumns,
290
540
  gridTemplateRows: cs.gridTemplateRows,
291
541
  maxWidth: cs.maxWidth,
542
+ fontVariationSettings: cs.fontVariationSettings || cs.getPropertyValue('font-variation-settings') || 'normal',
543
+ fontFeatureSettings: cs.fontFeatureSettings || cs.getPropertyValue('font-feature-settings') || 'normal',
544
+ textWrap: cs.textWrap || cs.getPropertyValue('text-wrap') || '',
545
+ textDecorationStyle: cs.textDecorationStyle || '',
546
+ textDecorationThickness: cs.textDecorationThickness || '',
547
+ textUnderlineOffset: cs.textUnderlineOffset || '',
548
+ pseudo,
549
+ sources,
292
550
  });
293
551
  }
294
552
 
@@ -348,6 +606,82 @@ async function extractPageData(page, ignoreSelectors) {
348
606
  }
349
607
  } catch { /* no access */ }
350
608
 
609
+ // Container queries (@container rules), env() usage, and modern colors
610
+ results.containerQueries = [];
611
+ results.envUsage = [];
612
+ results.modernColors = [];
613
+ const MODERN_COLOR_RE = /(oklch\([^)]+\)|oklab\([^)]+\)|color-mix\([^)]+\)|light-dark\([^)]+\)|color\(\s*display-p3[^)]+\)|color\(\s*rec2020[^)]+\))/gi;
614
+ function walkRulesForContainersAndEnv(rules) {
615
+ for (const rule of rules) {
616
+ try {
617
+ // Scan declarations for modern color functions
618
+ if (rule.style && rule.cssText) {
619
+ const css = rule.cssText;
620
+ for (const m of css.matchAll(MODERN_COLOR_RE)) {
621
+ const raw = m[1];
622
+ let type = 'other';
623
+ if (/^oklch/i.test(raw)) type = 'oklch';
624
+ else if (/^oklab/i.test(raw)) type = 'oklab';
625
+ else if (/^color-mix/i.test(raw)) type = 'color-mix';
626
+ else if (/^light-dark/i.test(raw)) type = 'light-dark';
627
+ else if (/display-p3/i.test(raw)) type = 'display-p3';
628
+ else if (/rec2020/i.test(raw)) type = 'rec2020';
629
+ // Try to infer property
630
+ let property = '';
631
+ for (let i = 0; i < rule.style.length; i++) {
632
+ const p = rule.style[i];
633
+ if ((rule.style.getPropertyValue(p) || '').includes(raw)) { property = p; break; }
634
+ }
635
+ results.modernColors.push({ raw, type, property, selector: rule.selectorText || '' });
636
+ }
637
+ }
638
+ // Container query
639
+ if (typeof CSSContainerRule !== 'undefined' && rule instanceof CSSContainerRule) {
640
+ const inner = [];
641
+ try {
642
+ for (const inr of rule.cssRules) {
643
+ if (inr.selectorText) inner.push(inr.selectorText);
644
+ }
645
+ } catch {}
646
+ results.containerQueries.push({
647
+ condition: rule.conditionText || rule.containerQuery || '',
648
+ selectorText: inner.join(', '),
649
+ declarationCount: inner.length,
650
+ });
651
+ } else if (rule.cssText && rule.cssText.startsWith('@container')) {
652
+ results.containerQueries.push({
653
+ condition: rule.conditionText || '',
654
+ selectorText: '',
655
+ declarationCount: 0,
656
+ });
657
+ }
658
+ // env() scan on declaration text
659
+ if (rule.style) {
660
+ const css = rule.cssText || '';
661
+ const envMatches = css.match(/env\(\s*(safe-area-inset-[a-z]+|viewport-[a-z-]+|[a-z-]+)/gi);
662
+ if (envMatches) {
663
+ for (const m of envMatches) {
664
+ results.envUsage.push(m.replace(/^env\(\s*/, '').trim());
665
+ }
666
+ }
667
+ }
668
+ // Recurse into grouping rules (media, supports, container)
669
+ if (rule.cssRules) {
670
+ walkRulesForContainersAndEnv(rule.cssRules);
671
+ }
672
+ } catch { /* ignore per-rule errors */ }
673
+ }
674
+ }
675
+ try {
676
+ for (const sheet of document.styleSheets) {
677
+ try {
678
+ walkRulesForContainersAndEnv(sheet.cssRules);
679
+ } catch { /* cross-origin — already tracked */ }
680
+ }
681
+ } catch { /* no access */ }
682
+ // dedupe envUsage
683
+ results.envUsage = [...new Set(results.envUsage)];
684
+
351
685
  // Component clusters (v7): per-element features for similarity-based grouping.
352
686
  function colorToChannels(str) {
353
687
  if (!str) return [0, 0, 0, 0];
@@ -533,6 +867,31 @@ function parseCrossOriginCSS(cssText, data) {
533
867
  for (const m of cssText.matchAll(/@media\s*([^{]+)\{/g)) {
534
868
  data.mediaQueries.push(m[1].trim());
535
869
  }
870
+ // Container queries
871
+ if (!data.containerQueries) data.containerQueries = [];
872
+ for (const m of cssText.matchAll(/@container\s*([^{]*)\{/g)) {
873
+ data.containerQueries.push({ condition: m[1].trim(), selectorText: '', declarationCount: 0 });
874
+ }
875
+ // env() usage
876
+ if (!data.envUsage) data.envUsage = [];
877
+ for (const m of cssText.matchAll(/env\(\s*(safe-area-inset-[a-z]+|viewport-[a-z-]+)/gi)) {
878
+ data.envUsage.push(m[1]);
879
+ }
880
+ data.envUsage = [...new Set(data.envUsage)];
881
+ // Modern colors
882
+ if (!data.modernColors) data.modernColors = [];
883
+ const modernRe = /(oklch\([^)]+\)|oklab\([^)]+\)|color-mix\([^)]+\)|light-dark\([^)]+\)|color\(\s*display-p3[^)]+\)|color\(\s*rec2020[^)]+\))/gi;
884
+ for (const m of cssText.matchAll(modernRe)) {
885
+ const raw = m[1];
886
+ let type = 'other';
887
+ if (/^oklch/i.test(raw)) type = 'oklch';
888
+ else if (/^oklab/i.test(raw)) type = 'oklab';
889
+ else if (/^color-mix/i.test(raw)) type = 'color-mix';
890
+ else if (/^light-dark/i.test(raw)) type = 'light-dark';
891
+ else if (/display-p3/i.test(raw)) type = 'display-p3';
892
+ else if (/rec2020/i.test(raw)) type = 'rec2020';
893
+ data.modernColors.push({ raw, type, property: '', selector: '' });
894
+ }
536
895
  // Keyframes
537
896
  for (const m of cssText.matchAll(/@keyframes\s+([\w-]+)\s*\{([\s\S]*?)\n\}/g)) {
538
897
  const steps = [];
@@ -0,0 +1,57 @@
1
+ // Structured catalog of transition styles captured by the Tier-2 interaction
2
+ // pass — hover deltas, modal appearance, menu styling.
3
+
4
+ function diffStyles(before, after) {
5
+ const diff = {};
6
+ if (!before || !after) return diff;
7
+ for (const k of Object.keys(after)) {
8
+ if (before[k] !== after[k]) {
9
+ diff[k] = { from: before[k], to: after[k] };
10
+ }
11
+ }
12
+ return diff;
13
+ }
14
+
15
+ export function extractInteractionStates(interactState) {
16
+ if (!interactState || typeof interactState !== 'object') {
17
+ return {
18
+ scrollSettled: false,
19
+ menusOpened: 0,
20
+ hover: { sampled: 0, changed: 0, deltas: [] },
21
+ accordionsOpened: 0,
22
+ modals: [],
23
+ };
24
+ }
25
+
26
+ const deltas = [];
27
+ const samples = Array.isArray(interactState.hoverSamples) ? interactState.hoverSamples : [];
28
+ for (const s of samples) {
29
+ const d = diffStyles(s.before, s.after);
30
+ if (Object.keys(d).length > 0) {
31
+ deltas.push({ selector: s.selector, changes: d });
32
+ }
33
+ }
34
+
35
+ const modals = Array.isArray(interactState.modals) ? interactState.modals.map(m => ({
36
+ trigger: m.trigger || '',
37
+ bg: m.snapshot?.bg || '',
38
+ color: m.snapshot?.color || '',
39
+ boxShadow: m.snapshot?.boxShadow || '',
40
+ borderRadius: m.snapshot?.borderRadius || '',
41
+ width: m.snapshot?.width || 0,
42
+ height: m.snapshot?.height || 0,
43
+ role: m.snapshot?.role || '',
44
+ })) : [];
45
+
46
+ return {
47
+ scrollSettled: !!interactState.scrollSettled,
48
+ menusOpened: interactState.menusOpened || 0,
49
+ hover: {
50
+ sampled: samples.length,
51
+ changed: deltas.length,
52
+ deltas,
53
+ },
54
+ accordionsOpened: interactState.accordionsOpened || 0,
55
+ modals,
56
+ };
57
+ }
@@ -0,0 +1,100 @@
1
+ // Extractor for modern CSS features captured by the crawler:
2
+ // - Pseudo-elements (::before / ::after)
3
+ // - Variable-font axes (font-variation-settings)
4
+ // - OpenType features (font-feature-settings)
5
+ // - Modern text layout (text-wrap, text-decoration-*)
6
+ // - Container queries (@container)
7
+ // - env() usage (safe-area-inset-*, viewport-*)
8
+
9
+ export function extractModernCss(payload) {
10
+ const light = (payload && payload.light) || payload || {};
11
+ const styles = Array.isArray(light.computedStyles) ? light.computedStyles : [];
12
+
13
+ // Pseudo-elements
14
+ const pseudoSamples = [];
15
+ let pseudoCount = 0;
16
+ for (const s of styles) {
17
+ const p = s && s.pseudo;
18
+ if (!p) continue;
19
+ if (p.before) { pseudoCount++; if (pseudoSamples.length < 20) pseudoSamples.push({ tag: s.tag, classList: s.classList, which: '::before', style: p.before }); }
20
+ if (p.after) { pseudoCount++; if (pseudoSamples.length < 20) pseudoSamples.push({ tag: s.tag, classList: s.classList, which: '::after', style: p.after }); }
21
+ }
22
+
23
+ // Variable fonts
24
+ const axesMap = new Map();
25
+ let variableFontCount = 0;
26
+ for (const s of styles) {
27
+ const v = s && s.fontVariationSettings;
28
+ if (!v || v === 'normal' || v === '') continue;
29
+ variableFontCount++;
30
+ // e.g. "\"wght\" 600, \"slnt\" -4"
31
+ for (const m of String(v).matchAll(/"([^"]+)"\s+(-?\d+(?:\.\d+)?)/g)) {
32
+ const axis = m[1];
33
+ const val = parseFloat(m[2]);
34
+ if (!axesMap.has(axis)) axesMap.set(axis, { axis, min: val, max: val, count: 0 });
35
+ const a = axesMap.get(axis);
36
+ a.min = Math.min(a.min, val);
37
+ a.max = Math.max(a.max, val);
38
+ a.count++;
39
+ }
40
+ }
41
+
42
+ // OpenType features
43
+ const featMap = new Map();
44
+ for (const s of styles) {
45
+ const f = s && s.fontFeatureSettings;
46
+ if (!f || f === 'normal' || f === '') continue;
47
+ for (const m of String(f).matchAll(/"([^"]+)"(?:\s+(on|off|\d+))?/g)) {
48
+ const key = m[1];
49
+ featMap.set(key, (featMap.get(key) || 0) + 1);
50
+ }
51
+ }
52
+
53
+ // Text-wrap / decoration
54
+ const textWrapMap = new Map();
55
+ const decStyleMap = new Map();
56
+ const thicknessMap = new Map();
57
+ const offsetMap = new Map();
58
+ for (const s of styles) {
59
+ if (s.textWrap && s.textWrap !== 'wrap' && s.textWrap !== '') {
60
+ textWrapMap.set(s.textWrap, (textWrapMap.get(s.textWrap) || 0) + 1);
61
+ }
62
+ if (s.textDecorationStyle && s.textDecorationStyle !== 'solid' && s.textDecorationStyle !== '') {
63
+ decStyleMap.set(s.textDecorationStyle, (decStyleMap.get(s.textDecorationStyle) || 0) + 1);
64
+ }
65
+ if (s.textDecorationThickness && s.textDecorationThickness !== 'auto' && s.textDecorationThickness !== '') {
66
+ thicknessMap.set(s.textDecorationThickness, (thicknessMap.get(s.textDecorationThickness) || 0) + 1);
67
+ }
68
+ if (s.textUnderlineOffset && s.textUnderlineOffset !== 'auto' && s.textUnderlineOffset !== '') {
69
+ offsetMap.set(s.textUnderlineOffset, (offsetMap.get(s.textUnderlineOffset) || 0) + 1);
70
+ }
71
+ }
72
+
73
+ const containerQueries = Array.isArray(light.containerQueries) ? light.containerQueries : [];
74
+ const envUsage = Array.isArray(light.envUsage) ? light.envUsage : [];
75
+
76
+ return {
77
+ pseudoElements: {
78
+ count: pseudoCount,
79
+ samples: pseudoSamples,
80
+ },
81
+ variableFonts: {
82
+ count: variableFontCount,
83
+ axes: [...axesMap.values()].sort((a, b) => b.count - a.count),
84
+ },
85
+ openTypeFeatures: [...featMap.entries()]
86
+ .map(([feature, count]) => ({ feature, count }))
87
+ .sort((a, b) => b.count - a.count),
88
+ textWrap: {
89
+ wrap: [...textWrapMap.entries()].map(([value, count]) => ({ value, count })),
90
+ decorationStyle: [...decStyleMap.entries()].map(([value, count]) => ({ value, count })),
91
+ decorationThickness: [...thicknessMap.entries()].map(([value, count]) => ({ value, count })),
92
+ underlineOffset: [...offsetMap.entries()].map(([value, count]) => ({ value, count })),
93
+ },
94
+ containerQueries: {
95
+ count: containerQueries.length,
96
+ rules: containerQueries,
97
+ },
98
+ envUsage: [...new Set(envUsage)],
99
+ };
100
+ }
@@ -0,0 +1,65 @@
1
+ // Attribute top design tokens back to the stylesheet URL that most likely
2
+ // contributed them. Uses the per-element `sources` captured by the crawler.
3
+
4
+ import { parseColor, rgbToHex } from '../utils.js';
5
+
6
+ function firstSourceUrlWhere(styles, predicate) {
7
+ for (const s of styles) {
8
+ if (!s || !predicate(s)) continue;
9
+ const src = Array.isArray(s.sources) ? s.sources[0] : null;
10
+ if (src && src.url) return src.url;
11
+ }
12
+ return '';
13
+ }
14
+
15
+ export function extractTokenSources(design, computedStyles) {
16
+ const styles = Array.isArray(computedStyles) ? computedStyles : [];
17
+ const out = [];
18
+
19
+ // Primary color
20
+ const primaryHex = design.colors?.primary?.hex;
21
+ if (primaryHex) {
22
+ const url = firstSourceUrlWhere(styles, s => {
23
+ const p = parseColor(s.color);
24
+ return p && rgbToHex(p) === primaryHex;
25
+ });
26
+ out.push({ token: 'color.primary', path: 'colors.primary', sourceUrl: url });
27
+ }
28
+
29
+ // Text color (first in design.colors.text[])
30
+ const textHex = (design.colors?.text || [])[0];
31
+ if (textHex) {
32
+ const url = firstSourceUrlWhere(styles, s => {
33
+ const p = parseColor(s.color);
34
+ return p && rgbToHex(p) === textHex;
35
+ });
36
+ out.push({ token: 'color.text', path: 'colors.text[0]', sourceUrl: url });
37
+ }
38
+
39
+ // Body font — first typography family
40
+ const bodyFont = design.typography?.families?.[0]?.name;
41
+ if (bodyFont) {
42
+ const url = firstSourceUrlWhere(styles, s => typeof s.fontFamily === 'string' && s.fontFamily.includes(bodyFont));
43
+ out.push({ token: 'font.body', path: 'typography.families[0]', sourceUrl: url });
44
+ }
45
+
46
+ // Spacing base
47
+ const spacingBase = design.spacing?.base;
48
+ if (spacingBase != null) {
49
+ const target = `${spacingBase}px`;
50
+ const url = firstSourceUrlWhere(styles,
51
+ s => s.paddingTop === target || s.paddingLeft === target || s.marginTop === target || s.gap === target);
52
+ out.push({ token: 'spacing.base', path: 'spacing.base', sourceUrl: url });
53
+ }
54
+
55
+ // Radius base — first non-zero from design.borders.radii
56
+ const radii = design.borders?.radii || [];
57
+ const firstRadius = radii.find(r => (r.value || r) && (r.value || r) !== '0px');
58
+ if (firstRadius) {
59
+ const target = typeof firstRadius === 'string' ? firstRadius : (firstRadius.value || '');
60
+ const url = firstSourceUrlWhere(styles, s => s.borderRadius === target);
61
+ out.push({ token: 'radius.base', path: 'borders.radii[0]', sourceUrl: url });
62
+ }
63
+
64
+ return out;
65
+ }