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
@@ -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
+ }
@@ -4,14 +4,16 @@ export function scoreDesignSystem(design) {
4
4
  const scores = {};
5
5
  const issues = [];
6
6
 
7
- // 1. Color discipline (0-100)
8
- // Fewer unique colors = more disciplined
7
+ // 1. Color discipline (0-100) — calibrated against real production sites
8
+ // (Linear, Stripe, Vercel, GitHub, Apple) which commonly ship 20–50 colors
9
+ // once you include hover/disabled/alpha variants.
9
10
  const colorCount = design.colors.all.length;
10
- if (colorCount <= 8) scores.colorDiscipline = 100;
11
- else if (colorCount <= 15) scores.colorDiscipline = 85;
12
- else if (colorCount <= 25) scores.colorDiscipline = 70;
13
- else if (colorCount <= 40) scores.colorDiscipline = 50;
14
- else { scores.colorDiscipline = 30; issues.push(`${colorCount} unique colors detected consider consolidating to a tighter palette`); }
11
+ if (colorCount <= 12) scores.colorDiscipline = 100;
12
+ else if (colorCount <= 25) scores.colorDiscipline = 92;
13
+ else if (colorCount <= 40) scores.colorDiscipline = 80;
14
+ else if (colorCount <= 60) scores.colorDiscipline = 65;
15
+ else if (colorCount <= 100) scores.colorDiscipline = 50;
16
+ else { scores.colorDiscipline = 35; issues.push(`${colorCount} unique colors detected — consider consolidating to a tighter palette`); }
15
17
 
16
18
  if (!design.colors.primary) {
17
19
  scores.colorDiscipline -= 15;
@@ -26,46 +28,62 @@ export function scoreDesignSystem(design) {
26
28
 
27
29
  const weightCount = design.typography.weights.length;
28
30
  if (weightCount <= 3) scores.typographyConsistency = Math.min(scores.typographyConsistency, 100);
29
- else if (weightCount <= 5) scores.typographyConsistency = Math.min(scores.typographyConsistency, 80);
31
+ else if (weightCount <= 5) scores.typographyConsistency = Math.min(scores.typographyConsistency, 90);
32
+ else if (weightCount <= 7) scores.typographyConsistency = Math.min(scores.typographyConsistency, 80);
30
33
  else { scores.typographyConsistency -= 15; issues.push(`${weightCount} font weights in use — consider standardizing to 3 (regular, medium, bold)`); }
31
34
 
35
+ // Type-scale count — variable-font sites and component-rich pages commonly
36
+ // ship 12–18 sizes (nav, body, caption, h1–h6, stat, pill, etc).
32
37
  const scaleSize = design.typography.scale.length;
33
- if (scaleSize <= 6) scores.typographyConsistency = Math.min(scores.typographyConsistency, 100);
34
- else if (scaleSize <= 10) scores.typographyConsistency = Math.min(scores.typographyConsistency, 85);
38
+ if (scaleSize <= 8) scores.typographyConsistency = Math.min(scores.typographyConsistency, 100);
39
+ else if (scaleSize <= 14) scores.typographyConsistency = Math.min(scores.typographyConsistency, 92);
40
+ else if (scaleSize <= 20) scores.typographyConsistency = Math.min(scores.typographyConsistency, 82);
35
41
  else { scores.typographyConsistency -= 10; issues.push(`${scaleSize} distinct font sizes — consider a tighter type scale`); }
36
42
 
37
- // 3. Spacing system (0-100)
43
+ // 3. Spacing system (0-100) — detectScale() only tries 2/4/6/8 as bases and
44
+ // fails on sites whose computed-style spacing has line-height/SVG noise mixed
45
+ // in. Don't penalise too hard when a base isn't pinned but tokenization is
46
+ // healthy — the design still has a system, we just can't name it.
38
47
  if (design.spacing.base) {
39
48
  scores.spacingSystem = 90;
40
- // Check how many values fit the base
41
49
  const fittingValues = design.spacing.scale.filter(v => v % design.spacing.base === 0).length;
42
50
  const fitRatio = fittingValues / design.spacing.scale.length;
43
51
  if (fitRatio >= 0.8) scores.spacingSystem = 100;
44
- else if (fitRatio >= 0.6) scores.spacingSystem = 80;
45
- else scores.spacingSystem = 65;
52
+ else if (fitRatio >= 0.6) scores.spacingSystem = 85;
53
+ else scores.spacingSystem = 75;
46
54
  } else {
47
- scores.spacingSystem = 40;
48
- issues.push('No consistent spacing base unit detected values appear arbitrary');
55
+ // Fallback: soft penalty if the site still ships design tokens (CSS vars).
56
+ const varCount = Object.values(design.variables || {}).reduce((s, v) => s + Object.keys(v || {}).length, 0);
57
+ scores.spacingSystem = varCount >= 20 ? 70 : 55;
58
+ if (varCount < 20) issues.push('No consistent spacing base unit detected — values appear arbitrary');
49
59
  }
50
60
 
51
- if (design.spacing.scale.length > 20) {
61
+ // Spacing value count — real sites commonly ship 25–40 distinct spacings
62
+ // (component padding, gap, stacked layout rhythm). Only penalise above 35.
63
+ if (design.spacing.scale.length > 50) {
52
64
  scores.spacingSystem -= 15;
53
65
  issues.push(`${design.spacing.scale.length} unique spacing values — too many one-off values`);
66
+ } else if (design.spacing.scale.length > 35) {
67
+ scores.spacingSystem -= 5;
54
68
  }
55
69
 
56
- // 4. Shadow consistency (0-100)
70
+ // 4. Shadow consistency (0-100) — calibrated; real sites routinely ship
71
+ // 10–20 shadows once hover/focus/elevation variants are counted.
57
72
  const shadowCount = design.shadows.values.length;
58
- if (shadowCount === 0) scores.shadowConsistency = 80; // no shadows is fine
59
- else if (shadowCount <= 4) scores.shadowConsistency = 100;
60
- else if (shadowCount <= 8) scores.shadowConsistency = 75;
73
+ if (shadowCount === 0) scores.shadowConsistency = 85;
74
+ else if (shadowCount <= 5) scores.shadowConsistency = 100;
75
+ else if (shadowCount <= 10) scores.shadowConsistency = 90;
76
+ else if (shadowCount <= 18) scores.shadowConsistency = 78;
77
+ else if (shadowCount <= 28) scores.shadowConsistency = 62;
61
78
  else { scores.shadowConsistency = 50; issues.push(`${shadowCount} unique shadows — consider a 3-level elevation scale (sm/md/lg)`); }
62
79
 
63
80
  // 5. Border radius consistency (0-100)
64
81
  const radiiCount = design.borders.radii.length;
65
- if (radiiCount <= 3) scores.radiusConsistency = 100;
66
- else if (radiiCount <= 5) scores.radiusConsistency = 85;
67
- else if (radiiCount <= 8) scores.radiusConsistency = 65;
68
- else { scores.radiusConsistency = 40; issues.push(`${radiiCount} unique border radii standardize to 3-4 values`); }
82
+ if (radiiCount <= 4) scores.radiusConsistency = 100;
83
+ else if (radiiCount <= 7) scores.radiusConsistency = 90;
84
+ else if (radiiCount <= 10) scores.radiusConsistency = 80;
85
+ else if (radiiCount <= 15) scores.radiusConsistency = 65;
86
+ else { scores.radiusConsistency = 45; issues.push(`${radiiCount} unique border radii — standardize to 3-4 values`); }
69
87
 
70
88
  // 6. Accessibility (from existing extractor)
71
89
  scores.accessibility = design.accessibility?.score || 0;
@@ -101,13 +119,14 @@ export function scoreDesignSystem(design) {
101
119
 
102
120
  // Overall score (weighted average)
103
121
  const weights = {
104
- colorDiscipline: 20,
105
- typographyConsistency: 20,
106
- spacingSystem: 20,
107
- shadowConsistency: 10,
108
- radiusConsistency: 10,
122
+ colorDiscipline: 18,
123
+ typographyConsistency: 18,
124
+ spacingSystem: 18,
125
+ shadowConsistency: 9,
126
+ radiusConsistency: 9,
109
127
  accessibility: 15,
110
128
  tokenization: 5,
129
+ cssHealth: 8,
111
130
  };
112
131
 
113
132
  let totalWeight = 0;
@@ -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
+ }
@@ -0,0 +1,47 @@
1
+ // Catalog of wide-gamut / modern color function usages from the crawler's
2
+ // scan of stylesheet cssText. Produces counts + sample raw values + converted
3
+ // sRGB hex for oklch/oklab values.
4
+
5
+ import { oklchLikeToHex } from '../utils/color-gamut.js';
6
+
7
+ export function extractWideGamut(modernColors) {
8
+ const src = Array.isArray(modernColors) ? modernColors : [];
9
+ const catalog = {
10
+ oklch: { count: 0, samples: [] },
11
+ oklab: { count: 0, samples: [] },
12
+ colorMix: { count: 0, samples: [] },
13
+ lightDark: { count: 0, samples: [] },
14
+ displayP3: { count: 0, samples: [] },
15
+ rec2020: { count: 0, samples: [] },
16
+ };
17
+
18
+ const bucket = {
19
+ oklch: catalog.oklch,
20
+ oklab: catalog.oklab,
21
+ 'color-mix': catalog.colorMix,
22
+ 'light-dark': catalog.lightDark,
23
+ 'display-p3': catalog.displayP3,
24
+ rec2020: catalog.rec2020,
25
+ };
26
+
27
+ for (const entry of src) {
28
+ const b = bucket[entry.type];
29
+ if (!b) continue;
30
+ b.count++;
31
+ if (b.samples.length < 10) {
32
+ const sample = {
33
+ raw: entry.raw,
34
+ property: entry.property || '',
35
+ selector: entry.selector || '',
36
+ };
37
+ if (entry.type === 'oklch' || entry.type === 'oklab') {
38
+ const hex = oklchLikeToHex(entry.raw);
39
+ if (hex) sample.value = hex;
40
+ }
41
+ b.samples.push(sample);
42
+ }
43
+ }
44
+
45
+ const totalCount = Object.values(catalog).reduce((n, c) => n + c.count, 0);
46
+ return { ...catalog, totalCount };
47
+ }
@@ -0,0 +1,160 @@
1
+ // Shared-vs-per-route reconciliation for multi-page token extraction.
2
+ // Input: [{ url, path, tokens: DTCG-shaped { primitive, semantic, ... } }]
3
+ // Output: { shared, perRoute: { <slug>: { added, changed } }, summary }
4
+ //
5
+ // "Shared" = a token path present with the same $value in ALL routes.
6
+ // "Added" = a token path present on this route but missing from the shared set.
7
+ // "Changed"= a token path present in shared but with a different $value here.
8
+
9
+ export function slugForPath(p) {
10
+ if (!p || p === '/' || p === '') return 'index';
11
+ return String(p)
12
+ .replace(/^\/+|\/+$/g, '')
13
+ .replace(/[^a-z0-9]+/gi, '-')
14
+ .replace(/^-+|-+$/g, '')
15
+ .toLowerCase() || 'index';
16
+ }
17
+
18
+ function isLeaf(node) {
19
+ return node && typeof node === 'object' && '$value' in node;
20
+ }
21
+
22
+ // Walk a DTCG tree and produce a flat map of dotted-path -> serialized $value.
23
+ function flattenTokens(tree, prefix = '', out = {}) {
24
+ if (!tree || typeof tree !== 'object') return out;
25
+ if (isLeaf(tree)) {
26
+ out[prefix] = JSON.stringify(tree.$value);
27
+ return out;
28
+ }
29
+ for (const [k, v] of Object.entries(tree)) {
30
+ if (k.startsWith('$')) continue;
31
+ const p = prefix ? `${prefix}.${k}` : k;
32
+ if (v && typeof v === 'object') flattenTokens(v, p, out);
33
+ }
34
+ return out;
35
+ }
36
+
37
+ // Set a dotted-path key to a (JSON-parsed) value on a nested object (leaf as DTCG $value).
38
+ function setPath(root, dotted, jsonVal) {
39
+ const parts = dotted.split('.');
40
+ let cur = root;
41
+ for (let i = 0; i < parts.length - 1; i++) {
42
+ const p = parts[i];
43
+ if (!cur[p] || typeof cur[p] !== 'object') cur[p] = {};
44
+ cur = cur[p];
45
+ }
46
+ cur[parts[parts.length - 1]] = { $value: JSON.parse(jsonVal) };
47
+ }
48
+
49
+ export function reconcileRoutes(routes) {
50
+ const safeRoutes = Array.isArray(routes) ? routes.filter(r => r && r.tokens) : [];
51
+ if (safeRoutes.length === 0) {
52
+ return {
53
+ shared: {},
54
+ perRoute: {},
55
+ summary: { routeCount: 0, sharedTokenCount: 0, totalUnique: 0, drift: [] },
56
+ };
57
+ }
58
+
59
+ // Flatten each route's tokens (combining primitive + semantic trees).
60
+ const flat = safeRoutes.map(r => {
61
+ const merged = {};
62
+ flattenTokens(r.tokens.primitive || {}, 'primitive', merged);
63
+ flattenTokens(r.tokens.semantic || {}, 'semantic', merged);
64
+ return { route: r, flat: merged };
65
+ });
66
+
67
+ // Build shared set: paths present across ALL routes with identical serialized value.
68
+ const firstKeys = Object.keys(flat[0].flat);
69
+ const sharedFlat = {};
70
+ for (const key of firstKeys) {
71
+ const v = flat[0].flat[key];
72
+ const allAgree = flat.every(f => f.flat[key] === v);
73
+ if (allAgree) sharedFlat[key] = v;
74
+ }
75
+
76
+ // Rebuild shared as DTCG tree.
77
+ const shared = {};
78
+ for (const [k, v] of Object.entries(sharedFlat)) setPath(shared, k, v);
79
+
80
+ // Per-route deltas.
81
+ const perRoute = {};
82
+ const usedSlugs = new Map();
83
+ const drift = [];
84
+ for (const { route, flat: f } of flat) {
85
+ let slug = slugForPath(route.path);
86
+ // Slug collision resolution.
87
+ if (usedSlugs.has(slug)) {
88
+ const n = usedSlugs.get(slug) + 1;
89
+ usedSlugs.set(slug, n);
90
+ slug = `${slug}-${n}`;
91
+ } else {
92
+ usedSlugs.set(slug, 1);
93
+ }
94
+
95
+ const added = {};
96
+ const changed = {};
97
+ for (const [k, v] of Object.entries(f)) {
98
+ if (!(k in sharedFlat)) {
99
+ // Not shared at all — either unique to this route or conflicts across routes.
100
+ // If the key exists in another route with a different value, it's a "changed" vs shared is not applicable;
101
+ // we classify as added when the token is absent from sharedFlat entirely.
102
+ const existsInOthers = flat.some(o => o.flat !== f && (k in o.flat));
103
+ if (!existsInOthers) {
104
+ setPath(added, k, v);
105
+ } else {
106
+ // Present in multiple routes but disagreeing values -> changed relative to the shared baseline.
107
+ setPath(changed, k, v);
108
+ drift.push({ path: route.path, token: k, value: JSON.parse(v) });
109
+ }
110
+ }
111
+ }
112
+ perRoute[slug] = { url: route.url, path: route.path, added, changed };
113
+ }
114
+
115
+ const allKeys = new Set();
116
+ for (const f of flat) for (const k of Object.keys(f.flat)) allKeys.add(k);
117
+
118
+ return {
119
+ shared,
120
+ perRoute,
121
+ summary: {
122
+ routeCount: safeRoutes.length,
123
+ sharedTokenCount: Object.keys(sharedFlat).length,
124
+ totalUnique: allKeys.size,
125
+ drift,
126
+ },
127
+ };
128
+ }
129
+
130
+ export function formatRoutesReport(reconciled) {
131
+ const { summary, perRoute } = reconciled;
132
+ const lines = [];
133
+ lines.push('# Multi-route Token Reconciliation');
134
+ lines.push('');
135
+ lines.push(`- Routes crawled: **${summary.routeCount}**`);
136
+ lines.push(`- Shared tokens: **${summary.sharedTokenCount}**`);
137
+ lines.push(`- Total unique tokens across routes: **${summary.totalUnique}**`);
138
+ lines.push(`- Cross-route drift entries: **${summary.drift.length}**`);
139
+ lines.push('');
140
+ lines.push('## Per-route contributions');
141
+ for (const [slug, entry] of Object.entries(perRoute)) {
142
+ const addedCount = countLeaves(entry.added);
143
+ const changedCount = countLeaves(entry.changed);
144
+ lines.push(`- \`${entry.path}\` (${slug}): ${addedCount} added, ${changedCount} changed`);
145
+ }
146
+ return lines.join('\n') + '\n';
147
+ }
148
+
149
+ function countLeaves(tree) {
150
+ let n = 0;
151
+ const walk = (node) => {
152
+ if (!node || typeof node !== 'object') return;
153
+ if (isLeaf(node)) { n++; return; }
154
+ for (const [k, v] of Object.entries(node)) {
155
+ if (!k.startsWith('$')) walk(v);
156
+ }
157
+ };
158
+ walk(tree);
159
+ return n;
160
+ }
package/src/index.js CHANGED
@@ -21,6 +21,11 @@ import { extractCssHealth } from './extractors/css-health.js';
21
21
  import { remediateFailingPairs } from './extractors/a11y-remediation.js';
22
22
  import { extractSemanticRegions } from './extractors/semantic-regions.js';
23
23
  import { clusterComponents } from './extractors/component-clusters.js';
24
+ import { extractModernCss } from './extractors/modern-css.js';
25
+ import { extractWideGamut } from './extractors/wide-gamut.js';
26
+ import { extractTokenSources } from './extractors/token-sources.js';
27
+ import { extractInteractionStates } from './extractors/interaction-states.js';
28
+ import { formatDtcgTokens } from './formatters/dtcg-tokens.js';
24
29
 
25
30
  function safeExtract(fn, ...args) {
26
31
  try { return fn(...args); } catch { return null; }
@@ -30,6 +35,7 @@ export async function extractDesignLanguage(url, options = {}) {
30
35
  const rawData = await crawlPage(url, {
31
36
  ...options,
32
37
  ignore: options.ignore,
38
+ deepInteract: options.deepInteract,
33
39
  });
34
40
  const styles = rawData.light.computedStyles;
35
41
  const warnings = [];
@@ -63,6 +69,10 @@ export async function extractDesignLanguage(url, options = {}) {
63
69
  cssHealth: safeExtract(extractCssHealth, rawData.light.cssCoverage) || null,
64
70
  regions: safeExtract(extractSemanticRegions, rawData.light.sections) || [],
65
71
  componentClusters: safeExtract(clusterComponents, rawData.light.componentCandidates) || [],
72
+ modernCss: safeExtract(extractModernCss, rawData) || { pseudoElements: { count: 0, samples: [] }, variableFonts: { count: 0, axes: [] }, openTypeFeatures: [], textWrap: { wrap: [], decorationStyle: [], decorationThickness: [], underlineOffset: [] }, containerQueries: { count: 0, rules: [] }, envUsage: [] },
73
+ wideGamut: safeExtract(extractWideGamut, rawData.light.modernColors || []) || { oklch: { count: 0, samples: [] }, oklab: { count: 0, samples: [] }, colorMix: { count: 0, samples: [] }, lightDark: { count: 0, samples: [] }, displayP3: { count: 0, samples: [] }, rec2020: { count: 0, samples: [] }, totalCount: 0 },
74
+ tokenSources: [],
75
+ interactionStates: safeExtract(extractInteractionStates, rawData.interactState || rawData.light.interactState) || { scrollSettled: false, menusOpened: 0, hover: { sampled: 0, changed: 0, deltas: [] }, accordionsOpened: 0, modals: [] },
66
76
  score: null,
67
77
  };
68
78
 
@@ -106,6 +116,25 @@ export async function extractDesignLanguage(url, options = {}) {
106
116
  };
107
117
  } catch { /* non-fatal */ }
108
118
 
119
+ design.tokenSources = safeExtract(extractTokenSources, design, styles) || [];
120
+
121
+ // Per-route token extraction (Tier 2 multi-page reconciliation).
122
+ if (Array.isArray(rawData.routes) && rawData.routes.length > 0) {
123
+ design.routes = rawData.routes.map(r => {
124
+ const rStyles = r.computedStylesSample || [];
125
+ const rDesign = {
126
+ meta: { url: r.url },
127
+ colors: safeExtract(extractColors, rStyles) || { all: [], neutrals: [], backgrounds: [], text: [], gradients: [] },
128
+ typography: safeExtract(extractTypography, rStyles) || { families: [], scale: [] },
129
+ spacing: safeExtract(extractSpacing, rStyles) || { scale: [], base: null },
130
+ shadows: safeExtract(extractShadows, rStyles) || { values: [] },
131
+ borders: safeExtract(extractBorders, rStyles) || { radii: [] },
132
+ };
133
+ const tokens = safeExtract(formatDtcgTokens, rDesign) || { primitive: {}, semantic: {} };
134
+ return { url: r.url, path: r.path, tokens };
135
+ });
136
+ }
137
+
109
138
  design.score = safeExtract(scoreDesignSystem, design);
110
139
  if (design.score === null) warnings.push('scoring failed');
111
140