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.
Files changed (90) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +154 -13
  3. package/bin/design-extract.js +94 -1
  4. package/package.json +9 -3
  5. package/src/config.js +2 -0
  6. package/src/crawler.js +55 -6
  7. package/src/drift.js +137 -0
  8. package/src/extractors/accessibility.js +44 -1
  9. package/src/extractors/colors.js +50 -12
  10. package/src/extractors/component-anatomy.js +123 -0
  11. package/src/extractors/motion.js +184 -0
  12. package/src/extractors/scoring.js +49 -30
  13. package/src/extractors/voice.js +96 -0
  14. package/src/formatters/markdown.js +88 -0
  15. package/src/formatters/motion-tokens.js +22 -0
  16. package/src/index.js +14 -0
  17. package/src/lint.js +198 -0
  18. package/src/visual-diff.js +116 -0
  19. package/.github/FUNDING.yml +0 -1
  20. package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -62
  21. package/.github/ISSUE_TEMPLATE/config.yml +0 -8
  22. package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -28
  23. package/.github/og-preview.png +0 -0
  24. package/.github/workflows/manavarya-bot.yml +0 -17
  25. package/chrome-extension/README.md +0 -41
  26. package/chrome-extension/icons/favicon.svg +0 -7
  27. package/chrome-extension/icons/icon-128.png +0 -0
  28. package/chrome-extension/icons/icon-16.png +0 -0
  29. package/chrome-extension/icons/icon-32.png +0 -0
  30. package/chrome-extension/icons/icon-48.png +0 -0
  31. package/chrome-extension/manifest.json +0 -26
  32. package/chrome-extension/popup.html +0 -167
  33. package/chrome-extension/popup.js +0 -59
  34. package/docs/superpowers/plans/2026-04-18-designlang-v7.md +0 -1121
  35. package/docs/superpowers/specs/2026-04-18-designlang-v7-design.md +0 -150
  36. package/docs/superpowers/specs/2026-04-18-website-redesign-design.md +0 -120
  37. package/docs/superpowers/specs/2026-04-19-designlang-v7-1-design.md +0 -111
  38. package/tests/cli.test.js +0 -84
  39. package/tests/cookies.test.js +0 -98
  40. package/tests/extractors.test.js +0 -792
  41. package/tests/formatters.test.js +0 -709
  42. package/tests/interaction-states.test.js +0 -62
  43. package/tests/mcp.test.js +0 -68
  44. package/tests/modern-css.test.js +0 -104
  45. package/tests/routes-reconciliation.test.js +0 -120
  46. package/tests/utils.test.js +0 -413
  47. package/tests/wide-gamut.test.js +0 -90
  48. package/website/.claude/launch.json +0 -11
  49. package/website/AGENTS.md +0 -5
  50. package/website/CLAUDE.md +0 -1
  51. package/website/README.md +0 -36
  52. package/website/app/api/extract/route.js +0 -245
  53. package/website/app/components/A11ySlider.js +0 -369
  54. package/website/app/components/Comparison.js +0 -286
  55. package/website/app/components/CssHealth.js +0 -243
  56. package/website/app/components/Extractor.js +0 -184
  57. package/website/app/components/HeroExtractor.js +0 -455
  58. package/website/app/components/Marginalia.js +0 -3
  59. package/website/app/components/McpSection.js +0 -223
  60. package/website/app/components/PlatformTabs.js +0 -250
  61. package/website/app/components/RegionsComponents.js +0 -429
  62. package/website/app/components/Rule.js +0 -13
  63. package/website/app/components/Specimens.js +0 -237
  64. package/website/app/components/StructuredData.js +0 -144
  65. package/website/app/components/TokenBrowser.js +0 -344
  66. package/website/app/components/token-browser-sample.js +0 -65
  67. package/website/app/globals.css +0 -505
  68. package/website/app/icon.svg +0 -7
  69. package/website/app/layout.js +0 -126
  70. package/website/app/opengraph-image.js +0 -170
  71. package/website/app/page.js +0 -399
  72. package/website/app/robots.js +0 -15
  73. package/website/app/seo-config.js +0 -82
  74. package/website/app/sitemap.js +0 -18
  75. package/website/jsconfig.json +0 -7
  76. package/website/lib/cache.js +0 -73
  77. package/website/lib/rate-limit.js +0 -30
  78. package/website/lib/rate-limit.test.js +0 -55
  79. package/website/lib/specimens.json +0 -86
  80. package/website/lib/token-helpers.js +0 -70
  81. package/website/lib/url-safety.js +0 -103
  82. package/website/lib/url-safety.test.js +0 -116
  83. package/website/lib/zip-files.js +0 -15
  84. package/website/next.config.mjs +0 -15
  85. package/website/package-lock.json +0 -1353
  86. package/website/package.json +0 -19
  87. package/website/public/favicon.svg +0 -7
  88. package/website/public/logo-specimen.svg +0 -76
  89. package/website/public/mark.svg +0 -12
  90. package/website/public/site.webmanifest +0 -13
package/src/drift.js ADDED
@@ -0,0 +1,137 @@
1
+ // designlang drift <url> --tokens <file>
2
+ // Compares local project tokens against the live site and reports what's drifted.
3
+ // Designed for CI/CD: exits non-zero when drift exceeds the tolerance budget.
4
+
5
+ import { readFileSync } from 'fs';
6
+ import { extname } from 'path';
7
+ import { extractDesignLanguage } from './index.js';
8
+
9
+ function flattenDtcg(obj, prefix = '', out = {}) {
10
+ for (const [k, v] of Object.entries(obj || {})) {
11
+ if (k.startsWith('$')) continue;
12
+ if (v && typeof v === 'object') {
13
+ if ('$value' in v) {
14
+ out[prefix ? `${prefix}.${k}` : k] = v.$value;
15
+ } else {
16
+ flattenDtcg(v, prefix ? `${prefix}.${k}` : k, out);
17
+ }
18
+ }
19
+ }
20
+ return out;
21
+ }
22
+
23
+ function loadLocalTokens(file) {
24
+ const raw = readFileSync(file, 'utf8');
25
+ const ext = extname(file);
26
+ if (ext === '.json') {
27
+ const j = JSON.parse(raw);
28
+ const flat = flattenDtcg(j);
29
+ if (Object.keys(flat).length) return flat;
30
+ const out = {};
31
+ for (const [group, entries] of Object.entries(j)) {
32
+ if (!entries || typeof entries !== 'object') continue;
33
+ for (const [k, v] of Object.entries(entries)) out[`${group}.${k}`] = String(v);
34
+ }
35
+ return out;
36
+ }
37
+ if (ext === '.css') {
38
+ const out = {};
39
+ for (const m of raw.matchAll(/--([\w-]+):\s*([^;]+);/g)) out[m[1]] = m[2].trim();
40
+ return out;
41
+ }
42
+ throw new Error(`Unsupported token file: ${ext}`);
43
+ }
44
+
45
+ function hexToRgb(h) {
46
+ const m = h.replace('#', '').match(/^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
47
+ return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : null;
48
+ }
49
+
50
+ function colorDistance(a, b) {
51
+ const ra = hexToRgb(a), rb = hexToRgb(b);
52
+ if (!ra || !rb) return Infinity;
53
+ return Math.sqrt(ra.reduce((s, v, i) => s + (v - rb[i]) ** 2, 0));
54
+ }
55
+
56
+ function findNearest(value, palette) {
57
+ if (!/^#[\da-f]{6}$/i.test(value)) return null;
58
+ let best = { distance: Infinity, token: null };
59
+ for (const p of palette) {
60
+ if (!/^#[\da-f]{6}$/i.test(p.hex || '')) continue;
61
+ const d = colorDistance(value, p.hex);
62
+ if (d < best.distance) best = { distance: d, token: p };
63
+ }
64
+ return best;
65
+ }
66
+
67
+ export async function checkDrift(url, { tokens: tokensFile, tolerance = 8, options = {} } = {}) {
68
+ const local = loadLocalTokens(tokensFile);
69
+ const design = await extractDesignLanguage(url, options);
70
+ const livePalette = design.colors?.all || [];
71
+
72
+ const drifted = [];
73
+ const matched = [];
74
+ const unknown = [];
75
+
76
+ for (const [name, value] of Object.entries(local)) {
77
+ if (!/^#[\da-f]{3,8}$/i.test(String(value))) continue; // only color tokens for now
78
+ const hex = value.length === 4 ? '#' + value.slice(1).split('').map(c => c + c).join('') : value;
79
+ const nearest = findNearest(hex, livePalette);
80
+ if (!nearest || nearest.distance === Infinity) { unknown.push({ name, value: hex }); continue; }
81
+ if (nearest.distance > tolerance) {
82
+ drifted.push({
83
+ token: name,
84
+ local: hex,
85
+ nearestLive: nearest.token.hex,
86
+ distance: Math.round(nearest.distance),
87
+ role: nearest.token.role || 'unknown',
88
+ });
89
+ } else {
90
+ matched.push({ token: name, local: hex, liveMatch: nearest.token.hex, distance: Math.round(nearest.distance) });
91
+ }
92
+ }
93
+
94
+ const driftRatio = drifted.length / Math.max(1, drifted.length + matched.length);
95
+ const verdict = driftRatio === 0 ? 'in-sync' : driftRatio < 0.15 ? 'minor-drift' : driftRatio < 0.4 ? 'notable-drift' : 'major-drift';
96
+
97
+ return {
98
+ url,
99
+ tokensFile,
100
+ tolerance,
101
+ verdict,
102
+ driftRatio: +driftRatio.toFixed(3),
103
+ drifted,
104
+ matched,
105
+ unknown,
106
+ summary: {
107
+ total: drifted.length + matched.length,
108
+ drifted: drifted.length,
109
+ matched: matched.length,
110
+ unknown: unknown.length,
111
+ },
112
+ };
113
+ }
114
+
115
+ export function formatDriftMarkdown(r) {
116
+ const lines = [
117
+ `# designlang drift report`,
118
+ ``,
119
+ `**Live site:** ${r.url}`,
120
+ `**Local tokens:** ${r.tokensFile}`,
121
+ `**Verdict:** ${r.verdict} (drift ratio: ${r.driftRatio})`,
122
+ ``,
123
+ `| metric | count |`,
124
+ `|---|---|`,
125
+ `| total color tokens | ${r.summary.total} |`,
126
+ `| matched | ${r.summary.matched} |`,
127
+ `| drifted | ${r.summary.drifted} |`,
128
+ `| unknown | ${r.summary.unknown} |`,
129
+ ``,
130
+ ];
131
+ if (r.drifted.length) {
132
+ lines.push(`## Drifted tokens`, ``, `| token | local | nearest live | Δ |`, `|---|---|---|---|`);
133
+ for (const d of r.drifted) lines.push(`| \`${d.token}\` | \`${d.local}\` | \`${d.nearestLive}\` (${d.role}) | ${d.distance} |`);
134
+ lines.push('');
135
+ }
136
+ return lines.join('\n');
137
+ }
@@ -28,16 +28,59 @@ function wcagLevel(ratio, isLargeText) {
28
28
  return 'FAIL';
29
29
  }
30
30
 
31
+ // Tags where "foreground vs background" contrast is *not* a WCAG text concern —
32
+ // SVG/icon glyphs, media, form primitives, and structural containers without
33
+ // direct text. Filtering these removes the overlay/decorative false-positives
34
+ // that used to crater scores on dark-themed sites.
35
+ const NON_TEXT_TAGS = new Set([
36
+ 'svg', 'path', 'circle', 'rect', 'polygon', 'polyline', 'line', 'ellipse',
37
+ 'use', 'defs', 'g', 'clippath', 'mask', 'filter', 'symbol', 'stop', 'lineargradient', 'radialgradient',
38
+ 'img', 'picture', 'video', 'audio', 'canvas', 'iframe', 'source', 'track',
39
+ 'br', 'hr', 'wbr',
40
+ 'input', 'select', 'textarea', 'progress', 'meter', 'option', 'optgroup',
41
+ 'script', 'style', 'link', 'meta', 'head', 'html', 'body',
42
+ 'main', 'section', 'article', 'aside', 'header', 'footer', 'nav',
43
+ 'div', 'figure', 'form', 'fieldset', 'ul', 'ol', 'dl',
44
+ ]);
45
+
46
+ const TEXT_BEARING_TAGS = new Set([
47
+ 'p', 'a', 'button', 'label', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
48
+ 'td', 'th', 'code', 'pre', 'em', 'strong', 'small', 'b', 'i', 'u',
49
+ 'time', 'summary', 'figcaption', 'blockquote', 'q', 'mark', 'cite', 'abbr',
50
+ 'dt', 'dd', 'kbd', 'samp', 'var', 'sub', 'sup', 'del', 'ins', 'caption', 'legend',
51
+ // span is a high-noise/high-signal tag — it wraps both real text and
52
+ // decorative glyphs. Include it but require an explicit background (the
53
+ // opacity filter downstream still removes the decorative transparent ones).
54
+ 'span',
55
+ ]);
56
+
57
+ function isContrastRelevant(el) {
58
+ const tag = (el.tag || '').toLowerCase();
59
+ if (NON_TEXT_TAGS.has(tag)) return false;
60
+ if (!TEXT_BEARING_TAGS.has(tag)) return false;
61
+ // If the crawler captured hasText, trust it — filters decorative
62
+ // span/link/button wrappers that hold no real glyphs. If hasText wasn't
63
+ // captured (older fixtures, unit tests) fall back to inclusion.
64
+ if (el.hasText === false) return false;
65
+ return true;
66
+ }
67
+
31
68
  export function extractAccessibility(computedStyles) {
32
69
  const pairs = new Map(); // "fg|bg" -> { fg, bg, count, elements }
33
70
 
34
71
  for (const el of computedStyles) {
72
+ if (!isContrastRelevant(el)) continue;
73
+
35
74
  const fg = parseColor(el.color);
36
75
  const bg = parseColor(el.backgroundColor);
37
- if (!fg || !bg || bg.a === 0) continue;
76
+ if (!fg || !bg) continue;
77
+ // Skip transparent/semi-transparent — real contrast depends on the parent
78
+ // stack which we don't composite. Counting these as "fails" is noise.
79
+ if (bg.a < 0.9 || fg.a < 0.9) continue;
38
80
 
39
81
  const fgHex = rgbToHex(fg);
40
82
  const bgHex = rgbToHex(bg);
83
+ if (fgHex === bgHex) continue;
41
84
  const key = `${fgHex}|${bgHex}`;
42
85
 
43
86
  if (!pairs.has(key)) {
@@ -1,25 +1,39 @@
1
- import { parseColor, rgbToHex, rgbToHsl, clusterColors, isSaturated } from '../utils.js';
1
+ import { parseColor, rgbToHex, rgbToHsl, clusterColors, isSaturated, colorDistance } from '../utils.js';
2
+
3
+ const INTERACTIVE_TAGS = new Set(['a', 'button']);
4
+ const INTERACTIVE_ROLES = new Set(['button', 'link', 'menuitem', 'tab']);
5
+ const INTERACTIVE_CLASS_RE = /\b(btn|button|cta|primary|action)\b/i;
6
+
7
+ function isInteractive(el) {
8
+ if (!el) return false;
9
+ if (INTERACTIVE_TAGS.has(el.tag)) return true;
10
+ if (el.role && INTERACTIVE_ROLES.has(el.role)) return true;
11
+ if (el.classList && INTERACTIVE_CLASS_RE.test(el.classList)) return true;
12
+ return false;
13
+ }
2
14
 
3
15
  export function extractColors(computedStyles) {
4
- const colorMap = new Map(); // hex -> { hex, parsed, count, contexts: Set }
16
+ const colorMap = new Map(); // hex -> { hex, parsed, count, contexts: Set, interactiveBg: number }
5
17
 
6
- function addColor(value, context) {
18
+ function addColor(value, context, { interactive = false } = {}) {
7
19
  const parsed = parseColor(value);
8
20
  if (!parsed || parsed.a === 0) return;
9
21
  const hex = rgbToHex(parsed);
10
22
  if (!colorMap.has(hex)) {
11
- colorMap.set(hex, { hex, parsed, count: 0, contexts: new Set() });
23
+ colorMap.set(hex, { hex, parsed, count: 0, contexts: new Set(), interactiveBg: 0 });
12
24
  }
13
25
  const entry = colorMap.get(hex);
14
26
  entry.count++;
15
27
  entry.contexts.add(context);
28
+ if (interactive && context === 'background') entry.interactiveBg++;
16
29
  }
17
30
 
18
31
  const gradients = new Set();
19
32
 
20
33
  for (const el of computedStyles) {
34
+ const interactive = isInteractive(el);
21
35
  addColor(el.color, 'text');
22
- addColor(el.backgroundColor, 'background');
36
+ addColor(el.backgroundColor, 'background', { interactive });
23
37
  addColor(el.borderColor, 'border');
24
38
 
25
39
  if (el.backgroundImage && el.backgroundImage !== 'none' && el.backgroundImage.includes('gradient')) {
@@ -30,12 +44,21 @@ export function extractColors(computedStyles) {
30
44
  const allColors = Array.from(colorMap.values());
31
45
  const clusters = clusterColors(allColors, 15);
32
46
 
33
- // Classify roles
47
+ // Aggregate interactive-bg score per cluster (sum across members)
48
+ for (const cluster of clusters) {
49
+ cluster.interactiveBg = cluster.members.reduce((s, m) => s + (m.interactiveBg || 0), 0);
50
+ const { s: sat, l: lit } = rgbToHsl(cluster.representative);
51
+ cluster.saturation = sat;
52
+ cluster.lightness = lit;
53
+ }
54
+
55
+ // Classify roles — tighten chromatic threshold so pale grays (hsl sat < 25) don't qualify
34
56
  const neutrals = [];
35
57
  const chromatic = [];
36
58
 
37
59
  for (const cluster of clusters) {
38
- if (isSaturated(cluster.representative)) {
60
+ const chromaticEnough = cluster.saturation > 25 && cluster.lightness > 5 && cluster.lightness < 95;
61
+ if (chromaticEnough || (isSaturated(cluster.representative) && cluster.interactiveBg > 0)) {
39
62
  chromatic.push(cluster);
40
63
  } else {
41
64
  neutrals.push(cluster);
@@ -61,12 +84,27 @@ export function extractColors(computedStyles) {
61
84
  }
62
85
  }
63
86
 
64
- const primary = chromatic[0] || null;
65
- const secondary = chromatic[1] || null;
66
- const accent = chromatic.find(c => {
67
- const pct = c.count / allColors.reduce((s, a) => s + a.count, 0);
87
+ // Rank chromatic clusters by brand-likelihood:
88
+ // interactiveBg carries the most signal (it's a CTA color)
89
+ // saturation comes next (brand colors are usually punchy)
90
+ // raw usage count is a weak tiebreaker (avoids neutral-heavy sites dominating)
91
+ function brandScore(c) {
92
+ return c.interactiveBg * 100 + c.saturation * 2 + Math.log10(Math.max(1, c.count));
93
+ }
94
+ const ranked = [...chromatic].sort((a, b) => brandScore(b) - brandScore(a));
95
+
96
+ const primary = ranked[0] || null;
97
+ // secondary: distinct hue from primary
98
+ const secondary = ranked.find(c => {
99
+ if (!primary || c === primary) return false;
100
+ return colorDistance(c.representative, primary.representative) > 60;
101
+ }) || ranked[1] || null;
102
+ // accent: sparse chromatic, prefers background context
103
+ const accent = ranked.find(c => {
104
+ if (c === primary || c === secondary) return false;
105
+ const pct = c.count / Math.max(1, allColors.reduce((s, a) => s + a.count, 0));
68
106
  return pct < 0.05 && c.members.some(m => m.contexts.has('background'));
69
- }) || chromatic[2] || null;
107
+ }) || ranked.find(c => c !== primary && c !== secondary) || null;
70
108
 
71
109
  return {
72
110
  primary: primary ? { hex: primary.hex, rgb: primary.representative, hsl: rgbToHsl(primary.representative), count: primary.count } : null,
@@ -0,0 +1,123 @@
1
+ // Component Anatomy v2 — builds per-kind anatomy trees, variant × state matrices,
2
+ // and typed prop surfaces that downstream generators can turn into stubs.
3
+
4
+ const KNOWN_VARIANTS = ['primary', 'secondary', 'tertiary', 'ghost', 'outline', 'solid', 'destructive', 'danger', 'success', 'warning', 'link', 'subtle'];
5
+ const KNOWN_SIZES = ['xs', 'sm', 'md', 'lg', 'xl', 'small', 'medium', 'large'];
6
+
7
+ function slotFingerprint(slots = []) {
8
+ return slots.map(s => s.role).join('>');
9
+ }
10
+
11
+ function inferSlots(slots = [], kind) {
12
+ const roles = new Set(slots.map(s => s.role));
13
+ if (kind === 'button') {
14
+ return {
15
+ label: true,
16
+ icon: roles.has('icon'),
17
+ badge: roles.has('badge'),
18
+ };
19
+ }
20
+ if (kind === 'card') {
21
+ return {
22
+ heading: roles.has('heading'),
23
+ description: roles.has('text'),
24
+ media: roles.has('icon'),
25
+ footer: slots.length > 3,
26
+ };
27
+ }
28
+ if (kind === 'input') {
29
+ return { leading: roles.has('icon'), trailing: false };
30
+ }
31
+ return {};
32
+ }
33
+
34
+ function dominant(arr) {
35
+ const counts = {};
36
+ for (const v of arr) counts[v] = (counts[v] || 0) + 1;
37
+ return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || '';
38
+ }
39
+
40
+ export function extractComponentAnatomy(candidates = []) {
41
+ const byKind = {};
42
+ for (const c of candidates) {
43
+ const k = c.kind || 'other';
44
+ (byKind[k] ||= []).push(c);
45
+ }
46
+
47
+ const anatomies = [];
48
+ for (const [kind, items] of Object.entries(byKind)) {
49
+ if (items.length < 2) continue;
50
+
51
+ // Group variants by explicit class hint, fall back to style vector.
52
+ const variantGroups = {};
53
+ for (const it of items) {
54
+ const v = it.variantHint || 'default';
55
+ (variantGroups[v] ||= []).push(it);
56
+ }
57
+
58
+ const variants = Object.entries(variantGroups).map(([name, vs]) => {
59
+ const sizes = {};
60
+ for (const it of vs) {
61
+ const sz = it.sizeHint || 'default';
62
+ (sizes[sz] ||= []).push(it);
63
+ }
64
+ return {
65
+ name,
66
+ count: vs.length,
67
+ states: {
68
+ default: { count: vs.filter(v => !v.disabled).length, css: vs.find(v => !v.disabled)?.css || null },
69
+ disabled: { count: vs.filter(v => v.disabled).length, css: vs.find(v => v.disabled)?.css || null },
70
+ },
71
+ sizes: Object.entries(sizes).map(([sName, sItems]) => ({ name: sName, count: sItems.length, css: sItems[0].css })),
72
+ sampleText: vs.slice(0, 5).map(v => v.text).filter(Boolean),
73
+ };
74
+ }).sort((a, b) => b.count - a.count);
75
+
76
+ const slotSignatures = items.map(i => slotFingerprint(i.slots));
77
+ const anatomy = {
78
+ kind,
79
+ totalInstances: items.length,
80
+ slots: inferSlots(items[0].slots, kind),
81
+ dominantSlotShape: dominant(slotSignatures),
82
+ variants,
83
+ props: {
84
+ variant: Object.keys(variantGroups).filter(v => v !== 'default'),
85
+ size: [...new Set(items.map(i => i.sizeHint).filter(Boolean))],
86
+ disabled: items.some(i => i.disabled),
87
+ },
88
+ };
89
+ anatomies.push(anatomy);
90
+ }
91
+
92
+ return anatomies.sort((a, b) => b.totalInstances - a.totalInstances);
93
+ }
94
+
95
+ // Emit TypeScript-flavored React stub for the anatomy — includes variant/size props + slot children.
96
+ export function formatAnatomyStubs(anatomies = []) {
97
+ const lines = [
98
+ "// Auto-generated by designlang — component anatomy v2.",
99
+ "// Scaffolds. Wire into your token system; not a runtime library.",
100
+ "",
101
+ "import * as React from 'react';",
102
+ "",
103
+ ];
104
+ for (const a of anatomies) {
105
+ const Name = a.kind.charAt(0).toUpperCase() + a.kind.slice(1);
106
+ const variantUnion = (a.props.variant.length ? a.props.variant : ['default']).map(v => `'${v}'`).join(' | ');
107
+ const sizeUnion = (a.props.size.length ? a.props.size : ['md']).map(v => `'${v}'`).join(' | ');
108
+ lines.push(`export interface ${Name}Props {`);
109
+ lines.push(` variant?: ${variantUnion};`);
110
+ lines.push(` size?: ${sizeUnion};`);
111
+ if (a.props.disabled) lines.push(` disabled?: boolean;`);
112
+ if (a.slots.icon) lines.push(` leadingIcon?: React.ReactNode;`);
113
+ if (a.slots.badge) lines.push(` badge?: React.ReactNode;`);
114
+ lines.push(` children?: React.ReactNode;`);
115
+ lines.push(`}`);
116
+ lines.push(``);
117
+ lines.push(`export function ${Name}({ variant = '${a.props.variant[0] || 'default'}', size = 'md', ...rest }: ${Name}Props) {`);
118
+ lines.push(` return React.createElement('${a.kind === 'input' ? 'input' : a.kind === 'link' ? 'a' : a.kind === 'card' ? 'div' : 'button'}', { 'data-variant': variant, 'data-size': size, ...rest });`);
119
+ lines.push(`}`);
120
+ lines.push(``);
121
+ }
122
+ return lines.join('\n');
123
+ }
@@ -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
+ }