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
package/src/apply.js ADDED
@@ -0,0 +1,65 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { extractDesignLanguage } from './index.js';
4
+ import { formatTailwind } from './formatters/tailwind.js';
5
+ import { formatCssVars } from './formatters/css-vars.js';
6
+ import { formatShadcnTheme } from './formatters/theme.js';
7
+
8
+ export async function applyDesign(url, options = {}) {
9
+ const { dir = '.', framework } = options;
10
+ const design = await extractDesignLanguage(url, options);
11
+ const detected = framework || detectFramework(dir);
12
+ const applied = [];
13
+
14
+ if (detected === 'tailwind' || detected === 'auto') {
15
+ const twPath = findFile(dir, ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.mjs']);
16
+ if (twPath) {
17
+ writeFileSync(twPath, formatTailwind(design), 'utf-8');
18
+ applied.push({ file: twPath, type: 'tailwind' });
19
+ }
20
+ }
21
+
22
+ if (detected === 'shadcn' || detected === 'auto') {
23
+ const globalsPath = findFile(dir, [
24
+ 'app/globals.css', 'src/app/globals.css', 'styles/globals.css',
25
+ 'src/styles/globals.css', 'src/index.css', 'app/global.css',
26
+ ]);
27
+ if (globalsPath) {
28
+ const existing = readFileSync(globalsPath, 'utf-8');
29
+ const shadcnVars = formatShadcnTheme(design);
30
+ // Replace existing @layer base block or append
31
+ const layerRegex = /@layer\s+base\s*\{[\s\S]*?\n\}/;
32
+ const updated = layerRegex.test(existing)
33
+ ? existing.replace(layerRegex, shadcnVars)
34
+ : existing + '\n\n' + shadcnVars;
35
+ writeFileSync(globalsPath, updated, 'utf-8');
36
+ applied.push({ file: globalsPath, type: 'shadcn' });
37
+ }
38
+ }
39
+
40
+ if (detected === 'css' || detected === 'auto') {
41
+ const cssVarsContent = formatCssVars(design);
42
+ const cssPath = join(dir, 'design-variables.css');
43
+ writeFileSync(cssPath, cssVarsContent, 'utf-8');
44
+ applied.push({ file: cssPath, type: 'css-variables' });
45
+ }
46
+
47
+ return { design, applied, framework: detected };
48
+ }
49
+
50
+ function detectFramework(dir) {
51
+ if (findFile(dir, ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.mjs'])) {
52
+ // Check for shadcn
53
+ if (findFile(dir, ['components.json'])) return 'shadcn';
54
+ return 'tailwind';
55
+ }
56
+ return 'auto';
57
+ }
58
+
59
+ function findFile(dir, candidates) {
60
+ for (const c of candidates) {
61
+ const p = join(dir, c);
62
+ if (existsSync(p)) return p;
63
+ }
64
+ return null;
65
+ }
package/src/config.js ADDED
@@ -0,0 +1,36 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const CONFIG_FILES = ['.designlangrc', 'designlang.config.json', '.designlangrc.json'];
5
+
6
+ export function loadConfig(dir = process.cwd()) {
7
+ for (const name of CONFIG_FILES) {
8
+ const path = join(dir, name);
9
+ if (existsSync(path)) {
10
+ try {
11
+ return JSON.parse(readFileSync(path, 'utf-8'));
12
+ } catch { return {}; }
13
+ }
14
+ }
15
+ return {};
16
+ }
17
+
18
+ export function mergeConfig(cliOpts, config) {
19
+ // CLI flags take precedence over config file
20
+ return {
21
+ ignore: cliOpts.ignore || config.ignore || [],
22
+ width: cliOpts.width || config.width || 1280,
23
+ height: cliOpts.height || config.height || 800,
24
+ wait: cliOpts.wait || config.wait || 0,
25
+ dark: cliOpts.dark || config.dark || false,
26
+ depth: cliOpts.depth || config.depth || 0,
27
+ screenshots: cliOpts.screenshots || config.screenshots || false,
28
+ framework: cliOpts.framework || config.framework,
29
+ responsive: cliOpts.responsive || config.responsive || false,
30
+ interactions: cliOpts.interactions || config.interactions || false,
31
+ full: cliOpts.full || config.full || false,
32
+ cookie: cliOpts.cookie || config.cookies,
33
+ header: cliOpts.header || config.headers,
34
+ out: cliOpts.out || config.out || './design-extract-output',
35
+ };
36
+ }
package/src/crawler.js CHANGED
@@ -4,91 +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 = '' } = 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
- const browser = await chromium.launch({ headless: true });
11
- const context = await browser.newContext({
12
- viewport: { width, height },
13
- colorScheme: 'light',
22
+ const browser = await chromium.launch({
23
+ headless: true,
24
+ ...(executablePath && { executablePath }),
25
+ ...(browserArgs && { args: browserArgs }),
14
26
  });
15
- 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
+ });
16
33
 
17
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
18
- // Wait for network to settle — but don't hang on sites with persistent connections
19
- await page.waitForLoadState('networkidle').catch(() => {});
20
- if (wait > 0) await page.waitForTimeout(wait);
21
- 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();
22
45
 
23
- const title = await page.title();
24
- 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(() => {});
25
51
 
26
- // Component screenshots
27
- let componentScreenshots = {};
28
- if (screenshots && outDir) {
29
- componentScreenshots = await captureComponentScreenshots(page, outDir);
30
- }
52
+ const title = await page.title();
53
+ const lightData = await extractPageData(page, ignore);
31
54
 
32
- // Multi-page crawl: discover internal links and extract from them
33
- let additionalPages = [];
34
- if (depth > 0) {
35
- const internalLinks = await discoverInternalLinks(page, url, depth);
36
- for (const link of internalLinks) {
37
- try {
38
- await page.goto(link, { waitUntil: 'domcontentloaded', timeout: 20000 });
39
- await page.waitForLoadState('networkidle').catch(() => {});
40
- await page.evaluate(() => document.fonts.ready).catch(() => {});
41
- const pageData = await extractPageData(page);
42
- additionalPages.push({ url: link, data: pageData });
43
- } catch { /* skip failed pages */ }
55
+ // Component screenshots
56
+ let componentScreenshots = {};
57
+ if (screenshots && outDir) {
58
+ componentScreenshots = await captureComponentScreenshots(page, outDir);
44
59
  }
45
- }
46
60
 
47
- // Dark mode extraction
48
- let darkData = null;
49
- if (dark) {
50
- await context.close();
51
- const darkContext = await browser.newContext({
52
- viewport: { width, height },
53
- colorScheme: 'dark',
54
- });
55
- const darkPage = await darkContext.newPage();
56
- await darkPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
57
- await darkPage.waitForLoadState('networkidle').catch(() => {});
58
- await darkPage.evaluate(() => document.fonts.ready).catch(() => {});
59
- darkData = await extractPageData(darkPage);
60
- await darkContext.close();
61
- } else {
62
- await context.close();
63
- }
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
+ }
64
75
 
65
- 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
+ }
66
93
 
67
- // Merge additional page data into light data
68
- if (additionalPages.length > 0) {
69
- lightData.computedStyles = mergeStyles(lightData.computedStyles, additionalPages);
70
- for (const ap of additionalPages) {
71
- Object.assign(lightData.cssVariables, ap.data.cssVariables);
72
- lightData.mediaQueries.push(...ap.data.mediaQueries);
73
- 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
+ });
74
110
  }
75
- // Deduplicate media queries and keyframes
76
- lightData.mediaQueries = [...new Set(lightData.mediaQueries)];
77
- const seenKf = new Set();
78
- lightData.keyframes = lightData.keyframes.filter(kf => {
79
- if (seenKf.has(kf.name)) return false;
80
- seenKf.add(kf.name);
81
- return true;
82
- });
83
- }
84
111
 
85
- return {
86
- url, title,
87
- light: lightData,
88
- dark: darkData,
89
- pagesAnalyzed: 1 + additionalPages.length,
90
- componentScreenshots,
91
- };
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
+ }
92
122
  }
93
123
 
94
124
  function mergeStyles(primary, additionalPages) {
@@ -158,19 +188,39 @@ export async function captureComponentScreenshots(page, outDir) {
158
188
  return result;
159
189
  }
160
190
 
161
- async function extractPageData(page) {
162
- 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
+
163
204
  const results = {
164
205
  computedStyles: [],
165
206
  cssVariables: {},
166
207
  mediaQueries: [],
167
208
  keyframes: [],
209
+ crossOriginSheets: [],
168
210
  };
169
211
 
170
- const allElements = document.querySelectorAll('*');
171
- const elements = allElements.length > maxElements
172
- ? Array.from(allElements).slice(0, maxElements)
173
- : 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, []);
174
224
 
175
225
  for (const el of elements) {
176
226
  const cs = getComputedStyle(el);
@@ -201,7 +251,10 @@ async function extractPageData(page) {
201
251
  marginLeft: cs.marginLeft,
202
252
  gap: cs.gap,
203
253
  borderRadius: cs.borderRadius,
254
+ borderWidth: cs.borderWidth,
255
+ borderStyle: cs.borderStyle,
204
256
  boxShadow: cs.boxShadow,
257
+ textShadow: cs.textShadow,
205
258
  zIndex: cs.zIndex,
206
259
  transition: cs.transition,
207
260
  animation: cs.animation,
@@ -232,7 +285,7 @@ async function extractPageData(page) {
232
285
  }
233
286
  }
234
287
  }
235
- } catch { /* cross-origin */ }
288
+ } catch { if (sheet.href) results.crossOriginSheets.push(sheet.href); }
236
289
  }
237
290
  } catch { /* no access */ }
238
291
 
@@ -252,7 +305,7 @@ async function extractPageData(page) {
252
305
  results.mediaQueries.push(rule.conditionText || rule.media.mediaText);
253
306
  }
254
307
  }
255
- } catch { /* cross-origin */ }
308
+ } catch { /* cross-origin — already tracked */ }
256
309
  }
257
310
  } catch { /* no access */ }
258
311
 
@@ -269,10 +322,122 @@ async function extractPageData(page) {
269
322
  results.keyframes.push({ name: rule.name, steps });
270
323
  }
271
324
  }
272
- } catch { /* cross-origin */ }
325
+ } catch { /* cross-origin — already tracked */ }
273
326
  }
274
327
  } catch { /* no access */ }
275
328
 
329
+ // SVG icons
330
+ results.icons = [];
331
+ for (const svg of document.querySelectorAll('svg')) {
332
+ const rect = svg.getBoundingClientRect();
333
+ if (rect.width > 4 && rect.width < 200 && rect.height > 4 && rect.height < 200) {
334
+ results.icons.push({
335
+ svg: svg.outerHTML,
336
+ width: rect.width,
337
+ height: rect.height,
338
+ viewBox: svg.getAttribute('viewBox') || '',
339
+ classList: Array.from(svg.classList).join(' '),
340
+ fill: svg.getAttribute('fill') || getComputedStyle(svg).fill || '',
341
+ stroke: svg.getAttribute('stroke') || getComputedStyle(svg).stroke || '',
342
+ });
343
+ }
344
+ }
345
+
346
+ // Font data
347
+ results.fontData = { fontFaces: [], googleFontsLinks: [], documentFonts: [] };
348
+ try {
349
+ for (const sheet of document.styleSheets) {
350
+ try {
351
+ for (const rule of sheet.cssRules) {
352
+ if (rule instanceof CSSFontFaceRule) {
353
+ results.fontData.fontFaces.push({
354
+ family: rule.style.getPropertyValue('font-family').replace(/['"]/g, ''),
355
+ style: rule.style.getPropertyValue('font-style') || 'normal',
356
+ weight: rule.style.getPropertyValue('font-weight') || '400',
357
+ src: rule.style.getPropertyValue('src') || '',
358
+ });
359
+ }
360
+ }
361
+ } catch { /* cross-origin — already tracked */ }
362
+ }
363
+ } catch {}
364
+ for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com"]')) {
365
+ results.fontData.googleFontsLinks.push(link.href);
366
+ }
367
+ for (const font of document.fonts) {
368
+ results.fontData.documentFonts.push({ family: font.family.replace(/['"]/g, ''), style: font.style, weight: font.weight, status: font.status });
369
+ }
370
+
371
+ // Image data
372
+ results.images = [];
373
+ for (const img of document.querySelectorAll('img, picture img, [role="img"]')) {
374
+ const rect = img.getBoundingClientRect();
375
+ if (rect.width < 5 || rect.height < 5) continue;
376
+ const cs = getComputedStyle(img);
377
+ results.images.push({
378
+ tag: img.tagName.toLowerCase(),
379
+ src: img.src || '',
380
+ width: rect.width,
381
+ height: rect.height,
382
+ objectFit: cs.objectFit,
383
+ objectPosition: cs.objectPosition,
384
+ borderRadius: cs.borderRadius,
385
+ filter: cs.filter,
386
+ opacity: cs.opacity,
387
+ aspectRatio: cs.aspectRatio,
388
+ classList: Array.from(img.classList).join(' '),
389
+ });
390
+ }
391
+
276
392
  return results;
277
- }, 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
+ }
278
443
  }
@@ -0,0 +1,65 @@
1
+ export function diffDarkMode(lightDesign, darkDesign) {
2
+ const changes = [];
3
+
4
+ // Color changes
5
+ const lightColors = new Set(lightDesign.colors.all.map(c => c.hex));
6
+ const darkColors = new Set(darkDesign.colors.all.map(c => c.hex));
7
+
8
+ const addedInDark = darkDesign.colors.all.filter(c => !lightColors.has(c.hex));
9
+ const removedInDark = lightDesign.colors.all.filter(c => !darkColors.has(c.hex));
10
+
11
+ if (addedInDark.length > 0 || removedInDark.length > 0) {
12
+ changes.push({
13
+ category: 'colors',
14
+ light: lightDesign.colors.all.length,
15
+ dark: darkDesign.colors.all.length,
16
+ added: addedInDark.map(c => c.hex),
17
+ removed: removedInDark.map(c => c.hex),
18
+ });
19
+ }
20
+
21
+ // CSS variable changes
22
+ const lightVars = flattenVars(lightDesign.variables);
23
+ const darkVars = flattenVars(darkDesign.variables);
24
+ const varChanges = [];
25
+
26
+ for (const [key, lightVal] of Object.entries(lightVars)) {
27
+ const darkVal = darkVars[key];
28
+ if (darkVal && darkVal !== lightVal) {
29
+ varChanges.push({ name: key, light: lightVal, dark: darkVal });
30
+ }
31
+ }
32
+ const newDarkVars = Object.entries(darkVars)
33
+ .filter(([key]) => !lightVars[key])
34
+ .map(([name, dark]) => ({ name, light: null, dark }));
35
+
36
+ if (varChanges.length > 0 || newDarkVars.length > 0) {
37
+ changes.push({
38
+ category: 'cssVariables',
39
+ changed: varChanges,
40
+ newInDark: newDarkVars,
41
+ });
42
+ }
43
+
44
+ return {
45
+ hasChanges: changes.length > 0,
46
+ changes,
47
+ summary: {
48
+ colorsChanged: (addedInDark.length + removedInDark.length) || 0,
49
+ variablesChanged: varChanges.length,
50
+ newDarkVariables: newDarkVars.length,
51
+ },
52
+ };
53
+ }
54
+
55
+ function flattenVars(variables) {
56
+ const flat = {};
57
+ for (const [, group] of Object.entries(variables)) {
58
+ if (typeof group === 'object') {
59
+ for (const [key, val] of Object.entries(group)) {
60
+ flat[key] = val;
61
+ }
62
+ }
63
+ }
64
+ return flat;
65
+ }
@@ -2,27 +2,95 @@ export function extractAnimations(computedStyles, keyframes) {
2
2
  const transitionSet = new Set();
3
3
  const easingSet = new Set();
4
4
  const durationSet = new Set();
5
+ const animationNames = new Set();
6
+ const transitionProperties = {};
5
7
 
6
8
  for (const el of computedStyles) {
7
9
  if (el.transition && el.transition !== 'all 0s ease 0s' && el.transition !== 'none') {
8
10
  transitionSet.add(el.transition);
9
11
 
10
- // Extract easing and duration
11
- 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);
12
14
  if (dMatch) dMatch.forEach(d => durationSet.add(d));
13
15
 
14
- 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);
15
17
  if (eMatch) eMatch.forEach(e => easingSet.add(e));
18
+
19
+ // Extract which properties are animated
20
+ const parts = el.transition.split(',').map(s => s.trim());
21
+ for (const part of parts) {
22
+ const prop = part.split(/\s+/)[0];
23
+ if (prop && prop !== 'all') {
24
+ transitionProperties[prop] = (transitionProperties[prop] || 0) + 1;
25
+ }
26
+ }
27
+ }
28
+
29
+ // Capture animation usage with full shorthand parsing
30
+ if (el.animation && el.animation !== 'none 0s ease 0s 1 normal none running' && el.animation !== 'none') {
31
+ const nameMatch = el.animation.match(/^([\w-]+)/);
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
41
+ }
42
+ }
43
+
44
+ // Enhanced keyframes with timing and properties changed
45
+ const enhancedKeyframes = keyframes.map(kf => {
46
+ const propertiesAnimated = new Set();
47
+ for (const step of kf.steps) {
48
+ const props = step.style.split(';').map(s => s.split(':')[0].trim()).filter(Boolean);
49
+ props.forEach(p => propertiesAnimated.add(p));
16
50
  }
51
+ return {
52
+ name: kf.name,
53
+ steps: kf.steps,
54
+ propertiesAnimated: [...propertiesAnimated],
55
+ isUsed: animationNames.has(kf.name),
56
+ };
57
+ });
58
+
59
+ // Sort transition properties by usage
60
+ const sortedProps = Object.entries(transitionProperties)
61
+ .sort((a, b) => b[1] - a[1])
62
+ .map(([prop, count]) => ({ property: prop, count }));
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);
17
86
  }
18
87
 
19
88
  return {
20
89
  transitions: [...transitionSet],
21
- easings: [...easingSet],
90
+ easings: classifiedEasings,
22
91
  durations: [...durationSet],
23
- keyframes: keyframes.map(kf => ({
24
- name: kf.name,
25
- steps: kf.steps,
26
- })),
92
+ keyframes: enhancedKeyframes,
93
+ transitionProperties: sortedProps,
94
+ animationNames: [...animationNames],
27
95
  };
28
96
  }