designlang 5.0.0 → 7.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 (51) hide show
  1. package/.github/FUNDING.yml +1 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.yml +62 -0
  3. package/.github/ISSUE_TEMPLATE/config.yml +8 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.yml +28 -0
  5. package/CHANGELOG.md +43 -0
  6. package/README.md +177 -6
  7. package/bin/design-extract.js +302 -92
  8. package/docs/superpowers/plans/2026-04-18-designlang-v7.md +1121 -0
  9. package/docs/superpowers/specs/2026-04-18-designlang-v7-design.md +150 -0
  10. package/package.json +13 -7
  11. package/src/config.js +59 -0
  12. package/src/crawler.js +297 -95
  13. package/src/extractors/a11y-remediation.js +47 -0
  14. package/src/extractors/animations.js +37 -5
  15. package/src/extractors/borders.js +40 -5
  16. package/src/extractors/component-clusters.js +39 -0
  17. package/src/extractors/components.js +77 -1
  18. package/src/extractors/css-health.js +151 -0
  19. package/src/extractors/gradients.js +25 -5
  20. package/src/extractors/scoring.js +20 -1
  21. package/src/extractors/semantic-regions.js +44 -0
  22. package/src/extractors/shadows.js +60 -17
  23. package/src/extractors/spacing.js +31 -2
  24. package/src/extractors/stack-fingerprint.js +88 -0
  25. package/src/extractors/variables.js +20 -1
  26. package/src/formatters/_token-ref.js +44 -0
  27. package/src/formatters/agent-rules.js +116 -0
  28. package/src/formatters/android-compose.js +164 -0
  29. package/src/formatters/dtcg-tokens.js +175 -0
  30. package/src/formatters/figma.js +66 -47
  31. package/src/formatters/flutter-dart.js +130 -0
  32. package/src/formatters/ios-swiftui.js +161 -0
  33. package/src/formatters/markdown.js +25 -0
  34. package/src/formatters/preview.js +65 -22
  35. package/src/formatters/svelte-theme.js +40 -0
  36. package/src/formatters/tailwind.js +57 -4
  37. package/src/formatters/theme.js +134 -0
  38. package/src/formatters/vue-theme.js +44 -0
  39. package/src/formatters/wordpress.js +267 -0
  40. package/src/history.js +8 -1
  41. package/src/index.js +76 -20
  42. package/src/mcp/resources.js +64 -0
  43. package/src/mcp/server.js +110 -0
  44. package/src/mcp/tools.js +149 -0
  45. package/src/utils.js +68 -0
  46. package/tests/cli.test.js +84 -0
  47. package/tests/extractors.test.js +792 -0
  48. package/tests/formatters.test.js +709 -0
  49. package/tests/mcp.test.js +68 -0
  50. package/tests/utils.test.js +413 -0
  51. package/website/app/globals.css +11 -11
@@ -0,0 +1,39 @@
1
+ // Cluster similar component instances by structuralHash + style vector cosine similarity.
2
+
3
+ function cosine(a = [], b = []) {
4
+ const n = Math.min(a.length, b.length);
5
+ let dot = 0, na = 0, nb = 0;
6
+ for (let i = 0; i < n; i++) {
7
+ dot += a[i] * b[i];
8
+ na += a[i] * a[i];
9
+ nb += b[i] * b[i];
10
+ }
11
+ return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : (na === nb ? 1 : 0);
12
+ }
13
+
14
+ export function clusterComponents(elements = [], { threshold = 0.95 } = {}) {
15
+ const byKind = {};
16
+ for (const el of elements) {
17
+ const key = `${el.kind}|${el.structuralHash}`;
18
+ (byKind[key] ||= []).push(el);
19
+ }
20
+ const out = [];
21
+ for (const group of Object.values(byKind)) {
22
+ const variants = [];
23
+ for (const el of group) {
24
+ const match = variants.find(v => cosine(v.example.styleVector || [], el.styleVector || []) >= threshold);
25
+ if (match) {
26
+ match.instanceCount++;
27
+ } else {
28
+ variants.push({ example: el, instanceCount: 1 });
29
+ }
30
+ }
31
+ out.push({
32
+ kind: group[0].kind,
33
+ structuralHash: group[0].structuralHash,
34
+ instanceCount: group.length,
35
+ variants: variants.map(v => ({ css: v.example.css, instanceCount: v.instanceCount })),
36
+ });
37
+ }
38
+ return out;
39
+ }
@@ -1,15 +1,33 @@
1
1
  export function extractComponents(computedStyles) {
2
2
  const components = {};
3
3
 
4
- // Buttons
4
+ // Buttons — with variant detection
5
5
  const buttons = computedStyles.filter(el =>
6
6
  el.tag === 'button' || el.role === 'button' ||
7
7
  (el.tag === 'a' && /btn|button/i.test(el.classList))
8
8
  );
9
9
  if (buttons.length > 0) {
10
+ // Group by background color to detect variants
11
+ const bgGroups = new Map();
12
+ for (const btn of buttons) {
13
+ const bg = btn.backgroundColor || 'transparent';
14
+ if (!bgGroups.has(bg)) bgGroups.set(bg, []);
15
+ bgGroups.get(bg).push(btn);
16
+ }
17
+ const variants = [...bgGroups.entries()]
18
+ .sort((a, b) => b[1].length - a[1].length)
19
+ .map(([bg, group], i) => {
20
+ let variant = 'default';
21
+ if (bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') variant = 'ghost';
22
+ else if (i === 0) variant = 'primary';
23
+ else if (i === 1) variant = 'secondary';
24
+ else variant = `variant-${i + 1}`;
25
+ return { variant, backgroundColor: bg, count: group.length, style: mostCommonStyle(group, ['color', 'fontSize', 'fontWeight', 'paddingTop', 'paddingRight', 'borderRadius']) };
26
+ });
10
27
  components.buttons = {
11
28
  count: buttons.length,
12
29
  baseStyle: mostCommonStyle(buttons, ['backgroundColor', 'color', 'fontSize', 'fontWeight', 'paddingTop', 'paddingRight', 'borderRadius']),
30
+ variants,
13
31
  };
14
32
  }
15
33
 
@@ -131,6 +149,64 @@ export function extractComponents(computedStyles) {
131
149
  };
132
150
  }
133
151
 
152
+ // Tabs
153
+ const tabs = computedStyles.filter(el =>
154
+ el.role === 'tab' || /\btab\b/i.test(el.classList)
155
+ );
156
+ if (tabs.length > 0) {
157
+ components.tabs = {
158
+ count: tabs.length,
159
+ baseStyle: mostCommonStyle(tabs, ['backgroundColor', 'color', 'fontSize', 'fontWeight', 'paddingTop', 'paddingRight', 'borderColor', 'borderRadius']),
160
+ };
161
+ }
162
+
163
+ // Accordions
164
+ const accordions = computedStyles.filter(el =>
165
+ /accordion/i.test(el.classList) ||
166
+ (el.tag === 'summary') ||
167
+ (el.tag === 'details')
168
+ );
169
+ if (accordions.length > 0) {
170
+ components.accordions = {
171
+ count: accordions.length,
172
+ baseStyle: mostCommonStyle(accordions, ['backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingRight', 'borderColor']),
173
+ };
174
+ }
175
+
176
+ // Tooltips
177
+ const tooltips = computedStyles.filter(el =>
178
+ el.role === 'tooltip' || /tooltip/i.test(el.classList)
179
+ );
180
+ if (tooltips.length > 0) {
181
+ components.tooltips = {
182
+ count: tooltips.length,
183
+ baseStyle: mostCommonStyle(tooltips, ['backgroundColor', 'color', 'fontSize', 'borderRadius', 'paddingTop', 'paddingRight', 'boxShadow']),
184
+ };
185
+ }
186
+
187
+ // Progress bars
188
+ const progressBars = computedStyles.filter(el =>
189
+ el.tag === 'progress' || el.role === 'progressbar' || /progress/i.test(el.classList)
190
+ );
191
+ if (progressBars.length > 0) {
192
+ components.progressBars = {
193
+ count: progressBars.length,
194
+ baseStyle: mostCommonStyle(progressBars, ['backgroundColor', 'color', 'borderRadius', 'fontSize']),
195
+ };
196
+ }
197
+
198
+ // Switches / Toggles
199
+ const switches = computedStyles.filter(el =>
200
+ el.role === 'switch' ||
201
+ /switch|toggle/i.test(el.classList)
202
+ );
203
+ if (switches.length > 0) {
204
+ components.switches = {
205
+ count: switches.length,
206
+ baseStyle: mostCommonStyle(switches, ['backgroundColor', 'borderRadius', 'borderColor']),
207
+ };
208
+ }
209
+
134
210
  // Generate CSS snippets for each component
135
211
  for (const [type, data] of Object.entries(components)) {
136
212
  if (data.baseStyle) {
@@ -0,0 +1,151 @@
1
+ // CSS health audit — operates on page.coverage.stopCSSCoverage() output
2
+ // serialized as [{ url, text, totalBytes, ranges:[{start,end}] }].
3
+
4
+ function countUsed(ranges = []) {
5
+ let used = 0;
6
+ for (const r of ranges) used += Math.max(0, (r.end || 0) - (r.start || 0));
7
+ return used;
8
+ }
9
+
10
+ function countImportant(text) {
11
+ const m = text.match(/!important/g);
12
+ return m ? m.length : 0;
13
+ }
14
+
15
+ function countDuplicates(text) {
16
+ // Count prop:value pairs that appear >=2x across the sheet.
17
+ const seen = new Map();
18
+ const re = /([\w-]+)\s*:\s*([^;{}!]+)(\s*!important)?\s*;?/g;
19
+ for (const m of text.matchAll(re)) {
20
+ const key = `${m[1].trim()}:${m[2].trim()}`;
21
+ seen.set(key, (seen.get(key) || 0) + 1);
22
+ }
23
+ let dup = 0;
24
+ for (const n of seen.values()) if (n >= 2) dup += (n - 1);
25
+ return dup;
26
+ }
27
+
28
+ function countVendorPrefixes(text) {
29
+ return {
30
+ webkit: (text.match(/-webkit-/g) || []).length,
31
+ moz: (text.match(/-moz-/g) || []).length,
32
+ ms: (text.match(/-ms-/g) || []).length,
33
+ o: (text.match(/(^|[^-\w])-o-/g) || []).length,
34
+ };
35
+ }
36
+
37
+ function extractKeyframes(text) {
38
+ const out = [];
39
+ const re = /@keyframes\s+([\w-]+)\s*\{([\s\S]*?)\n\}/g;
40
+ for (const m of text.matchAll(re)) {
41
+ const body = m[2];
42
+ const steps = (body.match(/(\d+%|from|to)\s*\{/g) || []).length;
43
+ out.push({ name: m[1], steps });
44
+ }
45
+ return out;
46
+ }
47
+
48
+ function specificityFor(selector) {
49
+ // Simple WCAG-ish triple: ids, classes+attrs+pseudo-classes, types
50
+ const ids = (selector.match(/#[\w-]+/g) || []).length;
51
+ const classes = (selector.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(?:\([^)]+\))?/g) || []).length;
52
+ const types = (selector.match(/(?:^|[\s>+~,])([a-z][\w-]*)/gi) || []).length;
53
+ return [ids, classes, types];
54
+ }
55
+
56
+ function specificityDistribution(text) {
57
+ const triples = [];
58
+ const re = /([^{}]+)\{([^}]*)\}/g;
59
+ for (const m of text.matchAll(re)) {
60
+ const selectorList = m[1];
61
+ for (const sel of selectorList.split(',').map(s => s.trim()).filter(Boolean)) {
62
+ if (sel.startsWith('@')) continue;
63
+ triples.push(specificityFor(sel));
64
+ }
65
+ }
66
+ if (triples.length === 0) {
67
+ return { max: [0, 0, 0], average: [0, 0, 0], count: 0 };
68
+ }
69
+ let max = [0, 0, 0];
70
+ let sum = [0, 0, 0];
71
+ for (const t of triples) {
72
+ for (let i = 0; i < 3; i++) {
73
+ sum[i] += t[i];
74
+ if (t[i] > max[i]) max[i] = t[i];
75
+ }
76
+ }
77
+ const avg = sum.map(v => Math.round((v / triples.length) * 100) / 100);
78
+ return { max, average: avg, count: triples.length };
79
+ }
80
+
81
+ export function extractCssHealth(coverage = []) {
82
+ const sheets = [];
83
+ let totalBytes = 0;
84
+ let usedBytes = 0;
85
+ let importantCount = 0;
86
+ let duplicates = 0;
87
+ const vendorPrefixes = { webkit: 0, moz: 0, ms: 0, o: 0 };
88
+ const keyframes = [];
89
+ let specMax = [0, 0, 0];
90
+ let specSumWeighted = [0, 0, 0];
91
+ let specCount = 0;
92
+
93
+ for (const c of coverage) {
94
+ const text = c.text || '';
95
+ const sheetTotal = typeof c.totalBytes === 'number' ? c.totalBytes : text.length;
96
+ const sheetUsed = countUsed(c.ranges);
97
+ const sheetUnused = Math.max(0, sheetTotal - sheetUsed);
98
+ sheets.push({
99
+ url: c.url || '',
100
+ totalBytes: sheetTotal,
101
+ usedBytes: sheetUsed,
102
+ unusedBytes: sheetUnused,
103
+ unusedPercent: sheetTotal ? Math.round((sheetUnused / sheetTotal) * 100) : 0,
104
+ });
105
+ totalBytes += sheetTotal;
106
+ usedBytes += sheetUsed;
107
+
108
+ importantCount += countImportant(text);
109
+ duplicates += countDuplicates(text);
110
+
111
+ const vp = countVendorPrefixes(text);
112
+ vendorPrefixes.webkit += vp.webkit;
113
+ vendorPrefixes.moz += vp.moz;
114
+ vendorPrefixes.ms += vp.ms;
115
+ vendorPrefixes.o += vp.o;
116
+
117
+ keyframes.push(...extractKeyframes(text));
118
+
119
+ const spec = specificityDistribution(text);
120
+ for (let i = 0; i < 3; i++) {
121
+ if (spec.max[i] > specMax[i]) specMax[i] = spec.max[i];
122
+ specSumWeighted[i] += spec.average[i] * spec.count;
123
+ }
124
+ specCount += spec.count;
125
+ }
126
+
127
+ const unusedBytes = Math.max(0, totalBytes - usedBytes);
128
+ const unusedPercent = totalBytes ? Math.round((unusedBytes / totalBytes) * 100) : 0;
129
+ const specAvg = specCount > 0
130
+ ? specSumWeighted.map(v => Math.round((v / specCount) * 100) / 100)
131
+ : [0, 0, 0];
132
+
133
+ const issues = [];
134
+ if (importantCount > 0) issues.push(`${importantCount} !important rule${importantCount > 1 ? 's' : ''}`);
135
+ if (duplicates > 0) issues.push(`${duplicates} duplicate declaration${duplicates > 1 ? 's' : ''}`);
136
+ if (unusedPercent >= 50) issues.push(`${unusedPercent}% unused CSS`);
137
+
138
+ return {
139
+ sheets,
140
+ totalBytes,
141
+ usedBytes,
142
+ unusedBytes,
143
+ unusedPercent,
144
+ importantCount,
145
+ duplicates,
146
+ vendorPrefixes,
147
+ keyframes,
148
+ specificity: { max: specMax, average: specAvg, count: specCount },
149
+ issues,
150
+ };
151
+ }
@@ -6,7 +6,9 @@ export function extractGradients(styles) {
6
6
  const bg = el.backgroundImage;
7
7
  if (!bg || !bg.includes('gradient')) continue;
8
8
  const rawGradients = splitGradients(bg);
9
- for (const raw of rawGradients) {
9
+ for (let raw of rawGradients) {
10
+ // Normalize vendor prefixes
11
+ raw = raw.replace(/-(webkit|moz)-/g, '');
10
12
  if (seen.has(raw)) continue;
11
13
  seen.add(raw);
12
14
  gradients.push(parseGradient(raw));
@@ -58,15 +60,33 @@ function parseGradient(raw) {
58
60
  let direction = null;
59
61
  let stopArgs = args;
60
62
  const first = args[0] || '';
61
- if (/^(to |from |\d+deg|at )/.test(first) || /^(circle|ellipse)/.test(first)) {
63
+ if (/^(to |from |\d+(\.\d+)?(deg|grad|rad|turn)|at )/.test(first) || /^(circle|ellipse)/.test(first)) {
62
64
  direction = first;
63
65
  stopArgs = args.slice(1);
64
66
  }
65
67
 
66
68
  const stops = stopArgs.map(s => {
67
- const posMatch = s.match(/([\d.]+%?)$/);
68
- const position = posMatch ? posMatch[1] : null;
69
- const color = position ? s.slice(0, posMatch.index).trim() : s.trim();
69
+ // Match position only if it's outside parentheses (not inside rgb/hsl)
70
+ // Position is a percentage or length at the end, after the color value
71
+ const trimmed = s.trim();
72
+ // Check if trailing value is outside any parens
73
+ const lastParen = trimmed.lastIndexOf(')');
74
+ const trailing = lastParen >= 0 ? trimmed.slice(lastParen + 1).trim() : trimmed;
75
+ const posMatch = trailing.match(/([\d.]+(%|px|em|rem|vw|vh)?)$/);
76
+ let position = null;
77
+ let color = trimmed;
78
+ if (posMatch && posMatch[0] !== trimmed) {
79
+ // Position found after the color function closes
80
+ position = posMatch[0];
81
+ color = trimmed.slice(0, trimmed.length - trailing.length + trailing.indexOf(posMatch[0])).trim();
82
+ } else if (lastParen < 0) {
83
+ // No parens — simple color like "red 50%"
84
+ const simplePos = trimmed.match(/\s+([\d.]+(%|px|em|rem|vw|vh)?)$/);
85
+ if (simplePos) {
86
+ position = simplePos[1];
87
+ color = trimmed.slice(0, simplePos.index).trim();
88
+ }
89
+ }
70
90
  return { color, position };
71
91
  });
72
92
 
@@ -73,7 +73,26 @@ export function scoreDesignSystem(design) {
73
73
  issues.push(`${design.accessibility.failCount} WCAG contrast failures`);
74
74
  }
75
75
 
76
- // 7. CSS variable usage (0-100)
76
+ // 7. CSS health (0-100) — additive; does not affect existing weights.
77
+ if (design.cssHealth) {
78
+ const ch = design.cssHealth;
79
+ let h = 100;
80
+ if (ch.unusedPercent >= 70) h -= 30;
81
+ else if (ch.unusedPercent >= 50) h -= 20;
82
+ else if (ch.unusedPercent >= 30) h -= 10;
83
+ if (ch.importantCount >= 20) h -= 20;
84
+ else if (ch.importantCount >= 5) h -= 10;
85
+ else if (ch.importantCount >= 1) h -= 5;
86
+ if (ch.duplicates >= 20) h -= 15;
87
+ else if (ch.duplicates >= 5) h -= 8;
88
+ else if (ch.duplicates >= 1) h -= 3;
89
+ scores.cssHealth = Math.max(0, h);
90
+ if (ch.importantCount >= 5) issues.push(`${ch.importantCount} !important rules — prefer specificity over overrides`);
91
+ if (ch.unusedPercent >= 50) issues.push(`${ch.unusedPercent}% of CSS is unused — consider purging`);
92
+ if (ch.duplicates >= 5) issues.push(`${ch.duplicates} duplicate CSS declarations`);
93
+ }
94
+
95
+ // 8. CSS variable usage (0-100)
77
96
  const varCount = Object.values(design.variables).reduce((s, v) => s + Object.keys(v).length, 0);
78
97
  if (varCount >= 20) scores.tokenization = 100;
79
98
  else if (varCount >= 10) scores.tokenization = 75;
@@ -0,0 +1,44 @@
1
+ // Classify page sections: nav/hero/features/pricing/testimonials/cta/footer/sidebar/content.
2
+
3
+ const KW = {
4
+ pricing: /\b(\$\s*\d|per\s?month|\/mo\b|pricing|free|billed)/i,
5
+ testimonials: /(customer|review|testimonial|said|"|")/i,
6
+ features: /(feature|benefit|why|what you get)/i,
7
+ cta: /(get started|sign up|try free|start now|request demo|contact sales)/i,
8
+ };
9
+
10
+ function classify(s) {
11
+ const role = (s.role || '').toLowerCase();
12
+ const tag = (s.tag || '').toLowerCase();
13
+ if (tag === 'nav' || role === 'navigation') return 'nav';
14
+ if (tag === 'header' || role === 'banner') return 'nav';
15
+ if (tag === 'footer' || role === 'contentinfo') return 'footer';
16
+ if (tag === 'aside' || role === 'complementary') return 'sidebar';
17
+
18
+ const cls = (s.className || '').toLowerCase();
19
+ const id = (s.id || '').toLowerCase();
20
+ const blob = `${cls} ${id}`;
21
+ const text = s.text || '';
22
+ const headings = s.headings || [];
23
+
24
+ if (/hero/.test(blob)) return 'hero';
25
+ if (/pricing/.test(blob) || KW.pricing.test(text)) return 'pricing';
26
+ if (/testimonial|review/.test(blob) || KW.testimonials.test(text)) return 'testimonials';
27
+ if (/features?|grid/.test(blob) && s.cardCount >= 3) return 'features';
28
+ if (KW.features.test(text) && s.cardCount >= 3) return 'features';
29
+ if (s.buttonCount <= 2 && headings.length && text.length < 400 && KW.cta.test(text)) return 'cta';
30
+ if (headings.length === 1 && s.buttonCount >= 1 && s.bounds && s.bounds.h > 300) return 'hero';
31
+ return 'content';
32
+ }
33
+
34
+ export function extractSemanticRegions(sections = []) {
35
+ return sections.map(s => ({
36
+ role: classify(s),
37
+ tag: s.tag,
38
+ bounds: s.bounds,
39
+ heading: (s.headings && s.headings[0]) || null,
40
+ buttonCount: s.buttonCount || 0,
41
+ cardCount: s.cardCount || 0,
42
+ className: s.className || null,
43
+ }));
44
+ }
@@ -1,27 +1,70 @@
1
1
  export function extractShadows(computedStyles) {
2
2
  const shadowSet = new Set();
3
+ const textShadowSet = new Set();
3
4
 
4
5
  for (const el of computedStyles) {
5
6
  if (el.boxShadow && el.boxShadow !== 'none') {
6
7
  shadowSet.add(el.boxShadow);
7
8
  }
9
+ if (el.textShadow && el.textShadow !== 'none') {
10
+ textShadowSet.add(el.textShadow);
11
+ }
12
+ }
13
+
14
+ const values = [...shadowSet].map(raw => parseShadow(raw));
15
+ values.sort((a, b) => a.visualWeight - b.visualWeight);
16
+
17
+ const textShadows = [...textShadowSet].map(raw => parseShadow(raw));
18
+ textShadows.sort((a, b) => a.visualWeight - b.visualWeight);
19
+
20
+ return { values, textShadows };
21
+ }
22
+
23
+ function parseShadow(raw) {
24
+ const inset = raw.includes('inset');
25
+ const cleaned = raw.replace(/\binset\b/g, '').trim();
26
+
27
+ // Extract color (rgb/rgba/hsl/hsla or named/hex) — find the non-numeric portion
28
+ let color = '';
29
+ let numericPart = cleaned;
30
+ // Match color functions first (they may contain commas/numbers)
31
+ const colorFnMatch = cleaned.match(/(rgba?\([^)]+\)|hsla?\([^)]+\))/);
32
+ if (colorFnMatch) {
33
+ color = colorFnMatch[1];
34
+ numericPart = cleaned.replace(color, '').trim();
35
+ } else {
36
+ // Try hex or named color at start or end
37
+ const hexMatch = cleaned.match(/(#[0-9a-fA-F]{3,8})/);
38
+ if (hexMatch) {
39
+ color = hexMatch[1];
40
+ numericPart = cleaned.replace(color, '').trim();
41
+ } else {
42
+ // Named color — typically last or first token that isn't a length
43
+ const tokens = cleaned.split(/\s+/);
44
+ const colorToken = tokens.find(t => !/^-?[\d.]+px$/.test(t) && !/^[\d.]+$/.test(t));
45
+ if (colorToken) {
46
+ color = colorToken;
47
+ numericPart = cleaned.replace(colorToken, '').trim();
48
+ }
49
+ }
8
50
  }
9
51
 
10
- const values = [...shadowSet].map(raw => {
11
- // Parse: offset-x offset-y blur spread color [inset]
12
- const inset = raw.includes('inset');
13
- // Approximate blur from the string for classification
14
- const nums = raw.match(/([\d.]+)px/g)?.map(n => parseFloat(n)) || [];
15
- const blur = nums[2] || 0;
16
- let label = 'md';
17
- if (blur <= 2) label = 'xs';
18
- else if (blur <= 6) label = 'sm';
19
- else if (blur <= 15) label = 'md';
20
- else if (blur <= 30) label = 'lg';
21
- else label = 'xl';
22
- return { raw, blur, inset, label };
23
- });
24
-
25
- values.sort((a, b) => a.blur - b.blur);
26
- return { values };
52
+ // Parse numeric values (offset-x, offset-y, blur, spread)
53
+ const nums = numericPart.match(/-?[\d.]+px/g)?.map(n => parseFloat(n)) || [];
54
+ const offsetX = nums[0] || 0;
55
+ const offsetY = nums[1] || 0;
56
+ const blur = nums[2] || 0;
57
+ const spread = nums[3] || 0;
58
+
59
+ // Visual weight = distance + blur
60
+ const visualWeight = Math.sqrt(offsetX * offsetX + offsetY * offsetY) + blur;
61
+
62
+ let label = 'none';
63
+ if (visualWeight > 0 && visualWeight <= 3) label = 'xs';
64
+ else if (visualWeight <= 8) label = 'sm';
65
+ else if (visualWeight <= 16) label = 'md';
66
+ else if (visualWeight <= 32) label = 'lg';
67
+ else if (visualWeight > 32) label = 'xl';
68
+
69
+ return { raw, offsetX, offsetY, blur, spread, color, inset, visualWeight: Math.round(visualWeight * 100) / 100, label };
27
70
  }
@@ -1,4 +1,33 @@
1
- import { parseCSSValue, clusterValues, detectScale } from '../utils.js';
1
+ import { parseCSSValue, detectScale } from '../utils.js';
2
+
3
+ function naturalBreakCluster(values) {
4
+ if (values.length <= 1) return values;
5
+ const sorted = [...values].sort((a, b) => a - b);
6
+ if (sorted.length <= 2) return sorted;
7
+
8
+ // Compute gaps between consecutive values
9
+ const gaps = [];
10
+ for (let i = 1; i < sorted.length; i++) {
11
+ gaps.push(sorted[i] - sorted[i - 1]);
12
+ }
13
+
14
+ // Find median gap
15
+ const sortedGaps = [...gaps].sort((a, b) => a - b);
16
+ const medianGap = sortedGaps[Math.floor(sortedGaps.length / 2)];
17
+
18
+ // Split into clusters at gaps larger than the median
19
+ const clusters = [[sorted[0]]];
20
+ for (let i = 1; i < sorted.length; i++) {
21
+ if (gaps[i - 1] > medianGap) {
22
+ clusters.push([sorted[i]]);
23
+ } else {
24
+ clusters[clusters.length - 1].push(sorted[i]);
25
+ }
26
+ }
27
+
28
+ // Use the first (smallest) value in each cluster as representative
29
+ return clusters.map(c => c[0]);
30
+ }
2
31
 
3
32
  export function extractSpacing(computedStyles) {
4
33
  const allValues = new Set();
@@ -13,7 +42,7 @@ export function extractSpacing(computedStyles) {
13
42
  }
14
43
 
15
44
  const sorted = [...allValues].sort((a, b) => a - b);
16
- const clustered = clusterValues(sorted, 2);
45
+ const clustered = naturalBreakCluster(sorted);
17
46
  const { base, scale } = detectScale(clustered);
18
47
 
19
48
  // Build named tokens
@@ -0,0 +1,88 @@
1
+ // Framework + CSS layer + analytics fingerprint.
2
+ // Pure function over signals collected by the crawler.
3
+
4
+ const FRAMEWORK_BY_GLOBAL = {
5
+ '__NEXT_DATA__': 'next',
6
+ '__NUXT__': 'nuxt',
7
+ '___gatsby': 'gatsby',
8
+ '_remixContext': 'remix',
9
+ 'React': 'react',
10
+ 'Vue': 'vue',
11
+ 'Shopify': 'shopify',
12
+ 'wp': 'wordpress',
13
+ };
14
+
15
+ const SCRIPT_PATTERNS = [
16
+ [/_next\/static/, 'next'],
17
+ [/\/nuxt\//, 'nuxt'],
18
+ [/\/astro\//, 'astro'],
19
+ [/\/sveltekit\//, 'sveltekit'],
20
+ [/shopify\./, 'shopify'],
21
+ [/wp-(content|includes)/, 'wordpress'],
22
+ [/webflow\.com/, 'webflow'],
23
+ [/framerusercontent/, 'framer'],
24
+ ];
25
+
26
+ const ANALYTICS = {
27
+ gtag: /googletagmanager\.com|google-analytics/,
28
+ plausible: /plausible\.io/,
29
+ posthog: /posthog\.com/,
30
+ segment: /segment\.(io|com)/,
31
+ mixpanel: /mixpanel/,
32
+ amplitude: /amplitude/,
33
+ hotjar: /hotjar/,
34
+ vercelInsights: /\/_vercel\/insights/,
35
+ };
36
+
37
+ const TAILWIND_UTIL = /(^|\s)(flex|grid|block|inline|hidden|text-(xs|sm|base|lg|xl|\d+xl)|text-(gray|slate|zinc|red|blue|green|amber|neutral|stone)-\d+|bg-(gray|slate|zinc|red|blue|green|amber|neutral|stone)-\d+|p[xy]?-\d+|m[xy]?-\d+|gap-\d+|rounded(-\w+)?|shadow(-\w+)?|items-(start|center|end|baseline|stretch)|justify-(start|center|end|between|around|evenly)|grid-cols-\d+|col-span-\d+)(\s|$)/;
38
+
39
+ function detectFramework(signals) {
40
+ for (const g of signals.windowGlobals || []) {
41
+ if (FRAMEWORK_BY_GLOBAL[g]) return FRAMEWORK_BY_GLOBAL[g];
42
+ }
43
+ for (const s of signals.scripts || []) {
44
+ for (const [re, name] of SCRIPT_PATTERNS) if (re.test(s)) return name;
45
+ }
46
+ return 'unknown';
47
+ }
48
+
49
+ function detectTailwind(signals) {
50
+ const classes = (signals.classNameSample || []).filter(c => typeof c === 'string');
51
+ const hits = classes.filter(c => TAILWIND_UTIL.test(c));
52
+ if (hits.length < Math.max(5, classes.length * 0.1)) return null;
53
+ const utilFreq = new Map();
54
+ for (const c of hits) {
55
+ for (const u of c.split(/\s+/).filter(Boolean)) {
56
+ utilFreq.set(u, (utilFreq.get(u) || 0) + 1);
57
+ }
58
+ }
59
+ const topUtilities = [...utilFreq.entries()]
60
+ .sort((a, b) => b[1] - a[1])
61
+ .slice(0, 100)
62
+ .map(([u, n]) => ({ utility: u, count: n }));
63
+ return { detected: true, utilities: topUtilities };
64
+ }
65
+
66
+ function detectAnalytics(signals) {
67
+ const found = [];
68
+ for (const [name, re] of Object.entries(ANALYTICS)) {
69
+ if ((signals.scripts || []).some(s => re.test(s))) found.push(name);
70
+ }
71
+ return found;
72
+ }
73
+
74
+ export function extractStackFingerprint(signals = {}) {
75
+ const framework = detectFramework(signals);
76
+ const tailwind = detectTailwind(signals);
77
+ const css = { layer: tailwind ? 'tailwind' : 'unknown', tailwind: tailwind || null };
78
+ return {
79
+ framework,
80
+ css,
81
+ analytics: detectAnalytics(signals),
82
+ detectedFrom: {
83
+ globalCount: (signals.windowGlobals || []).length,
84
+ scriptCount: (signals.scripts || []).length,
85
+ classSampleSize: (signals.classNameSample || []).length,
86
+ },
87
+ };
88
+ }
@@ -18,5 +18,24 @@ export function extractVariables(cssVariables) {
18
18
  }
19
19
  }
20
20
 
21
- return categories;
21
+ // Build dependency map: which variables reference other variables
22
+ const dependencies = {};
23
+ for (const [name, value] of Object.entries(cssVariables)) {
24
+ const refs = [...value.matchAll(/var\((--[\w-]+)/g)].map(m => m[1]);
25
+ if (refs.length > 0) {
26
+ dependencies[name] = refs;
27
+ }
28
+ }
29
+
30
+ // Semantic grouping by name patterns
31
+ const semantic = { success: {}, warning: {}, error: {}, info: {} };
32
+ for (const [name, value] of Object.entries(cssVariables)) {
33
+ const lower = name.toLowerCase();
34
+ if (/success|green|valid|positive/.test(lower)) semantic.success[name] = value;
35
+ else if (/warning|warn|yellow|caution|amber/.test(lower)) semantic.warning[name] = value;
36
+ else if (/error|danger|destructive|red|invalid|negative/.test(lower)) semantic.error[name] = value;
37
+ else if (/info|informati|blue|notice/.test(lower)) semantic.info[name] = value;
38
+ }
39
+
40
+ return { ...categories, dependencies, semantic };
22
41
  }