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
|
@@ -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
|
-
//
|
|
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 <=
|
|
11
|
-
else if (colorCount <=
|
|
12
|
-
else if (colorCount <=
|
|
13
|
-
else if (colorCount <=
|
|
14
|
-
else
|
|
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,
|
|
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 <=
|
|
34
|
-
else if (scaleSize <=
|
|
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 =
|
|
45
|
-
else scores.spacingSystem =
|
|
52
|
+
else if (fitRatio >= 0.6) scores.spacingSystem = 85;
|
|
53
|
+
else scores.spacingSystem = 75;
|
|
46
54
|
} else {
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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 =
|
|
59
|
-
else if (shadowCount <=
|
|
60
|
-
else if (shadowCount <=
|
|
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 <=
|
|
66
|
-
else if (radiiCount <=
|
|
67
|
-
else if (radiiCount <=
|
|
68
|
-
else
|
|
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:
|
|
105
|
-
typographyConsistency:
|
|
106
|
-
spacingSystem:
|
|
107
|
-
shadowConsistency:
|
|
108
|
-
radiusConsistency:
|
|
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
|
|