designlang 7.2.0 → 9.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 +69 -0
- package/README.md +154 -13
- package/bin/design-extract.js +94 -1
- package/package.json +9 -3
- package/src/config.js +2 -0
- package/src/crawler.js +55 -6
- package/src/drift.js +137 -0
- package/src/extractors/accessibility.js +44 -1
- package/src/extractors/colors.js +50 -12
- package/src/extractors/component-anatomy.js +123 -0
- package/src/extractors/motion.js +184 -0
- package/src/extractors/scoring.js +49 -30
- package/src/extractors/voice.js +96 -0
- package/src/formatters/markdown.js +88 -0
- package/src/formatters/motion-tokens.js +22 -0
- package/src/index.js +14 -0
- package/src/lint.js +198 -0
- package/src/visual-diff.js +116 -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/.github/og-preview.png +0 -0
- package/.github/workflows/manavarya-bot.yml +0 -17
- 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/interaction-states.test.js +0 -62
- package/tests/mcp.test.js +0 -68
- package/tests/modern-css.test.js +0 -104
- package/tests/routes-reconciliation.test.js +0 -120
- package/tests/utils.test.js +0 -413
- package/tests/wide-gamut.test.js +0 -90
- 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 -399
- 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
|
@@ -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,96 @@
|
|
|
1
|
+
// Brand voice extraction — microcopy patterns from CTA verbs, headings, and section copy.
|
|
2
|
+
// Feeds LLMs the *tone*, not just the paint.
|
|
3
|
+
|
|
4
|
+
const IMPERATIVE_VERBS = new Set([
|
|
5
|
+
'get', 'start', 'try', 'build', 'create', 'ship', 'deploy', 'launch', 'learn', 'read',
|
|
6
|
+
'buy', 'shop', 'explore', 'discover', 'join', 'sign', 'subscribe', 'book', 'download',
|
|
7
|
+
'install', 'contact', 'talk', 'request', 'view', 'see', 'watch', 'read', 'meet',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
const FRIENDLY_MARKERS = /\b(we|our|us|you|your|let's|hey|welcome|thanks|love|yay|awesome)\b/i;
|
|
11
|
+
const FORMAL_MARKERS = /\b(enterprise|platform|solution|infrastructure|comprehensive|leverage|empower|facilitate|utilize)\b/i;
|
|
12
|
+
const TECHNICAL_MARKERS = /\b(api|sdk|webhook|latency|throughput|cli|runtime|schema|endpoint|token|typescript|kubernetes)\b/i;
|
|
13
|
+
const PLAYFUL_MARKERS = /[!?]{1,2}$|\b(magic|wow|boom|zap|rocket|sparkle|fire)\b|[✨🚀🔥⚡💫]/i;
|
|
14
|
+
|
|
15
|
+
function words(str) {
|
|
16
|
+
return (str || '').toLowerCase().match(/[a-z']+/g) || [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function firstVerb(text) {
|
|
20
|
+
const w = words(text);
|
|
21
|
+
for (const word of w) {
|
|
22
|
+
if (IMPERATIVE_VERBS.has(word)) return word;
|
|
23
|
+
}
|
|
24
|
+
return w[0] || '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function scoreTone(texts) {
|
|
28
|
+
const joined = texts.join(' ');
|
|
29
|
+
return {
|
|
30
|
+
friendly: (joined.match(FRIENDLY_MARKERS) || []).length,
|
|
31
|
+
formal: (joined.match(FORMAL_MARKERS) || []).length,
|
|
32
|
+
technical: (joined.match(TECHNICAL_MARKERS) || []).length,
|
|
33
|
+
playful: (joined.match(PLAYFUL_MARKERS) || []).length,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pickTone(scores) {
|
|
38
|
+
const entries = Object.entries(scores);
|
|
39
|
+
const total = entries.reduce((s, [, n]) => s + n, 0);
|
|
40
|
+
if (total === 0) return 'neutral';
|
|
41
|
+
const [top] = entries.sort((a, b) => b[1] - a[1]);
|
|
42
|
+
return top[1] / total > 0.4 ? top[0] : 'neutral';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function topN(arr, n = 10) {
|
|
46
|
+
const counts = {};
|
|
47
|
+
for (const v of arr) if (v) counts[v] = (counts[v] || 0) + 1;
|
|
48
|
+
return Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, n).map(([value, count]) => ({ value, count }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function avgLen(arr) {
|
|
52
|
+
if (!arr.length) return 0;
|
|
53
|
+
return Math.round(arr.reduce((s, v) => s + v.length, 0) / arr.length);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function extractVoice({ componentCandidates = [], sections = [] } = {}) {
|
|
57
|
+
const buttons = componentCandidates.filter(c => c.kind === 'button' || c.kind === 'link').map(c => c.text).filter(Boolean);
|
|
58
|
+
const headings = sections.flatMap(s => s.headings || []).filter(Boolean);
|
|
59
|
+
const sectionTexts = sections.map(s => s.text || '').filter(Boolean);
|
|
60
|
+
|
|
61
|
+
const ctaVerbs = buttons.map(firstVerb).filter(Boolean);
|
|
62
|
+
const buttonPatterns = topN(buttons.map(b => b.toLowerCase().trim()), 15);
|
|
63
|
+
const ctaTopVerbs = topN(ctaVerbs, 10);
|
|
64
|
+
|
|
65
|
+
const tone = pickTone(scoreTone([...buttons, ...headings, ...sectionTexts]));
|
|
66
|
+
const personPronoun = /\byou\b/i.test(headings.join(' ') + sectionTexts.join(' '))
|
|
67
|
+
? (/\bwe\b/i.test(headings.join(' ')) ? 'we→you' : 'you-only')
|
|
68
|
+
: (/\bwe\b/i.test(headings.join(' ')) ? 'we-only' : 'third-person');
|
|
69
|
+
|
|
70
|
+
const headingStyle = (() => {
|
|
71
|
+
if (!headings.length) return 'unknown';
|
|
72
|
+
const titleCase = headings.filter(h => /^[A-Z]/.test(h) && /\s[A-Z]/.test(h)).length;
|
|
73
|
+
const sentenceCase = headings.filter(h => /^[A-Z]/.test(h) && !/\s[A-Z]/.test(h)).length;
|
|
74
|
+
const allLower = headings.filter(h => h === h.toLowerCase()).length;
|
|
75
|
+
if (allLower > headings.length / 2) return 'all-lowercase';
|
|
76
|
+
if (titleCase > sentenceCase) return 'Title Case';
|
|
77
|
+
return 'Sentence case';
|
|
78
|
+
})();
|
|
79
|
+
|
|
80
|
+
const avgHeadingWords = avgLen(headings.map(h => words(h))) / Math.max(avgLen(headings) || 1, 1) * (avgLen(headings) || 1);
|
|
81
|
+
const headingLengthClass = avgHeadingWords <= 4 ? 'tight' : avgHeadingWords <= 8 ? 'balanced' : 'verbose';
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
tone,
|
|
85
|
+
pronoun: personPronoun,
|
|
86
|
+
headingStyle,
|
|
87
|
+
headingLengthClass,
|
|
88
|
+
ctaVerbs: ctaTopVerbs,
|
|
89
|
+
buttonPatterns,
|
|
90
|
+
sampleHeadings: headings.slice(0, 10),
|
|
91
|
+
stats: {
|
|
92
|
+
buttons: buttons.length,
|
|
93
|
+
headings: headings.length,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -623,6 +623,94 @@ export function formatMarkdown(design) {
|
|
|
623
623
|
}
|
|
624
624
|
}
|
|
625
625
|
|
|
626
|
+
// ── Motion Language (v9) ──
|
|
627
|
+
if (design.motion && (design.motion.durations?.length || design.motion.keyframes?.length)) {
|
|
628
|
+
lines.push('## Motion Language');
|
|
629
|
+
lines.push('');
|
|
630
|
+
lines.push(`**Feel:** ${design.motion.feel} · **Scroll-linked:** ${design.motion.scrollLinked?.present ? 'yes' : 'no'}`);
|
|
631
|
+
lines.push('');
|
|
632
|
+
if (design.motion.durations?.length) {
|
|
633
|
+
lines.push('### Duration Tokens');
|
|
634
|
+
lines.push('');
|
|
635
|
+
lines.push('| name | value | ms |');
|
|
636
|
+
lines.push('|---|---|---|');
|
|
637
|
+
for (const d of design.motion.durations) lines.push(`| \`${d.name}\` | \`${d.css}\` | ${d.ms} |`);
|
|
638
|
+
lines.push('');
|
|
639
|
+
}
|
|
640
|
+
if (design.motion.easings?.length) {
|
|
641
|
+
lines.push('### Easing Families');
|
|
642
|
+
lines.push('');
|
|
643
|
+
const byFamily = {};
|
|
644
|
+
for (const e of design.motion.easings) (byFamily[e.family] ||= []).push(e);
|
|
645
|
+
for (const [family, list] of Object.entries(byFamily)) {
|
|
646
|
+
lines.push(`- **${family}** (${list.reduce((s, e) => s + (e.count || 0), 0)} uses) — \`${list.map(e => e.raw).slice(0, 3).join('`, `')}\``);
|
|
647
|
+
}
|
|
648
|
+
lines.push('');
|
|
649
|
+
}
|
|
650
|
+
if (design.motion.springs?.length) {
|
|
651
|
+
lines.push('### Spring / Overshoot Easings');
|
|
652
|
+
lines.push('');
|
|
653
|
+
for (const s of design.motion.springs) lines.push(`- \`${s.raw}\``);
|
|
654
|
+
lines.push('');
|
|
655
|
+
}
|
|
656
|
+
const usedKf = (design.motion.keyframes || []).filter(k => k.used);
|
|
657
|
+
if (usedKf.length) {
|
|
658
|
+
lines.push('### Keyframes In Use');
|
|
659
|
+
lines.push('');
|
|
660
|
+
lines.push('| name | kind | properties | uses |');
|
|
661
|
+
lines.push('|---|---|---|---|');
|
|
662
|
+
for (const k of usedKf.slice(0, 20)) lines.push(`| \`${k.name}\` | ${k.kind} | ${k.propertiesAnimated.slice(0, 4).join(', ')} | ${k.usageCount} |`);
|
|
663
|
+
lines.push('');
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── Component Anatomy (v9) ──
|
|
668
|
+
if ((design.componentAnatomy || []).length) {
|
|
669
|
+
lines.push('## Component Anatomy');
|
|
670
|
+
lines.push('');
|
|
671
|
+
for (const a of design.componentAnatomy.slice(0, 6)) {
|
|
672
|
+
lines.push(`### ${a.kind} — ${a.totalInstances} instance${a.totalInstances === 1 ? '' : 's'}`);
|
|
673
|
+
lines.push('');
|
|
674
|
+
const slots = Object.entries(a.slots).filter(([, v]) => v).map(([k]) => k);
|
|
675
|
+
if (slots.length) lines.push(`**Slots:** ${slots.join(', ')}`);
|
|
676
|
+
if (a.props.variant.length) lines.push(`**Variants:** ${a.props.variant.join(' · ')}`);
|
|
677
|
+
if (a.props.size.length) lines.push(`**Sizes:** ${a.props.size.join(' · ')}`);
|
|
678
|
+
lines.push('');
|
|
679
|
+
if (a.variants.length > 1) {
|
|
680
|
+
lines.push('| variant | count | sample label |');
|
|
681
|
+
lines.push('|---|---|---|');
|
|
682
|
+
for (const v of a.variants.slice(0, 8)) lines.push(`| ${v.name} | ${v.count} | ${(v.sampleText[0] || '').slice(0, 40)} |`);
|
|
683
|
+
lines.push('');
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ── Brand Voice (v9) ──
|
|
689
|
+
if (design.voice && (design.voice.ctaVerbs?.length || design.voice.sampleHeadings?.length)) {
|
|
690
|
+
lines.push('## Brand Voice');
|
|
691
|
+
lines.push('');
|
|
692
|
+
lines.push(`**Tone:** ${design.voice.tone} · **Pronoun:** ${design.voice.pronoun} · **Headings:** ${design.voice.headingStyle} (${design.voice.headingLengthClass})`);
|
|
693
|
+
lines.push('');
|
|
694
|
+
if (design.voice.ctaVerbs?.length) {
|
|
695
|
+
lines.push('### Top CTA Verbs');
|
|
696
|
+
lines.push('');
|
|
697
|
+
for (const v of design.voice.ctaVerbs.slice(0, 8)) lines.push(`- **${v.value}** (${v.count})`);
|
|
698
|
+
lines.push('');
|
|
699
|
+
}
|
|
700
|
+
if (design.voice.buttonPatterns?.length) {
|
|
701
|
+
lines.push('### Button Copy Patterns');
|
|
702
|
+
lines.push('');
|
|
703
|
+
for (const p of design.voice.buttonPatterns.slice(0, 10)) lines.push(`- "${p.value}" (${p.count}×)`);
|
|
704
|
+
lines.push('');
|
|
705
|
+
}
|
|
706
|
+
if (design.voice.sampleHeadings?.length) {
|
|
707
|
+
lines.push('### Sample Headings');
|
|
708
|
+
lines.push('');
|
|
709
|
+
for (const h of design.voice.sampleHeadings) lines.push(`> ${h}`);
|
|
710
|
+
lines.push('');
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
626
714
|
// ── Quick Start ──
|
|
627
715
|
lines.push('## Quick Start');
|
|
628
716
|
lines.push('');
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Emits a DTCG-flavored motion tokens JSON.
|
|
2
|
+
export function formatMotionTokens(motion) {
|
|
3
|
+
if (!motion) return '{}';
|
|
4
|
+
const out = {
|
|
5
|
+
$description: 'Motion tokens extracted by designlang',
|
|
6
|
+
duration: {},
|
|
7
|
+
easing: {},
|
|
8
|
+
spring: {},
|
|
9
|
+
};
|
|
10
|
+
for (const d of motion.durations || []) {
|
|
11
|
+
out.duration[d.name] = { $value: d.css, $type: 'duration', ms: d.ms };
|
|
12
|
+
}
|
|
13
|
+
for (const e of motion.easings || []) {
|
|
14
|
+
const slug = e.family + (e.raw.includes('cubic-bezier') ? `-${Math.abs(e.raw.split('').reduce((a, c) => a + c.charCodeAt(0), 0)) % 1000}` : '');
|
|
15
|
+
out.easing[slug] = { $value: e.raw, $type: 'cubicBezier', family: e.family };
|
|
16
|
+
}
|
|
17
|
+
(motion.springs || []).forEach((s, i) => {
|
|
18
|
+
out.spring[`spring-${i + 1}`] = { $value: s.raw, $type: 'cubicBezier', overshoot: true };
|
|
19
|
+
});
|
|
20
|
+
out.$meta = { feel: motion.feel, scrollLinked: !!motion.scrollLinked?.present };
|
|
21
|
+
return JSON.stringify(out, null, 2);
|
|
22
|
+
}
|
package/src/index.js
CHANGED
|
@@ -25,7 +25,11 @@ import { extractModernCss } from './extractors/modern-css.js';
|
|
|
25
25
|
import { extractWideGamut } from './extractors/wide-gamut.js';
|
|
26
26
|
import { extractTokenSources } from './extractors/token-sources.js';
|
|
27
27
|
import { extractInteractionStates } from './extractors/interaction-states.js';
|
|
28
|
+
import { extractMotion } from './extractors/motion.js';
|
|
29
|
+
import { extractComponentAnatomy, formatAnatomyStubs } from './extractors/component-anatomy.js';
|
|
30
|
+
import { extractVoice } from './extractors/voice.js';
|
|
28
31
|
import { formatDtcgTokens } from './formatters/dtcg-tokens.js';
|
|
32
|
+
import { formatMotionTokens } from './formatters/motion-tokens.js';
|
|
29
33
|
|
|
30
34
|
function safeExtract(fn, ...args) {
|
|
31
35
|
try { return fn(...args); } catch { return null; }
|
|
@@ -73,6 +77,9 @@ export async function extractDesignLanguage(url, options = {}) {
|
|
|
73
77
|
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
78
|
tokenSources: [],
|
|
75
79
|
interactionStates: safeExtract(extractInteractionStates, rawData.interactState || rawData.light.interactState) || { scrollSettled: false, menusOpened: 0, hover: { sampled: 0, changed: 0, deltas: [] }, accordionsOpened: 0, modals: [] },
|
|
80
|
+
motion: safeExtract(extractMotion, styles, rawData.light.keyframes) || { durations: [], easings: [], springs: [], keyframes: [], scrollLinked: { present: false, signals: [] }, stats: {}, feel: 'unknown' },
|
|
81
|
+
componentAnatomy: safeExtract(extractComponentAnatomy, rawData.light.componentCandidates) || [],
|
|
82
|
+
voice: safeExtract(extractVoice, { componentCandidates: rawData.light.componentCandidates, sections: rawData.light.sections }) || { tone: 'neutral', ctaVerbs: [], buttonPatterns: [], sampleHeadings: [] },
|
|
76
83
|
score: null,
|
|
77
84
|
};
|
|
78
85
|
|
|
@@ -165,3 +172,10 @@ export { watchSite } from './watch.js';
|
|
|
165
172
|
export { diffDarkMode } from './darkdiff.js';
|
|
166
173
|
export { applyDesign } from './apply.js';
|
|
167
174
|
export { loadConfig, mergeConfig } from './config.js';
|
|
175
|
+
export { extractMotion } from './extractors/motion.js';
|
|
176
|
+
export { formatMotionTokens } from './formatters/motion-tokens.js';
|
|
177
|
+
export { extractComponentAnatomy, formatAnatomyStubs } from './extractors/component-anatomy.js';
|
|
178
|
+
export { extractVoice } from './extractors/voice.js';
|
|
179
|
+
export { lintTokens } from './lint.js';
|
|
180
|
+
export { checkDrift, formatDriftMarkdown } from './drift.js';
|
|
181
|
+
export { visualDiff, formatVisualDiffHtml } from './visual-diff.js';
|
package/src/lint.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// designlang lint — audit a local token file for the same issues scoring flags on live sites.
|
|
2
|
+
// Supports DTCG, flat design-tokens.json, Tailwind config (partial), and CSS variable files.
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
5
|
+
import { extname } from 'path';
|
|
6
|
+
|
|
7
|
+
function hexToRgb(hex) {
|
|
8
|
+
const m = hex.replace('#', '').match(/^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
|
|
9
|
+
if (!m) return null;
|
|
10
|
+
return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function relLum({ r, g, b }) {
|
|
14
|
+
const f = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
|
|
15
|
+
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function contrast(a, b) {
|
|
19
|
+
const la = relLum(a), lb = relLum(b);
|
|
20
|
+
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
|
|
21
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function flattenDtcg(obj, prefix = '', out = []) {
|
|
25
|
+
for (const [k, v] of Object.entries(obj || {})) {
|
|
26
|
+
if (k.startsWith('$')) continue;
|
|
27
|
+
if (v && typeof v === 'object') {
|
|
28
|
+
if ('$value' in v) {
|
|
29
|
+
out.push({ name: prefix ? `${prefix}.${k}` : k, value: v.$value, type: v.$type });
|
|
30
|
+
} else {
|
|
31
|
+
flattenDtcg(v, prefix ? `${prefix}.${k}` : k, out);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadTokens(file) {
|
|
39
|
+
const raw = readFileSync(file, 'utf8');
|
|
40
|
+
const ext = extname(file);
|
|
41
|
+
if (ext === '.json') {
|
|
42
|
+
const json = JSON.parse(raw);
|
|
43
|
+
const flat = flattenDtcg(json);
|
|
44
|
+
if (flat.length) return flat;
|
|
45
|
+
// flat fallback: { colors: { primary: '#000' }, ... }
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const [group, entries] of Object.entries(json)) {
|
|
48
|
+
if (!entries || typeof entries !== 'object') continue;
|
|
49
|
+
for (const [k, v] of Object.entries(entries)) {
|
|
50
|
+
out.push({ name: `${group}.${k}`, value: String(v), type: group.replace(/s$/, '') });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
if (ext === '.css') {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const m of raw.matchAll(/--([\w-]+):\s*([^;]+);/g)) {
|
|
58
|
+
out.push({ name: m[1], value: m[2].trim(), type: 'unknown' });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`Unsupported file type: ${ext}. Use .json or .css.`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isColor(t) { return t.type === 'color' || /^#[\da-f]{3,8}$/i.test(t.value) || /^rgb/i.test(t.value); }
|
|
66
|
+
function toHex(v) {
|
|
67
|
+
const m = v.match(/^#([a-f\d]{3,8})$/i);
|
|
68
|
+
if (m) {
|
|
69
|
+
const h = m[1];
|
|
70
|
+
if (h.length === 3) return '#' + h.split('').map(c => c + c).join('');
|
|
71
|
+
return '#' + h.slice(0, 6);
|
|
72
|
+
}
|
|
73
|
+
const rgb = v.match(/rgba?\(\s*(\d+)\s*,?\s*(\d+)\s*,?\s*(\d+)/i);
|
|
74
|
+
if (rgb) return '#' + [rgb[1], rgb[2], rgb[3]].map(n => (+n).toString(16).padStart(2, '0')).join('');
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function dedupeClose(hexes, threshold = 8) {
|
|
79
|
+
const near = [];
|
|
80
|
+
for (let i = 0; i < hexes.length; i++) {
|
|
81
|
+
for (let j = i + 1; j < hexes.length; j++) {
|
|
82
|
+
const a = hexToRgb(hexes[i].value), b = hexToRgb(hexes[j].value);
|
|
83
|
+
if (!a || !b) continue;
|
|
84
|
+
const dist = Math.sqrt((a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2);
|
|
85
|
+
if (dist > 0 && dist < threshold) near.push({ a: hexes[i].name, b: hexes[j].name, distance: Math.round(dist) });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return near;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parsePx(v) {
|
|
92
|
+
const m = String(v).match(/^(-?\d*\.?\d+)px$/);
|
|
93
|
+
return m ? parseFloat(m[1]) : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function checkScale(values, label) {
|
|
97
|
+
const nums = values.map(parsePx).filter(n => n !== null && n >= 0).sort((a, b) => a - b);
|
|
98
|
+
if (nums.length < 3) return null;
|
|
99
|
+
const gaps = [];
|
|
100
|
+
for (let i = 1; i < nums.length; i++) gaps.push(nums[i] - nums[i - 1]);
|
|
101
|
+
// Heuristic: ratio wildly inconsistent?
|
|
102
|
+
const ratios = [];
|
|
103
|
+
for (let i = 1; i < nums.length; i++) if (nums[i - 1] > 0) ratios.push(nums[i] / nums[i - 1]);
|
|
104
|
+
const avg = ratios.reduce((s, r) => s + r, 0) / (ratios.length || 1);
|
|
105
|
+
const variance = ratios.reduce((s, r) => s + (r - avg) ** 2, 0) / (ratios.length || 1);
|
|
106
|
+
return { label, count: nums.length, values: nums, avgRatio: +avg.toFixed(2), variance: +variance.toFixed(3) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function lintTokens(file) {
|
|
110
|
+
const tokens = loadTokens(file);
|
|
111
|
+
const findings = [];
|
|
112
|
+
const scorecard = {};
|
|
113
|
+
|
|
114
|
+
const colors = tokens.filter(isColor).map(t => ({ name: t.name, value: toHex(t.value) || t.value })).filter(t => /^#[\da-f]{6}$/i.test(t.value));
|
|
115
|
+
const neighbors = dedupeClose(colors);
|
|
116
|
+
if (neighbors.length) {
|
|
117
|
+
findings.push({
|
|
118
|
+
severity: 'warn',
|
|
119
|
+
rule: 'color-sprawl',
|
|
120
|
+
message: `${neighbors.length} near-duplicate color pair(s) within 8 RGB units`,
|
|
121
|
+
detail: neighbors.slice(0, 5),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
scorecard.colorDiscipline = Math.max(0, 100 - neighbors.length * 8);
|
|
125
|
+
|
|
126
|
+
const spacing = tokens.filter(t => /spacing|space|gap|size/i.test(t.name));
|
|
127
|
+
const spaceCheck = checkScale(spacing.map(t => t.value), 'spacing');
|
|
128
|
+
if (spaceCheck && spaceCheck.variance > 0.25) {
|
|
129
|
+
findings.push({
|
|
130
|
+
severity: 'warn',
|
|
131
|
+
rule: 'spacing-scale-inconsistent',
|
|
132
|
+
message: `Spacing scale ratios vary (variance ${spaceCheck.variance}). Consider a consistent ratio (1.5x, 2x).`,
|
|
133
|
+
detail: spaceCheck,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
scorecard.spacingSystem = spaceCheck ? Math.max(30, 100 - Math.round(spaceCheck.variance * 200)) : 50;
|
|
137
|
+
|
|
138
|
+
const radii = tokens.filter(t => /radius|radii/i.test(t.name));
|
|
139
|
+
if (radii.length > 8) {
|
|
140
|
+
findings.push({
|
|
141
|
+
severity: 'warn',
|
|
142
|
+
rule: 'radius-sprawl',
|
|
143
|
+
message: `${radii.length} radius tokens — consider collapsing to 4-6.`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
scorecard.borderRadii = Math.max(30, 100 - Math.max(0, radii.length - 6) * 10);
|
|
147
|
+
|
|
148
|
+
const shadows = tokens.filter(t => /shadow|elevation/i.test(t.name));
|
|
149
|
+
if (shadows.length > 10) {
|
|
150
|
+
findings.push({
|
|
151
|
+
severity: 'info',
|
|
152
|
+
rule: 'shadow-sprawl',
|
|
153
|
+
message: `${shadows.length} shadow tokens — rarely need more than 6 elevation levels.`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
scorecard.shadows = Math.max(30, 100 - Math.max(0, shadows.length - 6) * 8);
|
|
157
|
+
|
|
158
|
+
// Contrast: try to find text/bg pairs by name.
|
|
159
|
+
const palette = colors;
|
|
160
|
+
const bgs = palette.filter(c => /bg|background|surface/i.test(c.name));
|
|
161
|
+
const fgs = palette.filter(c => /fg|text|foreground|ink|label/i.test(c.name));
|
|
162
|
+
const contrastFails = [];
|
|
163
|
+
for (const fg of fgs) {
|
|
164
|
+
for (const bg of bgs) {
|
|
165
|
+
const fgRgb = hexToRgb(fg.value), bgRgb = hexToRgb(bg.value);
|
|
166
|
+
if (!fgRgb || !bgRgb) continue;
|
|
167
|
+
const c = contrast(fgRgb, bgRgb);
|
|
168
|
+
if (c < 4.5) contrastFails.push({ fg: fg.name, bg: bg.name, ratio: +c.toFixed(2) });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (contrastFails.length) {
|
|
172
|
+
findings.push({
|
|
173
|
+
severity: 'error',
|
|
174
|
+
rule: 'contrast-wcag-aa',
|
|
175
|
+
message: `${contrastFails.length} fg/bg pair(s) fail WCAG AA (4.5:1)`,
|
|
176
|
+
detail: contrastFails.slice(0, 10),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
scorecard.accessibility = contrastFails.length ? Math.max(20, 100 - contrastFails.length * 12) : 100;
|
|
180
|
+
|
|
181
|
+
const avg = Math.round(Object.values(scorecard).reduce((s, v) => s + v, 0) / Object.keys(scorecard).length);
|
|
182
|
+
const grade = avg >= 90 ? 'A' : avg >= 80 ? 'B' : avg >= 70 ? 'C' : avg >= 60 ? 'D' : 'F';
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
file,
|
|
186
|
+
tokenCount: tokens.length,
|
|
187
|
+
score: avg,
|
|
188
|
+
grade,
|
|
189
|
+
scorecard,
|
|
190
|
+
findings,
|
|
191
|
+
stats: {
|
|
192
|
+
colors: colors.length,
|
|
193
|
+
spacing: spacing.length,
|
|
194
|
+
radii: radii.length,
|
|
195
|
+
shadows: shadows.length,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|