designlang 4.0.1 → 6.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 (40) hide show
  1. package/README.md +66 -5
  2. package/bin/design-extract.js +269 -70
  3. package/package.json +9 -4
  4. package/src/apply.js +65 -0
  5. package/src/config.js +36 -0
  6. package/src/crawler.js +247 -82
  7. package/src/darkdiff.js +65 -0
  8. package/src/extractors/animations.js +76 -8
  9. package/src/extractors/borders.js +40 -5
  10. package/src/extractors/components.js +100 -1
  11. package/src/extractors/fonts.js +82 -0
  12. package/src/extractors/gradients.js +100 -0
  13. package/src/extractors/icons.js +80 -0
  14. package/src/extractors/images.js +76 -0
  15. package/src/extractors/shadows.js +60 -17
  16. package/src/extractors/spacing.js +31 -2
  17. package/src/extractors/variables.js +20 -1
  18. package/src/extractors/zindex.js +65 -0
  19. package/src/formatters/figma.js +66 -47
  20. package/src/formatters/markdown.js +98 -0
  21. package/src/formatters/preview.js +65 -22
  22. package/src/formatters/svelte-theme.js +40 -0
  23. package/src/formatters/tailwind.js +57 -4
  24. package/src/formatters/theme.js +134 -0
  25. package/src/formatters/vue-theme.js +44 -0
  26. package/src/formatters/wordpress.js +84 -0
  27. package/src/history.js +8 -1
  28. package/src/index.js +54 -16
  29. package/src/utils.js +68 -0
  30. package/tests/cli.test.js +34 -0
  31. package/tests/extractors.test.js +661 -0
  32. package/tests/formatters.test.js +477 -0
  33. package/tests/utils.test.js +413 -0
  34. package/website/app/api/extract/route.js +85 -0
  35. package/website/app/components/Extractor.js +184 -0
  36. package/website/app/globals.css +291 -0
  37. package/website/app/page.js +13 -0
  38. package/website/next.config.mjs +10 -1
  39. package/website/package-lock.json +356 -0
  40. package/website/package.json +4 -1
@@ -1,14 +1,46 @@
1
1
  import { parseCSSValue, clusterValues } from '../utils.js';
2
2
 
3
+ function parseBorderRadius(raw) {
4
+ if (!raw || raw === '0px') return [];
5
+ // Handle slash-separated (x/y) syntax — take the x part
6
+ const parts = raw.split('/')[0].trim().split(/\s+/);
7
+ const values = [];
8
+ for (const p of parts) {
9
+ const v = parseCSSValue(p);
10
+ if (v && v.value > 0) values.push(Math.round(v.value));
11
+ }
12
+ return values;
13
+ }
14
+
3
15
  export function extractBorders(computedStyles) {
4
16
  const radiiSet = new Map(); // value -> count
17
+ const widthSet = new Set();
18
+ const styleSet = new Set();
5
19
 
6
20
  for (const el of computedStyles) {
7
21
  if (el.borderRadius && el.borderRadius !== '0px') {
8
- const val = parseCSSValue(el.borderRadius.split(' ')[0]);
9
- if (val && val.value > 0) {
10
- const px = Math.round(val.value);
11
- radiiSet.set(px, (radiiSet.get(px) || 0) + 1);
22
+ const values = parseBorderRadius(el.borderRadius);
23
+ if (values.length > 0) {
24
+ // Use the max value from the shorthand as the representative
25
+ const representative = Math.max(...values);
26
+ radiiSet.set(representative, (radiiSet.get(representative) || 0) + 1);
27
+ }
28
+ }
29
+
30
+ // Collect border widths
31
+ if (el.borderWidth) {
32
+ const parts = el.borderWidth.split(/\s+/);
33
+ for (const p of parts) {
34
+ const v = parseCSSValue(p);
35
+ if (v && v.value > 0) widthSet.add(Math.round(v.value));
36
+ }
37
+ }
38
+
39
+ // Collect border styles
40
+ if (el.borderStyle) {
41
+ const parts = el.borderStyle.split(/\s+/);
42
+ for (const p of parts) {
43
+ if (p && p !== 'none' && p !== 'initial') styleSet.add(p);
12
44
  }
13
45
  }
14
46
  }
@@ -27,5 +59,8 @@ export function extractBorders(computedStyles) {
27
59
  return { value: v, label, count: radiiSet.get(v) || 0 };
28
60
  });
29
61
 
30
- return { radii };
62
+ const widths = [...widthSet].sort((a, b) => a - b);
63
+ const styles = [...styleSet].sort();
64
+
65
+ return { radii, widths, styles };
31
66
  }
@@ -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,9 +149,90 @@ 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
+
210
+ // Generate CSS snippets for each component
211
+ for (const [type, data] of Object.entries(components)) {
212
+ if (data.baseStyle) {
213
+ const style = type === 'tables' ? { ...data.baseStyle } : data.baseStyle;
214
+ delete style.cellStyle;
215
+ data.css = styleToCss(`.${type.replace(/s$/, '')}`, style);
216
+ }
217
+ }
218
+
134
219
  return components;
135
220
  }
136
221
 
222
+ function styleToCss(selector, style) {
223
+ const propMap = {
224
+ backgroundColor: 'background-color', color: 'color', fontSize: 'font-size',
225
+ fontWeight: 'font-weight', paddingTop: 'padding-top', paddingRight: 'padding-right',
226
+ paddingBottom: 'padding-bottom', paddingLeft: 'padding-left',
227
+ borderRadius: 'border-radius', boxShadow: 'box-shadow', borderColor: 'border-color',
228
+ maxWidth: 'max-width', position: 'position',
229
+ };
230
+ const lines = Object.entries(style)
231
+ .filter(([, v]) => v)
232
+ .map(([k, v]) => ` ${propMap[k] || k}: ${v};`);
233
+ return `${selector} {\n${lines.join('\n')}\n}`;
234
+ }
235
+
137
236
  function mostCommonStyle(elements, properties) {
138
237
  const style = {};
139
238
  for (const prop of properties) {
@@ -0,0 +1,82 @@
1
+ export function extractFonts({ fontFaces = [], googleFontsLinks = [], documentFonts = [] }) {
2
+ const fontMap = new Map();
3
+
4
+ // Parse Google Fonts URLs into a lookup: family -> weights
5
+ const googleFamilies = new Map();
6
+ for (const url of googleFontsLinks) {
7
+ const params = new URL(url).searchParams;
8
+ for (const val of (params.getAll('family'))) {
9
+ const [name, spec] = val.split(':');
10
+ const family = name.replace(/\+/g, ' ');
11
+ const weights = spec?.match(/\d{3}/g) || ['400'];
12
+ googleFamilies.set(family, [...new Set([...(googleFamilies.get(family) || []), ...weights])]);
13
+ }
14
+ }
15
+
16
+ const getSource = (family, src) => {
17
+ if (googleFamilies.has(family)) return 'google-fonts';
18
+ if (src && /url\(/.test(src)) return /fonts\.(googleapis|gstatic|cdnfonts|bunny)/.test(src) ? 'cdn' : 'self-hosted';
19
+ return 'system';
20
+ };
21
+
22
+ const getOrCreate = (family) => {
23
+ if (!fontMap.has(family)) {
24
+ fontMap.set(family, { family, source: 'system', weights: new Set(), styles: new Set(), urls: [], fontFaceCSS: '' });
25
+ }
26
+ return fontMap.get(family);
27
+ };
28
+
29
+ // Process @font-face rules
30
+ for (const ff of fontFaces) {
31
+ const family = ff.family?.replace(/["']/g, '');
32
+ if (!family) continue;
33
+ const entry = getOrCreate(family);
34
+ entry.source = getSource(family, ff.src);
35
+ if (ff.weight) entry.weights.add(String(ff.weight));
36
+ if (ff.style) entry.styles.add(ff.style);
37
+ if (ff.src) entry.urls.push(ff.src);
38
+ }
39
+
40
+ // Process document.fonts entries
41
+ for (const df of documentFonts) {
42
+ const family = df.family?.replace(/["']/g, '');
43
+ if (!family) continue;
44
+ const entry = getOrCreate(family);
45
+ if (entry.source === 'system') entry.source = getSource(family, '');
46
+ if (df.weight) entry.weights.add(String(df.weight));
47
+ if (df.style) entry.styles.add(df.style);
48
+ }
49
+
50
+ // Add Google Fonts families not yet seen
51
+ for (const [family, weights] of googleFamilies) {
52
+ const entry = getOrCreate(family);
53
+ entry.source = 'google-fonts';
54
+ for (const w of weights) entry.weights.add(w);
55
+ }
56
+
57
+ // Build output
58
+ const fonts = [];
59
+ const systemFonts = [];
60
+
61
+ for (const entry of fontMap.values()) {
62
+ const weights = [...entry.weights].sort();
63
+ const styles = [...entry.styles];
64
+ if (!weights.length) weights.push('400');
65
+ if (!styles.length) styles.push('normal');
66
+
67
+ const fontFaceCSS = entry.source === 'self-hosted'
68
+ ? entry.urls.map((src, i) =>
69
+ `@font-face {\n font-family: '${entry.family}';\n font-weight: ${weights[i] || weights[0]};\n font-style: ${styles[i] || styles[0]};\n src: ${src};\n}`
70
+ ).join('\n\n')
71
+ : '';
72
+
73
+ if (entry.source === 'system') { systemFonts.push(entry.family); continue; }
74
+ fonts.push({ family: entry.family, source: entry.source, weights, styles, urls: entry.urls, fontFaceCSS });
75
+ }
76
+
77
+ const googleFontsUrl = googleFontsLinks[0] || (googleFamilies.size
78
+ ? `https://fonts.googleapis.com/css2?${[...googleFamilies].map(([f, w]) => `family=${f.replace(/ /g, '+')}:wght@${w.join(';')}`).join('&')}&display=swap`
79
+ : '');
80
+
81
+ return { fonts, googleFontsUrl, systemFonts };
82
+ }
@@ -0,0 +1,100 @@
1
+ export function extractGradients(styles) {
2
+ const seen = new Set();
3
+ const gradients = [];
4
+
5
+ for (const el of styles) {
6
+ const bg = el.backgroundImage;
7
+ if (!bg || !bg.includes('gradient')) continue;
8
+ const rawGradients = splitGradients(bg);
9
+ for (let raw of rawGradients) {
10
+ // Normalize vendor prefixes
11
+ raw = raw.replace(/-(webkit|moz)-/g, '');
12
+ if (seen.has(raw)) continue;
13
+ seen.add(raw);
14
+ gradients.push(parseGradient(raw));
15
+ }
16
+ }
17
+
18
+ return { gradients, count: gradients.length };
19
+ }
20
+
21
+ function splitGradients(value) {
22
+ // Split comma-separated gradient layers, respecting nested parens
23
+ const results = [];
24
+ let depth = 0, start = 0;
25
+ for (let i = 0; i < value.length; i++) {
26
+ if (value[i] === '(') depth++;
27
+ else if (value[i] === ')') depth--;
28
+ else if (value[i] === ',' && depth === 0) {
29
+ const chunk = value.slice(start, i).trim();
30
+ if (chunk.includes('gradient')) results.push(chunk);
31
+ start = i + 1;
32
+ }
33
+ }
34
+ const last = value.slice(start).trim();
35
+ if (last.includes('gradient')) results.push(last);
36
+ return results;
37
+ }
38
+
39
+ function parseGradient(raw) {
40
+ const typeMatch = raw.match(/^(repeating-)?(linear|radial|conic)-gradient/);
41
+ const type = typeMatch ? (typeMatch[1] || '') + typeMatch[2] : 'linear';
42
+
43
+ // Extract content inside outermost parens
44
+ const inner = raw.slice(raw.indexOf('(') + 1, raw.lastIndexOf(')'));
45
+
46
+ // Split top-level arguments by comma (respecting nested parens)
47
+ const args = [];
48
+ let depth = 0, start = 0;
49
+ for (let i = 0; i < inner.length; i++) {
50
+ if (inner[i] === '(') depth++;
51
+ else if (inner[i] === ')') depth--;
52
+ else if (inner[i] === ',' && depth === 0) {
53
+ args.push(inner.slice(start, i).trim());
54
+ start = i + 1;
55
+ }
56
+ }
57
+ args.push(inner.slice(start).trim());
58
+
59
+ // First arg is direction/angle if it doesn't look like a color
60
+ let direction = null;
61
+ let stopArgs = args;
62
+ const first = args[0] || '';
63
+ if (/^(to |from |\d+(\.\d+)?(deg|grad|rad|turn)|at )/.test(first) || /^(circle|ellipse)/.test(first)) {
64
+ direction = first;
65
+ stopArgs = args.slice(1);
66
+ }
67
+
68
+ const stops = stopArgs.map(s => {
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
+ }
90
+ return { color, position };
91
+ });
92
+
93
+ const colorCount = stops.length;
94
+ let classification = 'subtle';
95
+ if (colorCount > 4) classification = 'complex';
96
+ else if (colorCount > 2) classification = 'bold';
97
+ else if (colorCount === 2) classification = 'brand';
98
+
99
+ return { raw, type, direction, stops, classification };
100
+ }
@@ -0,0 +1,80 @@
1
+ export function extractIcons(iconData) {
2
+ if (!iconData || !iconData.length) {
3
+ return { icons: [], sizeDistribution: { xs: 0, sm: 0, md: 0, lg: 0, xl: 0 }, dominantStyle: 'none', colorPalette: [], count: 0 };
4
+ }
5
+
6
+ function classifySize(w, h) {
7
+ const s = Math.max(w || 0, h || 0);
8
+ if (s < 16) return 'xs';
9
+ if (s < 20) return 'sm';
10
+ if (s < 28) return 'md';
11
+ if (s < 40) return 'lg';
12
+ return 'xl';
13
+ }
14
+
15
+ function cleanSvg(svg) {
16
+ return svg.replace(/\s*(data-[a-z-]*|class|id)="[^"]*"/g, '').replace(/\s+/g, ' ').trim();
17
+ }
18
+ function simplify(svg) { return cleanSvg(svg); }
19
+
20
+ function detectStyle(svg) {
21
+ const hasStroke = /stroke="(?!none)[^"]+"|stroke-width="[^0"][^"]*"/.test(svg);
22
+ const hasFill = /fill="(?!none|transparent)[^"]+"|<(rect|circle|path)[^>]*(?!fill="none")/.test(svg);
23
+ const fillNone = /fill="none"/.test(svg);
24
+ if (hasStroke && (fillNone || !hasFill)) return 'outlined';
25
+ if (hasStroke && hasFill) return 'duo-tone';
26
+ return 'filled';
27
+ }
28
+
29
+ function extractColors(svg) {
30
+ const colors = new Set();
31
+ for (const m of svg.matchAll(/(?:fill|stroke)="([^"]+)"/g)) {
32
+ if (m[1] !== 'none' && m[1] !== 'transparent') colors.add(m[1]);
33
+ }
34
+ return [...colors];
35
+ }
36
+
37
+ // Deduplicate
38
+ const seen = new Map();
39
+ for (const icon of iconData) {
40
+ const key = simplify(icon.svg);
41
+ if (!seen.has(key)) seen.set(key, icon);
42
+ }
43
+
44
+ const sizeDistribution = { xs: 0, sm: 0, md: 0, lg: 0, xl: 0 };
45
+ const styleCounts = { outlined: 0, filled: 0, 'duo-tone': 0 };
46
+ const allColors = new Set();
47
+
48
+ const icons = [];
49
+ for (const icon of seen.values()) {
50
+ const cleaned = cleanSvg(icon.svg);
51
+ const sc = classifySize(icon.width, icon.height);
52
+ const style = detectStyle(icon.svg);
53
+ const colors = extractColors(icon.svg);
54
+ if (icon.fill && icon.fill !== 'none') colors.push(icon.fill);
55
+ if (icon.stroke && icon.stroke !== 'none') colors.push(icon.stroke);
56
+ const uniqueColors = [...new Set(colors)];
57
+
58
+ sizeDistribution[sc]++;
59
+ styleCounts[style]++;
60
+ uniqueColors.forEach(c => allColors.add(c));
61
+
62
+ icons.push({
63
+ svg: cleaned,
64
+ size: { width: icon.width, height: icon.height },
65
+ sizeClass: sc,
66
+ style,
67
+ colors: uniqueColors,
68
+ });
69
+ }
70
+
71
+ const dominantStyle = Object.entries(styleCounts).sort((a, b) => b[1] - a[1])[0][0];
72
+
73
+ return {
74
+ icons,
75
+ sizeDistribution,
76
+ dominantStyle,
77
+ colorPalette: [...allColors],
78
+ count: icons.length,
79
+ };
80
+ }
@@ -0,0 +1,76 @@
1
+ export function extractImageStyles(imageData) {
2
+ const ratioCount = new Map();
3
+ const shapeCount = new Map();
4
+ const filterCount = new Map();
5
+ const fitCount = new Map();
6
+ const patternCount = new Map();
7
+
8
+ const knownRatios = [
9
+ [1, 1, '1:1'], [4, 3, '4:3'], [3, 4, '3:4'], [16, 9, '16:9'], [9, 16, '9:16'],
10
+ [3, 2, '3:2'], [2, 3, '2:3'], [21, 9, '21:9'],
11
+ ];
12
+
13
+ function closestRatio(w, h) {
14
+ if (!w || !h) return null;
15
+ const r = w / h;
16
+ let best = null, bestDiff = 0.15;
17
+ for (const [rw, rh, label] of knownRatios) {
18
+ const diff = Math.abs(r - rw / rh);
19
+ if (diff < bestDiff) { best = label; bestDiff = diff; }
20
+ }
21
+ return best || `${Math.round(r * 100) / 100}:1`;
22
+ }
23
+
24
+ function classifyShape(borderRadius) {
25
+ const br = parseFloat(borderRadius) || 0;
26
+ if (br >= 50) return 'circular';
27
+ if (br >= 20) return 'pill';
28
+ if (br > 0) return 'rounded';
29
+ return 'square';
30
+ }
31
+
32
+ function classifyPattern(img, shape) {
33
+ const w = img.width || 0, h = img.height || 0;
34
+ const area = w * h;
35
+ if (shape === 'circular' && area <= 22500) return 'avatar';
36
+ if (w >= 600 && h >= 200 && img.objectFit === 'cover') return 'hero';
37
+ if (area <= 40000 && (shape === 'rounded' || shape === 'square')) return 'thumbnail';
38
+ if (w >= 400 && h >= 400 && shape === 'square') return 'gallery';
39
+ return 'general';
40
+ }
41
+
42
+ function incMap(map, key, extra) {
43
+ if (!map.has(key)) map.set(key, { count: 0, ...extra });
44
+ map.get(key).count++;
45
+ }
46
+
47
+ for (const img of imageData) {
48
+ const ratio = closestRatio(img.width, img.height);
49
+ if (ratio) incMap(ratioCount, ratio);
50
+
51
+ const shape = classifyShape(img.borderRadius);
52
+ incMap(shapeCount, shape, { borderRadius: img.borderRadius || '0' });
53
+
54
+ if (img.filter && img.filter !== 'none') {
55
+ for (const f of img.filter.match(/[a-z-]+\(/g) || [img.filter]) {
56
+ incMap(filterCount, f.replace('(', ''));
57
+ }
58
+ }
59
+
60
+ if (img.objectFit && img.objectFit !== 'initial') incMap(fitCount, img.objectFit);
61
+
62
+ const pattern = classifyPattern(img, shape);
63
+ incMap(patternCount, pattern, { styles: { objectFit: img.objectFit, borderRadius: img.borderRadius, shape } });
64
+ }
65
+
66
+ const toArray = (map, keyName) =>
67
+ Array.from(map.entries()).map(([k, v]) => ({ [keyName]: k, ...v })).sort((a, b) => b.count - a.count);
68
+
69
+ return {
70
+ patterns: toArray(patternCount, 'name'),
71
+ aspectRatios: toArray(ratioCount, 'ratio'),
72
+ shapes: toArray(shapeCount, 'shape'),
73
+ filters: toArray(filterCount, 'filter'),
74
+ objectFitUsage: toArray(fitCount, 'value'),
75
+ };
76
+ }
@@ -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
@@ -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
  }