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
@@ -4,14 +4,16 @@ export function scoreDesignSystem(design) {
4
4
  const scores = {};
5
5
  const issues = [];
6
6
 
7
- // 1. Color discipline (0-100)
8
- // Fewer unique colors = more disciplined
7
+ // 1. Color discipline (0-100) — calibrated against real production sites
8
+ // (Linear, Stripe, Vercel, GitHub, Apple) which commonly ship 20–50 colors
9
+ // once you include hover/disabled/alpha variants.
9
10
  const colorCount = design.colors.all.length;
10
- if (colorCount <= 8) scores.colorDiscipline = 100;
11
- else if (colorCount <= 15) scores.colorDiscipline = 85;
12
- else if (colorCount <= 25) scores.colorDiscipline = 70;
13
- else if (colorCount <= 40) scores.colorDiscipline = 50;
14
- else { scores.colorDiscipline = 30; issues.push(`${colorCount} unique colors detected consider consolidating to a tighter palette`); }
11
+ if (colorCount <= 12) scores.colorDiscipline = 100;
12
+ else if (colorCount <= 25) scores.colorDiscipline = 92;
13
+ else if (colorCount <= 40) scores.colorDiscipline = 80;
14
+ else if (colorCount <= 60) scores.colorDiscipline = 65;
15
+ else if (colorCount <= 100) scores.colorDiscipline = 50;
16
+ else { scores.colorDiscipline = 35; issues.push(`${colorCount} unique colors detected — consider consolidating to a tighter palette`); }
15
17
 
16
18
  if (!design.colors.primary) {
17
19
  scores.colorDiscipline -= 15;
@@ -26,46 +28,62 @@ export function scoreDesignSystem(design) {
26
28
 
27
29
  const weightCount = design.typography.weights.length;
28
30
  if (weightCount <= 3) scores.typographyConsistency = Math.min(scores.typographyConsistency, 100);
29
- else if (weightCount <= 5) scores.typographyConsistency = Math.min(scores.typographyConsistency, 80);
31
+ else if (weightCount <= 5) scores.typographyConsistency = Math.min(scores.typographyConsistency, 90);
32
+ else if (weightCount <= 7) scores.typographyConsistency = Math.min(scores.typographyConsistency, 80);
30
33
  else { scores.typographyConsistency -= 15; issues.push(`${weightCount} font weights in use — consider standardizing to 3 (regular, medium, bold)`); }
31
34
 
35
+ // Type-scale count — variable-font sites and component-rich pages commonly
36
+ // ship 12–18 sizes (nav, body, caption, h1–h6, stat, pill, etc).
32
37
  const scaleSize = design.typography.scale.length;
33
- if (scaleSize <= 6) scores.typographyConsistency = Math.min(scores.typographyConsistency, 100);
34
- else if (scaleSize <= 10) scores.typographyConsistency = Math.min(scores.typographyConsistency, 85);
38
+ if (scaleSize <= 8) scores.typographyConsistency = Math.min(scores.typographyConsistency, 100);
39
+ else if (scaleSize <= 14) scores.typographyConsistency = Math.min(scores.typographyConsistency, 92);
40
+ else if (scaleSize <= 20) scores.typographyConsistency = Math.min(scores.typographyConsistency, 82);
35
41
  else { scores.typographyConsistency -= 10; issues.push(`${scaleSize} distinct font sizes — consider a tighter type scale`); }
36
42
 
37
- // 3. Spacing system (0-100)
43
+ // 3. Spacing system (0-100) — detectScale() only tries 2/4/6/8 as bases and
44
+ // fails on sites whose computed-style spacing has line-height/SVG noise mixed
45
+ // in. Don't penalise too hard when a base isn't pinned but tokenization is
46
+ // healthy — the design still has a system, we just can't name it.
38
47
  if (design.spacing.base) {
39
48
  scores.spacingSystem = 90;
40
- // Check how many values fit the base
41
49
  const fittingValues = design.spacing.scale.filter(v => v % design.spacing.base === 0).length;
42
50
  const fitRatio = fittingValues / design.spacing.scale.length;
43
51
  if (fitRatio >= 0.8) scores.spacingSystem = 100;
44
- else if (fitRatio >= 0.6) scores.spacingSystem = 80;
45
- else scores.spacingSystem = 65;
52
+ else if (fitRatio >= 0.6) scores.spacingSystem = 85;
53
+ else scores.spacingSystem = 75;
46
54
  } else {
47
- scores.spacingSystem = 40;
48
- issues.push('No consistent spacing base unit detected values appear arbitrary');
55
+ // Fallback: soft penalty if the site still ships design tokens (CSS vars).
56
+ const varCount = Object.values(design.variables || {}).reduce((s, v) => s + Object.keys(v || {}).length, 0);
57
+ scores.spacingSystem = varCount >= 20 ? 70 : 55;
58
+ if (varCount < 20) issues.push('No consistent spacing base unit detected — values appear arbitrary');
49
59
  }
50
60
 
51
- if (design.spacing.scale.length > 20) {
61
+ // Spacing value count — real sites commonly ship 25–40 distinct spacings
62
+ // (component padding, gap, stacked layout rhythm). Only penalise above 35.
63
+ if (design.spacing.scale.length > 50) {
52
64
  scores.spacingSystem -= 15;
53
65
  issues.push(`${design.spacing.scale.length} unique spacing values — too many one-off values`);
66
+ } else if (design.spacing.scale.length > 35) {
67
+ scores.spacingSystem -= 5;
54
68
  }
55
69
 
56
- // 4. Shadow consistency (0-100)
70
+ // 4. Shadow consistency (0-100) — calibrated; real sites routinely ship
71
+ // 10–20 shadows once hover/focus/elevation variants are counted.
57
72
  const shadowCount = design.shadows.values.length;
58
- if (shadowCount === 0) scores.shadowConsistency = 80; // no shadows is fine
59
- else if (shadowCount <= 4) scores.shadowConsistency = 100;
60
- else if (shadowCount <= 8) scores.shadowConsistency = 75;
73
+ if (shadowCount === 0) scores.shadowConsistency = 85;
74
+ else if (shadowCount <= 5) scores.shadowConsistency = 100;
75
+ else if (shadowCount <= 10) scores.shadowConsistency = 90;
76
+ else if (shadowCount <= 18) scores.shadowConsistency = 78;
77
+ else if (shadowCount <= 28) scores.shadowConsistency = 62;
61
78
  else { scores.shadowConsistency = 50; issues.push(`${shadowCount} unique shadows — consider a 3-level elevation scale (sm/md/lg)`); }
62
79
 
63
80
  // 5. Border radius consistency (0-100)
64
81
  const radiiCount = design.borders.radii.length;
65
- if (radiiCount <= 3) scores.radiusConsistency = 100;
66
- else if (radiiCount <= 5) scores.radiusConsistency = 85;
67
- else if (radiiCount <= 8) scores.radiusConsistency = 65;
68
- else { scores.radiusConsistency = 40; issues.push(`${radiiCount} unique border radii standardize to 3-4 values`); }
82
+ if (radiiCount <= 4) scores.radiusConsistency = 100;
83
+ else if (radiiCount <= 7) scores.radiusConsistency = 90;
84
+ else if (radiiCount <= 10) scores.radiusConsistency = 80;
85
+ else if (radiiCount <= 15) scores.radiusConsistency = 65;
86
+ else { scores.radiusConsistency = 45; issues.push(`${radiiCount} unique border radii — standardize to 3-4 values`); }
69
87
 
70
88
  // 6. Accessibility (from existing extractor)
71
89
  scores.accessibility = design.accessibility?.score || 0;
@@ -101,13 +119,14 @@ export function scoreDesignSystem(design) {
101
119
 
102
120
  // Overall score (weighted average)
103
121
  const weights = {
104
- colorDiscipline: 20,
105
- typographyConsistency: 20,
106
- spacingSystem: 20,
107
- shadowConsistency: 10,
108
- radiusConsistency: 10,
122
+ colorDiscipline: 18,
123
+ typographyConsistency: 18,
124
+ spacingSystem: 18,
125
+ shadowConsistency: 9,
126
+ radiusConsistency: 9,
109
127
  accessibility: 15,
110
128
  tokenization: 5,
129
+ cssHealth: 8,
111
130
  };
112
131
 
113
132
  let totalWeight = 0;
@@ -0,0 +1,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
+ }