designlang 5.0.0 → 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.
package/src/crawler.js CHANGED
@@ -4,107 +4,121 @@ import { join } from 'path';
4
4
 
5
5
  const MAX_ELEMENTS = 5000;
6
6
 
7
+ async function gotoWithRetry(page, url, opts, retries = 3) {
8
+ for (let i = 0; i < retries; i++) {
9
+ try {
10
+ await page.goto(url, opts);
11
+ return;
12
+ } catch (err) {
13
+ if (i === retries - 1) throw err;
14
+ await page.waitForTimeout(2000 * (i + 1));
15
+ }
16
+ }
17
+ }
18
+
7
19
  export async function crawlPage(url, options = {}) {
8
- const { width = 1280, height = 800, wait = 0, dark = false, depth = 0, screenshots = false, outDir = '', executablePath, browserArgs, cookies, headers } = options;
20
+ const { width = 1280, height = 800, wait = 0, dark = false, depth = 0, screenshots = false, outDir = '', executablePath, browserArgs, cookies, headers, ignore } = options;
9
21
 
10
22
  const browser = await chromium.launch({
11
23
  headless: true,
12
24
  ...(executablePath && { executablePath }),
13
25
  ...(browserArgs && { args: browserArgs }),
14
26
  });
15
- const context = await browser.newContext({
16
- viewport: { width, height },
17
- colorScheme: 'light',
18
- ...(headers && { extraHTTPHeaders: headers }),
19
- });
20
-
21
- // Set cookies if provided
22
- if (cookies && cookies.length > 0) {
23
- await context.addCookies(cookies.map(c => {
24
- if (typeof c === 'string') {
25
- const [name, ...rest] = c.split('=');
26
- return { name, value: rest.join('='), url };
27
- }
28
- return c;
29
- }));
30
- }
31
- const page = await context.newPage();
27
+ try {
28
+ const context = await browser.newContext({
29
+ viewport: { width, height },
30
+ colorScheme: 'light',
31
+ ...(headers && { extraHTTPHeaders: headers }),
32
+ });
32
33
 
33
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
34
- // Wait for network to settle — but don't hang on sites with persistent connections
35
- await page.waitForLoadState('networkidle').catch(() => {});
36
- if (wait > 0) await page.waitForTimeout(wait);
37
- await page.evaluate(() => document.fonts.ready).catch(() => {});
34
+ // Set cookies if provided
35
+ if (cookies && cookies.length > 0) {
36
+ await context.addCookies(cookies.map(c => {
37
+ if (typeof c === 'string') {
38
+ const [name, ...rest] = c.split('=');
39
+ return { name, value: rest.join('='), url };
40
+ }
41
+ return c;
42
+ }));
43
+ }
44
+ const page = await context.newPage();
38
45
 
39
- const title = await page.title();
40
- const lightData = await extractPageData(page);
46
+ await gotoWithRetry(page, url, { waitUntil: 'domcontentloaded', timeout: 30000 });
47
+ // Wait for network to settle — but don't hang on sites with persistent connections
48
+ await page.waitForLoadState('networkidle').catch(() => {});
49
+ if (wait > 0) await page.waitForTimeout(wait);
50
+ await page.evaluate(() => document.fonts.ready).catch(() => {});
41
51
 
42
- // Component screenshots
43
- let componentScreenshots = {};
44
- if (screenshots && outDir) {
45
- componentScreenshots = await captureComponentScreenshots(page, outDir);
46
- }
52
+ const title = await page.title();
53
+ const lightData = await extractPageData(page, ignore);
47
54
 
48
- // Multi-page crawl: discover internal links and extract from them
49
- let additionalPages = [];
50
- if (depth > 0) {
51
- const internalLinks = await discoverInternalLinks(page, url, depth);
52
- for (const link of internalLinks) {
53
- try {
54
- await page.goto(link, { waitUntil: 'domcontentloaded', timeout: 20000 });
55
- await page.waitForLoadState('networkidle').catch(() => {});
56
- await page.evaluate(() => document.fonts.ready).catch(() => {});
57
- const pageData = await extractPageData(page);
58
- additionalPages.push({ url: link, data: pageData });
59
- } catch { /* skip failed pages */ }
55
+ // Component screenshots
56
+ let componentScreenshots = {};
57
+ if (screenshots && outDir) {
58
+ componentScreenshots = await captureComponentScreenshots(page, outDir);
60
59
  }
61
- }
62
60
 
63
- // Dark mode extraction
64
- let darkData = null;
65
- if (dark) {
66
- await context.close();
67
- const darkContext = await browser.newContext({
68
- viewport: { width, height },
69
- colorScheme: 'dark',
70
- });
71
- const darkPage = await darkContext.newPage();
72
- await darkPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
73
- await darkPage.waitForLoadState('networkidle').catch(() => {});
74
- await darkPage.evaluate(() => document.fonts.ready).catch(() => {});
75
- darkData = await extractPageData(darkPage);
76
- await darkContext.close();
77
- } else {
78
- await context.close();
79
- }
61
+ // Multi-page crawl: discover internal links and extract from them
62
+ let additionalPages = [];
63
+ if (depth > 0) {
64
+ const internalLinks = await discoverInternalLinks(page, url, depth);
65
+ for (const link of internalLinks) {
66
+ try {
67
+ await gotoWithRetry(page, link, { waitUntil: 'domcontentloaded', timeout: 20000 });
68
+ await page.waitForLoadState('networkidle').catch(() => {});
69
+ await page.evaluate(() => document.fonts.ready).catch(() => {});
70
+ const pageData = await extractPageData(page);
71
+ additionalPages.push({ url: link, data: pageData });
72
+ } catch { /* skip failed pages */ }
73
+ }
74
+ }
80
75
 
81
- await browser.close();
76
+ // Dark mode extraction
77
+ let darkData = null;
78
+ if (dark) {
79
+ await context.close();
80
+ const darkContext = await browser.newContext({
81
+ viewport: { width, height },
82
+ colorScheme: 'dark',
83
+ });
84
+ const darkPage = await darkContext.newPage();
85
+ await gotoWithRetry(darkPage, url, { waitUntil: 'domcontentloaded', timeout: 30000 });
86
+ await darkPage.waitForLoadState('networkidle').catch(() => {});
87
+ await darkPage.evaluate(() => document.fonts.ready).catch(() => {});
88
+ darkData = await extractPageData(darkPage);
89
+ await darkContext.close();
90
+ } else {
91
+ await context.close();
92
+ }
82
93
 
83
- // Merge additional page data into light data
84
- if (additionalPages.length > 0) {
85
- lightData.computedStyles = mergeStyles(lightData.computedStyles, additionalPages);
86
- for (const ap of additionalPages) {
87
- Object.assign(lightData.cssVariables, ap.data.cssVariables);
88
- lightData.mediaQueries.push(...ap.data.mediaQueries);
89
- lightData.keyframes.push(...ap.data.keyframes);
94
+ // Merge additional page data into light data
95
+ if (additionalPages.length > 0) {
96
+ lightData.computedStyles = mergeStyles(lightData.computedStyles, additionalPages);
97
+ for (const ap of additionalPages) {
98
+ Object.assign(lightData.cssVariables, ap.data.cssVariables);
99
+ lightData.mediaQueries.push(...ap.data.mediaQueries);
100
+ lightData.keyframes.push(...ap.data.keyframes);
101
+ }
102
+ // Deduplicate media queries and keyframes
103
+ lightData.mediaQueries = [...new Set(lightData.mediaQueries)];
104
+ const seenKf = new Set();
105
+ lightData.keyframes = lightData.keyframes.filter(kf => {
106
+ if (seenKf.has(kf.name)) return false;
107
+ seenKf.add(kf.name);
108
+ return true;
109
+ });
90
110
  }
91
- // Deduplicate media queries and keyframes
92
- lightData.mediaQueries = [...new Set(lightData.mediaQueries)];
93
- const seenKf = new Set();
94
- lightData.keyframes = lightData.keyframes.filter(kf => {
95
- if (seenKf.has(kf.name)) return false;
96
- seenKf.add(kf.name);
97
- return true;
98
- });
99
- }
100
111
 
101
- return {
102
- url, title,
103
- light: lightData,
104
- dark: darkData,
105
- pagesAnalyzed: 1 + additionalPages.length,
106
- componentScreenshots,
107
- };
112
+ return {
113
+ url, title,
114
+ light: lightData,
115
+ dark: darkData,
116
+ pagesAnalyzed: 1 + additionalPages.length,
117
+ componentScreenshots,
118
+ };
119
+ } finally {
120
+ await browser.close();
121
+ }
108
122
  }
109
123
 
110
124
  function mergeStyles(primary, additionalPages) {
@@ -174,19 +188,39 @@ export async function captureComponentScreenshots(page, outDir) {
174
188
  return result;
175
189
  }
176
190
 
177
- async function extractPageData(page) {
178
- return page.evaluate((maxElements) => {
191
+ async function extractPageData(page, ignoreSelectors) {
192
+ const data = await page.evaluate(({ maxElements, ignoreSelectors }) => {
193
+ // Remove ignored elements before extraction
194
+ if (ignoreSelectors && ignoreSelectors.length > 0) {
195
+ for (const sel of ignoreSelectors) {
196
+ try {
197
+ for (const el of document.querySelectorAll(sel)) {
198
+ el.remove();
199
+ }
200
+ } catch { /* invalid selector */ }
201
+ }
202
+ }
203
+
179
204
  const results = {
180
205
  computedStyles: [],
181
206
  cssVariables: {},
182
207
  mediaQueries: [],
183
208
  keyframes: [],
209
+ crossOriginSheets: [],
184
210
  };
185
211
 
186
- const allElements = document.querySelectorAll('*');
187
- const elements = allElements.length > maxElements
188
- ? Array.from(allElements).slice(0, maxElements)
189
- : Array.from(allElements);
212
+ // Collect elements including shadow DOM contents
213
+ function collectElements(root, collected) {
214
+ for (const el of root.querySelectorAll('*')) {
215
+ if (collected.length >= maxElements) break;
216
+ collected.push(el);
217
+ if (el.shadowRoot) {
218
+ collectElements(el.shadowRoot, collected);
219
+ }
220
+ }
221
+ return collected;
222
+ }
223
+ const elements = collectElements(document, []);
190
224
 
191
225
  for (const el of elements) {
192
226
  const cs = getComputedStyle(el);
@@ -217,7 +251,10 @@ async function extractPageData(page) {
217
251
  marginLeft: cs.marginLeft,
218
252
  gap: cs.gap,
219
253
  borderRadius: cs.borderRadius,
254
+ borderWidth: cs.borderWidth,
255
+ borderStyle: cs.borderStyle,
220
256
  boxShadow: cs.boxShadow,
257
+ textShadow: cs.textShadow,
221
258
  zIndex: cs.zIndex,
222
259
  transition: cs.transition,
223
260
  animation: cs.animation,
@@ -248,7 +285,7 @@ async function extractPageData(page) {
248
285
  }
249
286
  }
250
287
  }
251
- } catch { /* cross-origin */ }
288
+ } catch { if (sheet.href) results.crossOriginSheets.push(sheet.href); }
252
289
  }
253
290
  } catch { /* no access */ }
254
291
 
@@ -268,7 +305,7 @@ async function extractPageData(page) {
268
305
  results.mediaQueries.push(rule.conditionText || rule.media.mediaText);
269
306
  }
270
307
  }
271
- } catch { /* cross-origin */ }
308
+ } catch { /* cross-origin — already tracked */ }
272
309
  }
273
310
  } catch { /* no access */ }
274
311
 
@@ -285,7 +322,7 @@ async function extractPageData(page) {
285
322
  results.keyframes.push({ name: rule.name, steps });
286
323
  }
287
324
  }
288
- } catch { /* cross-origin */ }
325
+ } catch { /* cross-origin — already tracked */ }
289
326
  }
290
327
  } catch { /* no access */ }
291
328
 
@@ -321,7 +358,7 @@ async function extractPageData(page) {
321
358
  });
322
359
  }
323
360
  }
324
- } catch { /* cross-origin */ }
361
+ } catch { /* cross-origin — already tracked */ }
325
362
  }
326
363
  } catch {}
327
364
  for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com"]')) {
@@ -353,5 +390,54 @@ async function extractPageData(page) {
353
390
  }
354
391
 
355
392
  return results;
356
- }, MAX_ELEMENTS);
393
+ }, { maxElements: MAX_ELEMENTS, ignoreSelectors: ignoreSelectors || [] });
394
+
395
+ // Fetch and parse cross-origin stylesheets
396
+ if (data.crossOriginSheets && data.crossOriginSheets.length > 0) {
397
+ const seen = new Set();
398
+ for (const href of data.crossOriginSheets) {
399
+ if (seen.has(href)) continue;
400
+ seen.add(href);
401
+ try {
402
+ const cssText = await page.evaluate(async (url) => {
403
+ const res = await fetch(url, { mode: 'cors' });
404
+ return res.text();
405
+ }, href);
406
+ parseCrossOriginCSS(cssText, data);
407
+ } catch { /* fetch failed too */ }
408
+ }
409
+ }
410
+ delete data.crossOriginSheets;
411
+
412
+ return data;
413
+ }
414
+
415
+ function parseCrossOriginCSS(cssText, data) {
416
+ // Media queries
417
+ for (const m of cssText.matchAll(/@media\s*([^{]+)\{/g)) {
418
+ data.mediaQueries.push(m[1].trim());
419
+ }
420
+ // Keyframes
421
+ for (const m of cssText.matchAll(/@keyframes\s+([\w-]+)\s*\{([\s\S]*?)\n\}/g)) {
422
+ const steps = [];
423
+ for (const s of m[2].matchAll(/([\d%,\s]+|from|to)\s*\{([^}]*)\}/g)) {
424
+ steps.push({ offset: s[1].trim(), style: s[2].trim() });
425
+ }
426
+ if (steps.length > 0) data.keyframes.push({ name: m[1], steps });
427
+ }
428
+ // :root variables
429
+ for (const rootBlock of cssText.matchAll(/:root\s*\{([^}]+)\}/g)) {
430
+ for (const v of rootBlock[1].matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
431
+ if (!data.cssVariables[v[1]]) data.cssVariables[v[1]] = v[2].trim();
432
+ }
433
+ }
434
+ // @font-face
435
+ for (const m of cssText.matchAll(/@font-face\s*\{([^}]+)\}/g)) {
436
+ const block = m[1];
437
+ const family = block.match(/font-family\s*:\s*['"]?([^'";]+)/)?.[1]?.trim();
438
+ const style = block.match(/font-style\s*:\s*([^;]+)/)?.[1]?.trim() || 'normal';
439
+ const weight = block.match(/font-weight\s*:\s*([^;]+)/)?.[1]?.trim() || '400';
440
+ const src = block.match(/src\s*:\s*([^;]+)/)?.[1]?.trim() || '';
441
+ if (family) data.fontData.fontFaces.push({ family, style, weight, src });
442
+ }
357
443
  }
@@ -9,11 +9,11 @@ export function extractAnimations(computedStyles, keyframes) {
9
9
  if (el.transition && el.transition !== 'all 0s ease 0s' && el.transition !== 'none') {
10
10
  transitionSet.add(el.transition);
11
11
 
12
- // Extract easing and duration
13
- const dMatch = el.transition.match(/([\d.]+m?s)/g);
12
+ // Extract duration only match standalone duration values, not inside functions
13
+ const dMatch = el.transition.match(/(?<![(\d])(\d+\.?\d*m?s)(?![)\w])/g);
14
14
  if (dMatch) dMatch.forEach(d => durationSet.add(d));
15
15
 
16
- const eMatch = el.transition.match(/(ease|ease-in|ease-out|ease-in-out|linear|cubic-bezier\([^)]+\))/g);
16
+ const eMatch = el.transition.match(/(ease|ease-in|ease-out|ease-in-out|linear|cubic-bezier\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*\))/g);
17
17
  if (eMatch) eMatch.forEach(e => easingSet.add(e));
18
18
 
19
19
  // Extract which properties are animated
@@ -26,10 +26,18 @@ export function extractAnimations(computedStyles, keyframes) {
26
26
  }
27
27
  }
28
28
 
29
- // Capture animation usage
29
+ // Capture animation usage with full shorthand parsing
30
30
  if (el.animation && el.animation !== 'none 0s ease 0s 1 normal none running' && el.animation !== 'none') {
31
31
  const nameMatch = el.animation.match(/^([\w-]+)/);
32
32
  if (nameMatch && nameMatch[1] !== 'none') animationNames.add(nameMatch[1]);
33
+
34
+ // Parse delay, iteration-count, fill-mode from shorthand
35
+ const delayMatch = el.animation.match(/(?<!\d)(\d+\.?\d*m?s)\s+(\d+\.?\d*m?s)/);
36
+ if (delayMatch) durationSet.add(delayMatch[1]); // duration is first, delay is second
37
+
38
+ const iterMatch = el.animation.match(/\b(infinite|\d+)\b(?=\s+(normal|reverse|alternate)|\s+none|\s+running|\s+paused|$)/);
39
+ const fillMatch = el.animation.match(/\b(none|forwards|backwards|both)\b/);
40
+ // These are collected but don't need separate sets — they enrich keyframe data below
33
41
  }
34
42
  }
35
43
 
@@ -53,9 +61,33 @@ export function extractAnimations(computedStyles, keyframes) {
53
61
  .sort((a, b) => b[1] - a[1])
54
62
  .map(([prop, count]) => ({ property: prop, count }));
55
63
 
64
+ // Classify timing patterns for each easing
65
+ const classifiedEasings = [...easingSet].map(e => {
66
+ let pattern = 'custom';
67
+ if (e === 'linear') pattern = 'linear';
68
+ else if (e === 'ease-in') pattern = 'ease-in';
69
+ else if (e === 'ease-out') pattern = 'ease-out';
70
+ else if (e === 'ease-in-out' || e === 'ease') pattern = 'ease-in-out';
71
+ else {
72
+ const cbMatch = e.match(/cubic-bezier\(\s*([\d.]+)\s*,\s*([-\d.]+)\s*,\s*([\d.]+)\s*,\s*([-\d.]+)\s*\)/);
73
+ if (cbMatch) {
74
+ const [, , y1, , y2] = cbMatch.map(Number);
75
+ if (y1 > 1 || y1 < 0 || y2 > 1 || y2 < 0) pattern = 'spring-like';
76
+ }
77
+ }
78
+ return { value: e, pattern };
79
+ });
80
+
81
+ // Detect bounce keyframes (return to start value)
82
+ for (const kf of enhancedKeyframes) {
83
+ const firstStep = kf.steps.find(s => s.offset === '0%' || s.offset === 'from');
84
+ const lastStep = kf.steps.find(s => s.offset === '100%' || s.offset === 'to');
85
+ kf.isBounce = !!(firstStep && lastStep && firstStep.style === lastStep.style && kf.steps.length > 2);
86
+ }
87
+
56
88
  return {
57
89
  transitions: [...transitionSet],
58
- easings: [...easingSet],
90
+ easings: classifiedEasings,
59
91
  durations: [...durationSet],
60
92
  keyframes: enhancedKeyframes,
61
93
  transitionProperties: sortedProps,
@@ -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,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) {
@@ -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