designlang 8.0.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 +34 -0
- package/README.md +137 -13
- package/bin/design-extract.js +90 -1
- package/package.json +9 -3
- package/src/crawler.js +20 -0
- package/src/drift.js +137 -0
- package/src/extractors/component-anatomy.js +123 -0
- package/src/extractors/motion.js +184 -0
- 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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Motion v2 — rich motion language extraction.
|
|
2
|
+
// Classifies easings into semantic families, detects springs/bounces,
|
|
3
|
+
// catches scroll/view-timeline usage, and emits motion tokens (duration, easing, spring).
|
|
4
|
+
|
|
5
|
+
const MS = v => {
|
|
6
|
+
if (!v) return 0;
|
|
7
|
+
const m = String(v).match(/(-?\d+\.?\d*)(m?s)?/);
|
|
8
|
+
if (!m) return 0;
|
|
9
|
+
const n = parseFloat(m[1]);
|
|
10
|
+
return m[2] === 's' ? n * 1000 : n;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const DURATION_NAMES = [
|
|
14
|
+
{ max: 80, name: 'instant' },
|
|
15
|
+
{ max: 150, name: 'xs' },
|
|
16
|
+
{ max: 250, name: 'sm' },
|
|
17
|
+
{ max: 400, name: 'md' },
|
|
18
|
+
{ max: 700, name: 'lg' },
|
|
19
|
+
{ max: 1200, name: 'xl' },
|
|
20
|
+
{ max: Infinity, name: 'xxl' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function nameDuration(ms) {
|
|
24
|
+
return DURATION_NAMES.find(d => ms <= d.max).name;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function classifyCubicBezier(raw) {
|
|
28
|
+
const m = raw.match(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/);
|
|
29
|
+
if (!m) return { family: 'custom', raw };
|
|
30
|
+
const [x1, y1, x2, y2] = m.slice(1).map(Number);
|
|
31
|
+
if (y1 < 0 || y2 > 1 || y2 < 0 || y1 > 1) return { family: 'spring', raw, overshoot: true };
|
|
32
|
+
if (x1 < 0.2 && x2 > 0.8) return { family: 'ease-in-out', raw };
|
|
33
|
+
if (x1 < 0.2 && y1 < 0.2) return { family: 'ease-out', raw };
|
|
34
|
+
if (x2 > 0.8 && y2 > 0.8) return { family: 'ease-in', raw };
|
|
35
|
+
if (y1 < x1 && y2 < x2) return { family: 'ease-in', raw };
|
|
36
|
+
if (y1 > x1 && y2 > x2) return { family: 'ease-out', raw };
|
|
37
|
+
return { family: 'custom', raw };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function classifyEasing(raw) {
|
|
41
|
+
if (!raw) return { family: 'linear', raw };
|
|
42
|
+
if (raw === 'linear') return { family: 'linear', raw };
|
|
43
|
+
if (raw === 'ease' || raw === 'ease-in-out') return { family: 'ease-in-out', raw };
|
|
44
|
+
if (raw === 'ease-in') return { family: 'ease-in', raw };
|
|
45
|
+
if (raw === 'ease-out') return { family: 'ease-out', raw };
|
|
46
|
+
if (/cubic-bezier/.test(raw)) return classifyCubicBezier(raw);
|
|
47
|
+
if (/steps/.test(raw)) return { family: 'steps', raw };
|
|
48
|
+
return { family: 'custom', raw };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isBounceKeyframe(kf) {
|
|
52
|
+
if (!kf.steps || kf.steps.length < 3) return false;
|
|
53
|
+
const first = kf.steps.find(s => s.offset === '0%' || s.offset === 'from');
|
|
54
|
+
const last = kf.steps.find(s => s.offset === '100%' || s.offset === 'to');
|
|
55
|
+
if (!first || !last) return false;
|
|
56
|
+
return first.style.replace(/\s+/g, '') === last.style.replace(/\s+/g, '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function keyframeKind(kf) {
|
|
60
|
+
const props = new Set();
|
|
61
|
+
const values = [];
|
|
62
|
+
for (const step of kf.steps || []) {
|
|
63
|
+
for (const part of (step.style || '').split(';')) {
|
|
64
|
+
const [p, v] = part.split(':').map(s => (s || '').trim());
|
|
65
|
+
if (p) props.add(p);
|
|
66
|
+
if (v) values.push(v);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const has = p => props.has(p);
|
|
70
|
+
const anyValue = re => values.some(v => re.test(v));
|
|
71
|
+
if (has('transform') && anyValue(/translate/i)) {
|
|
72
|
+
if (anyValue(/translateY\(-?\d/)) return 'slide-y';
|
|
73
|
+
if (anyValue(/translateX\(-?\d/)) return 'slide-x';
|
|
74
|
+
return 'slide';
|
|
75
|
+
}
|
|
76
|
+
if (has('opacity') && !has('transform')) return 'fade';
|
|
77
|
+
if (has('opacity') && has('transform')) return 'reveal';
|
|
78
|
+
if (anyValue(/rotate/)) return 'rotate';
|
|
79
|
+
if (anyValue(/scale/)) return 'scale';
|
|
80
|
+
if (isBounceKeyframe(kf)) return 'pulse';
|
|
81
|
+
return 'custom';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function extractMotion(computedStyles, keyframes = []) {
|
|
85
|
+
const transitions = new Set();
|
|
86
|
+
const easingRaw = new Set();
|
|
87
|
+
const durations = [];
|
|
88
|
+
const animationRefs = new Map();
|
|
89
|
+
const transitionedProps = {};
|
|
90
|
+
const scrollSignals = new Set();
|
|
91
|
+
let animatingElements = 0;
|
|
92
|
+
|
|
93
|
+
for (const el of computedStyles) {
|
|
94
|
+
let isAnimating = false;
|
|
95
|
+
if (el.transition && el.transition !== 'all 0s ease 0s' && el.transition !== 'none') {
|
|
96
|
+
transitions.add(el.transition);
|
|
97
|
+
isAnimating = true;
|
|
98
|
+
for (const m of el.transition.matchAll(/(?<![(\d])(\d+\.?\d*m?s)(?![)\w])/g)) durations.push(MS(m[1]));
|
|
99
|
+
for (const m of el.transition.matchAll(/(ease|ease-in|ease-out|ease-in-out|linear|cubic-bezier\([^)]+\)|steps\([^)]+\))/g)) easingRaw.add(m[1]);
|
|
100
|
+
for (const part of el.transition.split(',')) {
|
|
101
|
+
const prop = part.trim().split(/\s+/)[0];
|
|
102
|
+
if (prop && prop !== 'all') transitionedProps[prop] = (transitionedProps[prop] || 0) + 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (el.animation && el.animation !== 'none 0s ease 0s 1 normal none running' && el.animation !== 'none') {
|
|
106
|
+
const nameMatch = el.animation.match(/([a-zA-Z_][\w-]*)\s*$/) || el.animation.match(/^([a-zA-Z_][\w-]*)/);
|
|
107
|
+
if (nameMatch) {
|
|
108
|
+
const name = nameMatch[1];
|
|
109
|
+
if (name !== 'none' && name !== 'running' && name !== 'paused') {
|
|
110
|
+
animationRefs.set(name, (animationRefs.get(name) || 0) + 1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
isAnimating = true;
|
|
114
|
+
}
|
|
115
|
+
if (el.animationTimeline && el.animationTimeline !== 'auto' && el.animationTimeline !== 'none' && el.animationTimeline !== '') {
|
|
116
|
+
scrollSignals.add(el.animationTimeline);
|
|
117
|
+
}
|
|
118
|
+
if (el.viewTimelineName) scrollSignals.add(`view:${el.viewTimelineName}`);
|
|
119
|
+
if (el.scrollTimelineName) scrollSignals.add(`scroll:${el.scrollTimelineName}`);
|
|
120
|
+
if (isAnimating) animatingElements++;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const uniqueDurations = [...new Set(durations.filter(d => d > 0))].sort((a, b) => a - b);
|
|
124
|
+
const durationTokens = uniqueDurations.map(ms => ({
|
|
125
|
+
name: nameDuration(ms),
|
|
126
|
+
ms,
|
|
127
|
+
css: ms >= 1000 ? `${ms / 1000}s` : `${ms}ms`,
|
|
128
|
+
}));
|
|
129
|
+
// dedupe by name — keep first (smallest) per bucket
|
|
130
|
+
const seenName = new Set();
|
|
131
|
+
const namedDurations = [];
|
|
132
|
+
for (const t of durationTokens) {
|
|
133
|
+
if (seenName.has(t.name)) continue;
|
|
134
|
+
seenName.add(t.name);
|
|
135
|
+
namedDurations.push(t);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const easings = [...easingRaw].map(e => ({ ...classifyEasing(e), count: computedStyles.filter(c => (c.transition || '').includes(e)).length }));
|
|
139
|
+
const springs = easings.filter(e => e.family === 'spring');
|
|
140
|
+
|
|
141
|
+
const enrichedKeyframes = (keyframes || []).map(kf => ({
|
|
142
|
+
name: kf.name,
|
|
143
|
+
steps: kf.steps,
|
|
144
|
+
kind: keyframeKind(kf),
|
|
145
|
+
isBounce: isBounceKeyframe(kf),
|
|
146
|
+
used: animationRefs.has(kf.name),
|
|
147
|
+
usageCount: animationRefs.get(kf.name) || 0,
|
|
148
|
+
propertiesAnimated: [...new Set((kf.steps || []).flatMap(s => (s.style || '').split(';').map(d => d.split(':')[0].trim()).filter(Boolean)))],
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
const transitionTop = Object.entries(transitionedProps).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([property, count]) => ({ property, count }));
|
|
152
|
+
|
|
153
|
+
// Motion language fingerprint — what does this site's motion *feel* like?
|
|
154
|
+
const totalUses = easings.reduce((s, e) => s + e.count, 0);
|
|
155
|
+
const share = family => easings.filter(e => e.family === family).reduce((s, e) => s + e.count, 0) / (totalUses || 1);
|
|
156
|
+
const feel = springs.length > 0
|
|
157
|
+
? 'springy'
|
|
158
|
+
: share('ease-out') > 0.5
|
|
159
|
+
? 'responsive'
|
|
160
|
+
: share('ease-in-out') > 0.5
|
|
161
|
+
? 'smooth'
|
|
162
|
+
: share('linear') > 0.5
|
|
163
|
+
? 'mechanical'
|
|
164
|
+
: 'mixed';
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
durations: namedDurations,
|
|
168
|
+
easings,
|
|
169
|
+
springs,
|
|
170
|
+
keyframes: enrichedKeyframes,
|
|
171
|
+
transitionedProperties: transitionTop,
|
|
172
|
+
scrollLinked: {
|
|
173
|
+
present: scrollSignals.size > 0,
|
|
174
|
+
signals: [...scrollSignals],
|
|
175
|
+
},
|
|
176
|
+
stats: {
|
|
177
|
+
animatingElements,
|
|
178
|
+
transitionCount: transitions.size,
|
|
179
|
+
keyframeCount: enrichedKeyframes.length,
|
|
180
|
+
keyframeUnused: enrichedKeyframes.filter(k => !k.used).length,
|
|
181
|
+
},
|
|
182
|
+
feel,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -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
|
+
}
|