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
package/src/crawler.js CHANGED
@@ -4,107 +4,143 @@ 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
+ // Start CSS coverage for css-health audit. Not supported on all targets —
47
+ // fail gracefully and set empty coverage if the API is unavailable.
48
+ let cssCoverageAvailable = true;
49
+ try {
50
+ await page.coverage.startCSSCoverage();
51
+ } catch { cssCoverageAvailable = false; }
52
+
53
+ await gotoWithRetry(page, url, { waitUntil: 'domcontentloaded', timeout: 30000 });
54
+ // Wait for network to settle — but don't hang on sites with persistent connections
55
+ await page.waitForLoadState('networkidle').catch(() => {});
56
+ if (wait > 0) await page.waitForTimeout(wait);
57
+ await page.evaluate(() => document.fonts.ready).catch(() => {});
58
+
59
+ // Capture CSS coverage after the page has settled.
60
+ let cssCoverage = [];
61
+ if (cssCoverageAvailable) {
62
+ try {
63
+ const raw = await page.coverage.stopCSSCoverage();
64
+ cssCoverage = raw.map(c => ({
65
+ url: c.url,
66
+ text: c.text,
67
+ totalBytes: (c.text || '').length,
68
+ ranges: c.ranges || [],
69
+ }));
70
+ } catch { cssCoverage = []; }
71
+ }
41
72
 
42
- // Component screenshots
43
- let componentScreenshots = {};
44
- if (screenshots && outDir) {
45
- componentScreenshots = await captureComponentScreenshots(page, outDir);
46
- }
73
+ const title = await page.title();
74
+ const lightData = await extractPageData(page, ignore);
75
+ lightData.cssCoverage = cssCoverage;
47
76
 
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 */ }
77
+ // Component screenshots
78
+ let componentScreenshots = {};
79
+ if (screenshots && outDir) {
80
+ componentScreenshots = await captureComponentScreenshots(page, outDir);
60
81
  }
61
- }
62
82
 
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
- }
83
+ // Multi-page crawl: discover internal links and extract from them
84
+ let additionalPages = [];
85
+ if (depth > 0) {
86
+ const internalLinks = await discoverInternalLinks(page, url, depth);
87
+ for (const link of internalLinks) {
88
+ try {
89
+ await gotoWithRetry(page, link, { waitUntil: 'domcontentloaded', timeout: 20000 });
90
+ await page.waitForLoadState('networkidle').catch(() => {});
91
+ await page.evaluate(() => document.fonts.ready).catch(() => {});
92
+ const pageData = await extractPageData(page);
93
+ additionalPages.push({ url: link, data: pageData });
94
+ } catch { /* skip failed pages */ }
95
+ }
96
+ }
80
97
 
81
- await browser.close();
98
+ // Dark mode extraction
99
+ let darkData = null;
100
+ if (dark) {
101
+ await context.close();
102
+ const darkContext = await browser.newContext({
103
+ viewport: { width, height },
104
+ colorScheme: 'dark',
105
+ });
106
+ const darkPage = await darkContext.newPage();
107
+ await gotoWithRetry(darkPage, url, { waitUntil: 'domcontentloaded', timeout: 30000 });
108
+ await darkPage.waitForLoadState('networkidle').catch(() => {});
109
+ await darkPage.evaluate(() => document.fonts.ready).catch(() => {});
110
+ darkData = await extractPageData(darkPage);
111
+ await darkContext.close();
112
+ } else {
113
+ await context.close();
114
+ }
82
115
 
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);
116
+ // Merge additional page data into light data
117
+ if (additionalPages.length > 0) {
118
+ lightData.computedStyles = mergeStyles(lightData.computedStyles, additionalPages);
119
+ for (const ap of additionalPages) {
120
+ Object.assign(lightData.cssVariables, ap.data.cssVariables);
121
+ lightData.mediaQueries.push(...ap.data.mediaQueries);
122
+ lightData.keyframes.push(...ap.data.keyframes);
123
+ }
124
+ // Deduplicate media queries and keyframes
125
+ lightData.mediaQueries = [...new Set(lightData.mediaQueries)];
126
+ const seenKf = new Set();
127
+ lightData.keyframes = lightData.keyframes.filter(kf => {
128
+ if (seenKf.has(kf.name)) return false;
129
+ seenKf.add(kf.name);
130
+ return true;
131
+ });
90
132
  }
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
133
 
101
- return {
102
- url, title,
103
- light: lightData,
104
- dark: darkData,
105
- pagesAnalyzed: 1 + additionalPages.length,
106
- componentScreenshots,
107
- };
134
+ return {
135
+ url, title,
136
+ light: lightData,
137
+ dark: darkData,
138
+ pagesAnalyzed: 1 + additionalPages.length,
139
+ componentScreenshots,
140
+ };
141
+ } finally {
142
+ await browser.close();
143
+ }
108
144
  }
109
145
 
110
146
  function mergeStyles(primary, additionalPages) {
@@ -174,19 +210,39 @@ export async function captureComponentScreenshots(page, outDir) {
174
210
  return result;
175
211
  }
176
212
 
177
- async function extractPageData(page) {
178
- return page.evaluate((maxElements) => {
213
+ async function extractPageData(page, ignoreSelectors) {
214
+ const data = await page.evaluate(({ maxElements, ignoreSelectors }) => {
215
+ // Remove ignored elements before extraction
216
+ if (ignoreSelectors && ignoreSelectors.length > 0) {
217
+ for (const sel of ignoreSelectors) {
218
+ try {
219
+ for (const el of document.querySelectorAll(sel)) {
220
+ el.remove();
221
+ }
222
+ } catch { /* invalid selector */ }
223
+ }
224
+ }
225
+
179
226
  const results = {
180
227
  computedStyles: [],
181
228
  cssVariables: {},
182
229
  mediaQueries: [],
183
230
  keyframes: [],
231
+ crossOriginSheets: [],
184
232
  };
185
233
 
186
- const allElements = document.querySelectorAll('*');
187
- const elements = allElements.length > maxElements
188
- ? Array.from(allElements).slice(0, maxElements)
189
- : Array.from(allElements);
234
+ // Collect elements including shadow DOM contents
235
+ function collectElements(root, collected) {
236
+ for (const el of root.querySelectorAll('*')) {
237
+ if (collected.length >= maxElements) break;
238
+ collected.push(el);
239
+ if (el.shadowRoot) {
240
+ collectElements(el.shadowRoot, collected);
241
+ }
242
+ }
243
+ return collected;
244
+ }
245
+ const elements = collectElements(document, []);
190
246
 
191
247
  for (const el of elements) {
192
248
  const cs = getComputedStyle(el);
@@ -217,7 +273,10 @@ async function extractPageData(page) {
217
273
  marginLeft: cs.marginLeft,
218
274
  gap: cs.gap,
219
275
  borderRadius: cs.borderRadius,
276
+ borderWidth: cs.borderWidth,
277
+ borderStyle: cs.borderStyle,
220
278
  boxShadow: cs.boxShadow,
279
+ textShadow: cs.textShadow,
221
280
  zIndex: cs.zIndex,
222
281
  transition: cs.transition,
223
282
  animation: cs.animation,
@@ -248,7 +307,7 @@ async function extractPageData(page) {
248
307
  }
249
308
  }
250
309
  }
251
- } catch { /* cross-origin */ }
310
+ } catch { if (sheet.href) results.crossOriginSheets.push(sheet.href); }
252
311
  }
253
312
  } catch { /* no access */ }
254
313
 
@@ -268,7 +327,7 @@ async function extractPageData(page) {
268
327
  results.mediaQueries.push(rule.conditionText || rule.media.mediaText);
269
328
  }
270
329
  }
271
- } catch { /* cross-origin */ }
330
+ } catch { /* cross-origin — already tracked */ }
272
331
  }
273
332
  } catch { /* no access */ }
274
333
 
@@ -285,10 +344,104 @@ async function extractPageData(page) {
285
344
  results.keyframes.push({ name: rule.name, steps });
286
345
  }
287
346
  }
288
- } catch { /* cross-origin */ }
347
+ } catch { /* cross-origin — already tracked */ }
289
348
  }
290
349
  } catch { /* no access */ }
291
350
 
351
+ // Component clusters (v7): per-element features for similarity-based grouping.
352
+ function colorToChannels(str) {
353
+ if (!str) return [0, 0, 0, 0];
354
+ const m = String(str).match(/rgba?\(([^)]+)\)/i);
355
+ if (!m) return [0, 0, 0, 0];
356
+ const parts = m[1].split(',').map(s => parseFloat(s));
357
+ return [parts[0] || 0, parts[1] || 0, parts[2] || 0, parts[3] === undefined ? 1 : parts[3]];
358
+ }
359
+ function structuralHashOf(el) {
360
+ const parts = [el.tagName.toLowerCase()];
361
+ for (const c of el.children) {
362
+ parts.push(c.tagName.toLowerCase());
363
+ }
364
+ return parts.slice(0, 6).join('>');
365
+ }
366
+ const candidateSelector = 'button, a[role="button"], .btn, [class*="button"], input[type="text"], input[type="email"], input[type="search"], textarea, [class*="card"]';
367
+ results.componentCandidates = [];
368
+ const seenCandidates = new Set();
369
+ for (const el of document.querySelectorAll(candidateSelector)) {
370
+ if (results.componentCandidates.length >= 300) break;
371
+ const rect = el.getBoundingClientRect();
372
+ if (rect.width < 4 || rect.height < 4) continue;
373
+ if (seenCandidates.has(el)) continue;
374
+ seenCandidates.add(el);
375
+ const cs = getComputedStyle(el);
376
+ const tag = el.tagName.toLowerCase();
377
+ let kind = 'other';
378
+ const cls = typeof el.className === 'string' ? el.className.toLowerCase() : '';
379
+ if (tag === 'button' || el.getAttribute('role') === 'button' || /\bbtn\b|button/.test(cls)) kind = 'button';
380
+ else if (tag === 'input' || tag === 'textarea') kind = 'input';
381
+ else if (tag === 'a') kind = 'link';
382
+ else if (/card/.test(cls)) kind = 'card';
383
+ const bg = colorToChannels(cs.backgroundColor);
384
+ const fg = colorToChannels(cs.color);
385
+ const styleVector = [
386
+ parseFloat(cs.paddingTop) || 0,
387
+ parseFloat(cs.paddingRight) || 0,
388
+ parseFloat(cs.paddingBottom) || 0,
389
+ parseFloat(cs.paddingLeft) || 0,
390
+ bg[0], bg[1], bg[2], bg[3] * 255,
391
+ fg[0], fg[1], fg[2], fg[3] * 255,
392
+ parseFloat(cs.borderTopLeftRadius) || 0,
393
+ parseFloat(cs.borderWidth) || 0,
394
+ parseFloat(cs.fontSize) || 0,
395
+ parseFloat(cs.fontWeight) || 0,
396
+ ];
397
+ results.componentCandidates.push({
398
+ kind,
399
+ structuralHash: structuralHashOf(el),
400
+ styleVector,
401
+ css: {
402
+ background: cs.backgroundColor,
403
+ color: cs.color,
404
+ padding: `${cs.paddingTop} ${cs.paddingRight} ${cs.paddingBottom} ${cs.paddingLeft}`,
405
+ borderRadius: cs.borderTopLeftRadius,
406
+ border: `${cs.borderWidth} ${cs.borderStyle} ${cs.borderColor}`,
407
+ fontSize: cs.fontSize,
408
+ fontWeight: cs.fontWeight,
409
+ },
410
+ });
411
+ }
412
+
413
+ // Semantic regions (v7): landmark + heading + bounds data for classifier.
414
+ results.sections = Array.from(document.querySelectorAll(
415
+ 'header, nav, main, section, footer, aside, [role="banner"], [role="contentinfo"], [role="complementary"], [role="navigation"]'
416
+ )).slice(0, 100).map(el => {
417
+ const r = el.getBoundingClientRect();
418
+ return {
419
+ tag: el.tagName.toLowerCase(),
420
+ role: el.getAttribute('role') || '',
421
+ className: typeof el.className === 'string' ? el.className : '',
422
+ id: el.id || '',
423
+ text: (el.innerText || '').slice(0, 2000),
424
+ headings: Array.from(el.querySelectorAll('h1,h2,h3')).slice(0, 5).map(h => h.innerText || ''),
425
+ buttonCount: el.querySelectorAll('button, a[role="button"], .btn, [class*="button"]').length,
426
+ cardCount: el.querySelectorAll('article, li, [class*="card"], [class*="item"]').length,
427
+ bounds: { x: r.x, y: r.y, w: r.width, h: r.height },
428
+ };
429
+ });
430
+
431
+ // Stack fingerprint signals (v7)
432
+ results.stack = {
433
+ scripts: Array.from(document.scripts).map(s => s.src).filter(Boolean).slice(0, 50),
434
+ metas: Array.from(document.querySelectorAll('meta[name],meta[property]'))
435
+ .map(m => ({ name: m.name || m.getAttribute('property'), content: m.content }))
436
+ .slice(0, 50),
437
+ classNameSample: Array.from(document.querySelectorAll('[class]'))
438
+ .slice(0, 500)
439
+ .map(e => typeof e.className === 'string' ? e.className : '')
440
+ .filter(Boolean),
441
+ windowGlobals: ['React', 'Vue', '__NEXT_DATA__', '__NUXT__', '___gatsby', '_remixContext', 'Shopify', 'wp']
442
+ .filter(k => typeof window[k] !== 'undefined'),
443
+ };
444
+
292
445
  // SVG icons
293
446
  results.icons = [];
294
447
  for (const svg of document.querySelectorAll('svg')) {
@@ -321,7 +474,7 @@ async function extractPageData(page) {
321
474
  });
322
475
  }
323
476
  }
324
- } catch { /* cross-origin */ }
477
+ } catch { /* cross-origin — already tracked */ }
325
478
  }
326
479
  } catch {}
327
480
  for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com"]')) {
@@ -353,5 +506,54 @@ async function extractPageData(page) {
353
506
  }
354
507
 
355
508
  return results;
356
- }, MAX_ELEMENTS);
509
+ }, { maxElements: MAX_ELEMENTS, ignoreSelectors: ignoreSelectors || [] });
510
+
511
+ // Fetch and parse cross-origin stylesheets
512
+ if (data.crossOriginSheets && data.crossOriginSheets.length > 0) {
513
+ const seen = new Set();
514
+ for (const href of data.crossOriginSheets) {
515
+ if (seen.has(href)) continue;
516
+ seen.add(href);
517
+ try {
518
+ const cssText = await page.evaluate(async (url) => {
519
+ const res = await fetch(url, { mode: 'cors' });
520
+ return res.text();
521
+ }, href);
522
+ parseCrossOriginCSS(cssText, data);
523
+ } catch { /* fetch failed too */ }
524
+ }
525
+ }
526
+ delete data.crossOriginSheets;
527
+
528
+ return data;
529
+ }
530
+
531
+ function parseCrossOriginCSS(cssText, data) {
532
+ // Media queries
533
+ for (const m of cssText.matchAll(/@media\s*([^{]+)\{/g)) {
534
+ data.mediaQueries.push(m[1].trim());
535
+ }
536
+ // Keyframes
537
+ for (const m of cssText.matchAll(/@keyframes\s+([\w-]+)\s*\{([\s\S]*?)\n\}/g)) {
538
+ const steps = [];
539
+ for (const s of m[2].matchAll(/([\d%,\s]+|from|to)\s*\{([^}]*)\}/g)) {
540
+ steps.push({ offset: s[1].trim(), style: s[2].trim() });
541
+ }
542
+ if (steps.length > 0) data.keyframes.push({ name: m[1], steps });
543
+ }
544
+ // :root variables
545
+ for (const rootBlock of cssText.matchAll(/:root\s*\{([^}]+)\}/g)) {
546
+ for (const v of rootBlock[1].matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
547
+ if (!data.cssVariables[v[1]]) data.cssVariables[v[1]] = v[2].trim();
548
+ }
549
+ }
550
+ // @font-face
551
+ for (const m of cssText.matchAll(/@font-face\s*\{([^}]+)\}/g)) {
552
+ const block = m[1];
553
+ const family = block.match(/font-family\s*:\s*['"]?([^'";]+)/)?.[1]?.trim();
554
+ const style = block.match(/font-style\s*:\s*([^;]+)/)?.[1]?.trim() || 'normal';
555
+ const weight = block.match(/font-weight\s*:\s*([^;]+)/)?.[1]?.trim() || '400';
556
+ const src = block.match(/src\s*:\s*([^;]+)/)?.[1]?.trim() || '';
557
+ if (family) data.fontData.fontFaces.push({ family, style, weight, src });
558
+ }
357
559
  }
@@ -0,0 +1,47 @@
1
+ // A11y remediation: for each failing fg/bg pair, propose the nearest palette
2
+ // color that passes the requested WCAG rule.
3
+
4
+ function toRgb(hex) {
5
+ const h = String(hex || '').replace('#', '');
6
+ const n = h.length === 3 ? h.split('').map(x => x + x).join('') : h;
7
+ const i = parseInt(n, 16);
8
+ if (Number.isNaN(i)) return [0, 0, 0];
9
+ return [(i >> 16) & 255, (i >> 8) & 255, i & 255];
10
+ }
11
+
12
+ function relLum([r, g, b]) {
13
+ const f = c => {
14
+ const s = c / 255;
15
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
16
+ };
17
+ return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
18
+ }
19
+
20
+ function contrast(a, b) {
21
+ const la = relLum(toRgb(a));
22
+ const lb = relLum(toRgb(b));
23
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
24
+ }
25
+
26
+ const THRESHOLDS = { 'AA-normal': 4.5, 'AA-large': 3, 'AAA-normal': 7, 'AAA-large': 4.5 };
27
+
28
+ export function remediateFailingPairs(failing = [], palette = []) {
29
+ return failing.map(p => {
30
+ const target = THRESHOLDS[p.rule] || 4.5;
31
+ let best = null;
32
+ for (const candidate of palette) {
33
+ if (!candidate) continue;
34
+ const newRatio = contrast(candidate, p.bg);
35
+ if (newRatio >= target && (!best || newRatio > best.newRatio)) {
36
+ best = {
37
+ replace: 'fg',
38
+ color: candidate,
39
+ newRatio: Math.round(newRatio * 100) / 100,
40
+ };
41
+ }
42
+ }
43
+ return { ...p, suggestion: best };
44
+ });
45
+ }
46
+
47
+ export { contrast as _contrast };
@@ -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
  }