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.
- package/.github/FUNDING.yml +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +62 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +28 -0
- package/CHANGELOG.md +43 -0
- package/README.md +177 -6
- package/bin/design-extract.js +302 -92
- package/docs/superpowers/plans/2026-04-18-designlang-v7.md +1121 -0
- package/docs/superpowers/specs/2026-04-18-designlang-v7-design.md +150 -0
- package/package.json +13 -7
- package/src/config.js +59 -0
- package/src/crawler.js +297 -95
- package/src/extractors/a11y-remediation.js +47 -0
- package/src/extractors/animations.js +37 -5
- package/src/extractors/borders.js +40 -5
- package/src/extractors/component-clusters.js +39 -0
- package/src/extractors/components.js +77 -1
- package/src/extractors/css-health.js +151 -0
- package/src/extractors/gradients.js +25 -5
- package/src/extractors/scoring.js +20 -1
- package/src/extractors/semantic-regions.js +44 -0
- package/src/extractors/shadows.js +60 -17
- package/src/extractors/spacing.js +31 -2
- package/src/extractors/stack-fingerprint.js +88 -0
- package/src/extractors/variables.js +20 -1
- package/src/formatters/_token-ref.js +44 -0
- package/src/formatters/agent-rules.js +116 -0
- package/src/formatters/android-compose.js +164 -0
- package/src/formatters/dtcg-tokens.js +175 -0
- package/src/formatters/figma.js +66 -47
- package/src/formatters/flutter-dart.js +130 -0
- package/src/formatters/ios-swiftui.js +161 -0
- package/src/formatters/markdown.js +25 -0
- package/src/formatters/preview.js +65 -22
- package/src/formatters/svelte-theme.js +40 -0
- package/src/formatters/tailwind.js +57 -4
- package/src/formatters/theme.js +134 -0
- package/src/formatters/vue-theme.js +44 -0
- package/src/formatters/wordpress.js +267 -0
- package/src/history.js +8 -1
- package/src/index.js +76 -20
- package/src/mcp/resources.js +64 -0
- package/src/mcp/server.js +110 -0
- package/src/mcp/tools.js +149 -0
- package/src/utils.js +68 -0
- package/tests/cli.test.js +84 -0
- package/tests/extractors.test.js +792 -0
- package/tests/formatters.test.js +709 -0
- package/tests/mcp.test.js +68 -0
- package/tests/utils.test.js +413 -0
- 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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 {
|
|
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
|
|
13
|
-
const dMatch = el.transition.match(/([\d
|
|
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\([
|
|
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:
|
|
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
|
|
9
|
-
if (
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
62
|
+
const widths = [...widthSet].sort((a, b) => a - b);
|
|
63
|
+
const styles = [...styleSet].sort();
|
|
64
|
+
|
|
65
|
+
return { radii, widths, styles };
|
|
31
66
|
}
|