designlang 7.0.0 → 7.2.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/og-preview.png +0 -0
- package/.github/workflows/manavarya-bot.yml +17 -0
- package/.vercel/README.txt +11 -0
- package/.vercel/project.json +1 -0
- package/CHANGELOG.md +29 -0
- package/CONTRIBUTING.md +25 -0
- package/README.md +38 -11
- package/bin/design-extract.js +41 -2
- package/chrome-extension/README.md +41 -0
- package/chrome-extension/icons/favicon.svg +7 -0
- package/chrome-extension/icons/icon-128.png +0 -0
- package/chrome-extension/icons/icon-16.png +0 -0
- package/chrome-extension/icons/icon-32.png +0 -0
- package/chrome-extension/icons/icon-48.png +0 -0
- package/chrome-extension/manifest.json +26 -0
- package/chrome-extension/popup.html +167 -0
- package/chrome-extension/popup.js +59 -0
- package/docs/superpowers/specs/2026-04-18-website-redesign-design.md +120 -0
- package/docs/superpowers/specs/2026-04-19-designlang-v7-1-design.md +111 -0
- package/package.json +1 -1
- package/src/config.js +5 -1
- package/src/crawler.js +361 -2
- package/src/extractors/interaction-states.js +57 -0
- package/src/extractors/modern-css.js +100 -0
- package/src/extractors/token-sources.js +65 -0
- package/src/extractors/wide-gamut.js +47 -0
- package/src/formatters/routes-reconciliation.js +160 -0
- package/src/index.js +29 -0
- package/src/utils/color-gamut.js +82 -0
- package/src/utils-cookies.js +73 -0
- package/tests/cookies.test.js +98 -0
- package/tests/interaction-states.test.js +62 -0
- package/tests/modern-css.test.js +104 -0
- package/tests/routes-reconciliation.test.js +120 -0
- package/tests/wide-gamut.test.js +90 -0
- package/website/app/api/extract/route.js +216 -56
- package/website/app/components/A11ySlider.js +369 -0
- package/website/app/components/Comparison.js +286 -0
- package/website/app/components/CssHealth.js +243 -0
- package/website/app/components/HeroExtractor.js +455 -0
- package/website/app/components/Marginalia.js +3 -0
- package/website/app/components/McpSection.js +223 -0
- package/website/app/components/PlatformTabs.js +250 -0
- package/website/app/components/RegionsComponents.js +429 -0
- package/website/app/components/Rule.js +13 -0
- package/website/app/components/Specimens.js +237 -0
- package/website/app/components/StructuredData.js +144 -0
- package/website/app/components/TokenBrowser.js +344 -0
- package/website/app/components/token-browser-sample.js +65 -0
- package/website/app/globals.css +415 -633
- package/website/app/icon.svg +7 -0
- package/website/app/layout.js +113 -6
- package/website/app/opengraph-image.js +170 -0
- package/website/app/page.js +372 -148
- package/website/app/robots.js +15 -0
- package/website/app/seo-config.js +82 -0
- package/website/app/sitemap.js +18 -0
- package/website/lib/cache.js +73 -0
- package/website/lib/rate-limit.js +30 -0
- package/website/lib/rate-limit.test.js +55 -0
- package/website/lib/specimens.json +86 -0
- package/website/lib/token-helpers.js +70 -0
- package/website/lib/url-safety.js +103 -0
- package/website/lib/url-safety.test.js +116 -0
- package/website/lib/zip-files.js +15 -0
- package/website/package-lock.json +85 -0
- package/website/package.json +1 -0
- package/website/public/favicon.svg +7 -0
- package/website/public/logo-specimen.svg +76 -0
- package/website/public/mark.svg +12 -0
- package/website/public/site.webmanifest +13 -0
- package/website/app/favicon.ico +0 -0
- package/website/public/file.svg +0 -1
- package/website/public/globe.svg +0 -1
- package/website/public/next.svg +0 -1
- package/website/public/vercel.svg +0 -1
- package/website/public/window.svg +0 -1
package/src/crawler.js
CHANGED
|
@@ -17,17 +17,36 @@ async function gotoWithRetry(page, url, opts, retries = 3) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export async function crawlPage(url, options = {}) {
|
|
20
|
-
const {
|
|
20
|
+
const {
|
|
21
|
+
width = 1280, height = 800, wait = 0, dark = false, depth = 0,
|
|
22
|
+
screenshots = false, outDir = '', executablePath, browserArgs,
|
|
23
|
+
cookies, headers, ignore,
|
|
24
|
+
insecure = false,
|
|
25
|
+
userAgent,
|
|
26
|
+
deepInteract = false,
|
|
27
|
+
} = options;
|
|
28
|
+
|
|
29
|
+
const launchArgs = [
|
|
30
|
+
...(browserArgs || []),
|
|
31
|
+
// Common flags that help with dev environments and CI. Insecure-only flags
|
|
32
|
+
// are added below when the user opts in.
|
|
33
|
+
'--disable-dev-shm-usage',
|
|
34
|
+
];
|
|
35
|
+
if (insecure) {
|
|
36
|
+
launchArgs.push('--ignore-certificate-errors', '--ignore-ssl-errors');
|
|
37
|
+
}
|
|
21
38
|
|
|
22
39
|
const browser = await chromium.launch({
|
|
23
40
|
headless: true,
|
|
24
41
|
...(executablePath && { executablePath }),
|
|
25
|
-
|
|
42
|
+
args: launchArgs,
|
|
26
43
|
});
|
|
27
44
|
try {
|
|
28
45
|
const context = await browser.newContext({
|
|
29
46
|
viewport: { width, height },
|
|
30
47
|
colorScheme: 'light',
|
|
48
|
+
ignoreHTTPSErrors: insecure,
|
|
49
|
+
...(userAgent && { userAgent }),
|
|
31
50
|
...(headers && { extraHTTPHeaders: headers }),
|
|
32
51
|
});
|
|
33
52
|
|
|
@@ -71,8 +90,16 @@ export async function crawlPage(url, options = {}) {
|
|
|
71
90
|
}
|
|
72
91
|
|
|
73
92
|
const title = await page.title();
|
|
93
|
+
|
|
94
|
+
// Auto-interact pass (Tier 2): scroll, open menus, hover, open accordions & a first modal.
|
|
95
|
+
let interactState = null;
|
|
96
|
+
if (deepInteract) {
|
|
97
|
+
interactState = await runInteractionPass(page).catch(() => null);
|
|
98
|
+
}
|
|
99
|
+
|
|
74
100
|
const lightData = await extractPageData(page, ignore);
|
|
75
101
|
lightData.cssCoverage = cssCoverage;
|
|
102
|
+
if (interactState) lightData.interactState = interactState;
|
|
76
103
|
|
|
77
104
|
// Component screenshots
|
|
78
105
|
let componentScreenshots = {};
|
|
@@ -82,7 +109,17 @@ export async function crawlPage(url, options = {}) {
|
|
|
82
109
|
|
|
83
110
|
// Multi-page crawl: discover internal links and extract from them
|
|
84
111
|
let additionalPages = [];
|
|
112
|
+
const routes = [];
|
|
85
113
|
if (depth > 0) {
|
|
114
|
+
// Seed routes with the primary page
|
|
115
|
+
try {
|
|
116
|
+
const u0 = new URL(url);
|
|
117
|
+
routes.push({
|
|
118
|
+
url,
|
|
119
|
+
path: u0.pathname || '/',
|
|
120
|
+
computedStylesSample: (lightData.computedStyles || []).slice(0, 2000),
|
|
121
|
+
});
|
|
122
|
+
} catch { /* ignore */ }
|
|
86
123
|
const internalLinks = await discoverInternalLinks(page, url, depth);
|
|
87
124
|
for (const link of internalLinks) {
|
|
88
125
|
try {
|
|
@@ -91,6 +128,14 @@ export async function crawlPage(url, options = {}) {
|
|
|
91
128
|
await page.evaluate(() => document.fonts.ready).catch(() => {});
|
|
92
129
|
const pageData = await extractPageData(page);
|
|
93
130
|
additionalPages.push({ url: link, data: pageData });
|
|
131
|
+
try {
|
|
132
|
+
const u = new URL(link);
|
|
133
|
+
routes.push({
|
|
134
|
+
url: link,
|
|
135
|
+
path: u.pathname || '/',
|
|
136
|
+
computedStylesSample: (pageData.computedStyles || []).slice(0, 2000),
|
|
137
|
+
});
|
|
138
|
+
} catch { /* ignore */ }
|
|
94
139
|
} catch { /* skip failed pages */ }
|
|
95
140
|
}
|
|
96
141
|
}
|
|
@@ -135,6 +180,8 @@ export async function crawlPage(url, options = {}) {
|
|
|
135
180
|
url, title,
|
|
136
181
|
light: lightData,
|
|
137
182
|
dark: darkData,
|
|
183
|
+
interactState,
|
|
184
|
+
routes: routes.length > 0 ? routes : undefined,
|
|
138
185
|
pagesAnalyzed: 1 + additionalPages.length,
|
|
139
186
|
componentScreenshots,
|
|
140
187
|
};
|
|
@@ -210,6 +257,137 @@ export async function captureComponentScreenshots(page, outDir) {
|
|
|
210
257
|
return result;
|
|
211
258
|
}
|
|
212
259
|
|
|
260
|
+
async function snapshotSelector(page, selector) {
|
|
261
|
+
try {
|
|
262
|
+
return await page.evaluate((sel) => {
|
|
263
|
+
const el = document.querySelector(sel);
|
|
264
|
+
if (!el) return null;
|
|
265
|
+
const cs = getComputedStyle(el);
|
|
266
|
+
return {
|
|
267
|
+
color: cs.color, backgroundColor: cs.backgroundColor,
|
|
268
|
+
borderColor: cs.borderColor, boxShadow: cs.boxShadow,
|
|
269
|
+
transform: cs.transform, opacity: cs.opacity,
|
|
270
|
+
outline: cs.outline, textDecoration: cs.textDecoration,
|
|
271
|
+
};
|
|
272
|
+
}, selector);
|
|
273
|
+
} catch { return null; }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function runInteractionPass(page) {
|
|
277
|
+
const state = {
|
|
278
|
+
scrollSettled: false,
|
|
279
|
+
menusOpened: 0,
|
|
280
|
+
hoverSamples: [],
|
|
281
|
+
accordionsOpened: 0,
|
|
282
|
+
modals: [],
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// 1) Full-page scroll in 4 steps to trigger lazy-load + scroll-linked animations
|
|
286
|
+
try {
|
|
287
|
+
for (let i = 1; i <= 4; i++) {
|
|
288
|
+
await page.evaluate((step) => {
|
|
289
|
+
const h = document.body.scrollHeight;
|
|
290
|
+
window.scrollTo(0, (h * step) / 4);
|
|
291
|
+
}, i).catch(() => {});
|
|
292
|
+
await page.waitForTimeout(300);
|
|
293
|
+
await page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => {});
|
|
294
|
+
}
|
|
295
|
+
await page.evaluate(() => window.scrollTo(0, 0)).catch(() => {});
|
|
296
|
+
await page.waitForTimeout(200);
|
|
297
|
+
state.scrollSettled = true;
|
|
298
|
+
} catch { /* ignore */ }
|
|
299
|
+
|
|
300
|
+
// 2) Open menus / dropdowns
|
|
301
|
+
try {
|
|
302
|
+
const triggers = await page.$$('nav [aria-haspopup], [aria-expanded="false"], .menu-toggle, .hamburger, [data-menu]');
|
|
303
|
+
for (const t of triggers.slice(0, 5)) {
|
|
304
|
+
try {
|
|
305
|
+
await t.click({ timeout: 1000, trial: false });
|
|
306
|
+
state.menusOpened++;
|
|
307
|
+
} catch { /* ignore */ }
|
|
308
|
+
}
|
|
309
|
+
await page.waitForTimeout(400);
|
|
310
|
+
} catch { /* ignore */ }
|
|
311
|
+
|
|
312
|
+
// 3) Hover up to 6 buttons + 6 links with style diffs
|
|
313
|
+
try {
|
|
314
|
+
const btnSelectors = await page.evaluate(() => {
|
|
315
|
+
const arr = [];
|
|
316
|
+
const btns = Array.from(document.querySelectorAll('button')).slice(0, 6);
|
|
317
|
+
btns.forEach((el, i) => arr.push(`button:nth-of-type(${i + 1})`));
|
|
318
|
+
return arr;
|
|
319
|
+
});
|
|
320
|
+
const linkSelectors = await page.evaluate(() => {
|
|
321
|
+
const arr = [];
|
|
322
|
+
const links = Array.from(document.querySelectorAll('a[href]')).slice(0, 6);
|
|
323
|
+
links.forEach((el, i) => arr.push(`a[href]:nth-of-type(${i + 1})`));
|
|
324
|
+
return arr;
|
|
325
|
+
});
|
|
326
|
+
const samples = [...(btnSelectors || []), ...(linkSelectors || [])].slice(0, 12);
|
|
327
|
+
for (const sel of samples) {
|
|
328
|
+
const before = await snapshotSelector(page, sel);
|
|
329
|
+
if (!before) continue;
|
|
330
|
+
try {
|
|
331
|
+
await page.hover(sel, { timeout: 500 });
|
|
332
|
+
await page.waitForTimeout(100);
|
|
333
|
+
const after = await snapshotSelector(page, sel);
|
|
334
|
+
if (after) state.hoverSamples.push({ selector: sel, before, after });
|
|
335
|
+
} catch { /* ignore */ }
|
|
336
|
+
}
|
|
337
|
+
} catch { /* ignore */ }
|
|
338
|
+
|
|
339
|
+
// 4) Accordions / details
|
|
340
|
+
try {
|
|
341
|
+
const accs = await page.$$('details, [role="tab"], [data-accordion]');
|
|
342
|
+
for (const a of accs.slice(0, 6)) {
|
|
343
|
+
try {
|
|
344
|
+
await a.click({ timeout: 800 });
|
|
345
|
+
state.accordionsOpened++;
|
|
346
|
+
} catch { /* ignore */ }
|
|
347
|
+
}
|
|
348
|
+
await page.waitForTimeout(200);
|
|
349
|
+
} catch { /* ignore */ }
|
|
350
|
+
|
|
351
|
+
// 5) First triggerable modal / dialog
|
|
352
|
+
try {
|
|
353
|
+
const candidates = await page.$$('button, a[role="button"]');
|
|
354
|
+
let triggered = false;
|
|
355
|
+
for (const c of candidates.slice(0, 30)) {
|
|
356
|
+
if (triggered) break;
|
|
357
|
+
try {
|
|
358
|
+
const txt = (await c.innerText({ timeout: 500 }).catch(() => '')) || '';
|
|
359
|
+
if (!/sign\s*in|log\s*in|menu|open|subscribe/i.test(txt)) continue;
|
|
360
|
+
await c.click({ timeout: 2000 });
|
|
361
|
+
await page.waitForTimeout(600);
|
|
362
|
+
const snapshot = await page.evaluate(() => {
|
|
363
|
+
const dlg = document.querySelector('dialog[open], [role="dialog"], [aria-modal="true"]');
|
|
364
|
+
if (!dlg) return null;
|
|
365
|
+
const cs = getComputedStyle(dlg);
|
|
366
|
+
const r = dlg.getBoundingClientRect();
|
|
367
|
+
return {
|
|
368
|
+
tag: dlg.tagName.toLowerCase(),
|
|
369
|
+
role: dlg.getAttribute('role') || '',
|
|
370
|
+
bg: cs.backgroundColor,
|
|
371
|
+
color: cs.color,
|
|
372
|
+
boxShadow: cs.boxShadow,
|
|
373
|
+
borderRadius: cs.borderRadius,
|
|
374
|
+
width: r.width,
|
|
375
|
+
height: r.height,
|
|
376
|
+
};
|
|
377
|
+
});
|
|
378
|
+
if (snapshot) {
|
|
379
|
+
state.modals.push({ trigger: txt.slice(0, 60), snapshot });
|
|
380
|
+
triggered = true;
|
|
381
|
+
}
|
|
382
|
+
await page.keyboard.press('Escape').catch(() => {});
|
|
383
|
+
await page.waitForTimeout(200);
|
|
384
|
+
} catch { /* ignore */ }
|
|
385
|
+
}
|
|
386
|
+
} catch { /* ignore */ }
|
|
387
|
+
|
|
388
|
+
return state;
|
|
389
|
+
}
|
|
390
|
+
|
|
213
391
|
async function extractPageData(page, ignoreSelectors) {
|
|
214
392
|
const data = await page.evaluate(({ maxElements, ignoreSelectors }) => {
|
|
215
393
|
// Remove ignored elements before extraction
|
|
@@ -244,6 +422,67 @@ async function extractPageData(page, ignoreSelectors) {
|
|
|
244
422
|
}
|
|
245
423
|
const elements = collectElements(document, []);
|
|
246
424
|
|
|
425
|
+
// Build a lightweight index: stylesheet URL + their top selectors.
|
|
426
|
+
// Used to attribute each element's primary source stylesheet.
|
|
427
|
+
const sheetIndex = [];
|
|
428
|
+
try {
|
|
429
|
+
for (const sheet of document.styleSheets) {
|
|
430
|
+
const entry = { url: sheet.href || '', mediaText: sheet.media ? sheet.media.mediaText : '', selectors: [] };
|
|
431
|
+
try {
|
|
432
|
+
let cap = 0;
|
|
433
|
+
for (const rule of sheet.cssRules) {
|
|
434
|
+
if (cap >= 200) break;
|
|
435
|
+
if (rule && rule.selectorText) {
|
|
436
|
+
entry.selectors.push(rule.selectorText);
|
|
437
|
+
cap++;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
} catch { /* cross-origin */ }
|
|
441
|
+
if (entry.url || entry.selectors.length > 0) sheetIndex.push(entry);
|
|
442
|
+
}
|
|
443
|
+
} catch { /* no access */ }
|
|
444
|
+
|
|
445
|
+
function findSourceFor(el) {
|
|
446
|
+
// Try to find the first stylesheet that has a selector matching this element.
|
|
447
|
+
for (const sheet of sheetIndex) {
|
|
448
|
+
for (const sel of sheet.selectors) {
|
|
449
|
+
try {
|
|
450
|
+
// selectorText can contain multiple comma-separated selectors
|
|
451
|
+
if (el.matches(sel)) {
|
|
452
|
+
return { url: sheet.url, mediaText: sheet.mediaText };
|
|
453
|
+
}
|
|
454
|
+
} catch { /* invalid or unsupported selector */ }
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function readPseudo(el, which) {
|
|
461
|
+
try {
|
|
462
|
+
const ps = getComputedStyle(el, which);
|
|
463
|
+
const content = ps.getPropertyValue('content');
|
|
464
|
+
if (!content || content === 'none' || content === 'normal') return null;
|
|
465
|
+
return {
|
|
466
|
+
content,
|
|
467
|
+
display: ps.display,
|
|
468
|
+
position: ps.position,
|
|
469
|
+
top: ps.top,
|
|
470
|
+
left: ps.left,
|
|
471
|
+
right: ps.right,
|
|
472
|
+
bottom: ps.bottom,
|
|
473
|
+
width: ps.width,
|
|
474
|
+
height: ps.height,
|
|
475
|
+
background: ps.background,
|
|
476
|
+
color: ps.color,
|
|
477
|
+
border: ps.border,
|
|
478
|
+
transform: ps.transform,
|
|
479
|
+
mask: ps.mask || ps.getPropertyValue('-webkit-mask') || '',
|
|
480
|
+
clipPath: ps.clipPath || ps.getPropertyValue('-webkit-clip-path') || '',
|
|
481
|
+
};
|
|
482
|
+
} catch { return null; }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
let sourceAttrBudget = 500;
|
|
247
486
|
for (const el of elements) {
|
|
248
487
|
const cs = getComputedStyle(el);
|
|
249
488
|
const tag = el.tagName.toLowerCase();
|
|
@@ -252,6 +491,17 @@ async function extractPageData(page, ignoreSelectors) {
|
|
|
252
491
|
const rect = el.getBoundingClientRect();
|
|
253
492
|
const area = rect.width * rect.height;
|
|
254
493
|
|
|
494
|
+
const before = readPseudo(el, '::before');
|
|
495
|
+
const after = readPseudo(el, '::after');
|
|
496
|
+
const pseudo = (before || after) ? { before, after } : null;
|
|
497
|
+
|
|
498
|
+
let sources = null;
|
|
499
|
+
if (sourceAttrBudget > 0) {
|
|
500
|
+
const s = findSourceFor(el);
|
|
501
|
+
if (s) sources = [s];
|
|
502
|
+
sourceAttrBudget--;
|
|
503
|
+
}
|
|
504
|
+
|
|
255
505
|
results.computedStyles.push({
|
|
256
506
|
tag, classList, role, area,
|
|
257
507
|
color: cs.color,
|
|
@@ -289,6 +539,14 @@ async function extractPageData(page, ignoreSelectors) {
|
|
|
289
539
|
gridTemplateColumns: cs.gridTemplateColumns,
|
|
290
540
|
gridTemplateRows: cs.gridTemplateRows,
|
|
291
541
|
maxWidth: cs.maxWidth,
|
|
542
|
+
fontVariationSettings: cs.fontVariationSettings || cs.getPropertyValue('font-variation-settings') || 'normal',
|
|
543
|
+
fontFeatureSettings: cs.fontFeatureSettings || cs.getPropertyValue('font-feature-settings') || 'normal',
|
|
544
|
+
textWrap: cs.textWrap || cs.getPropertyValue('text-wrap') || '',
|
|
545
|
+
textDecorationStyle: cs.textDecorationStyle || '',
|
|
546
|
+
textDecorationThickness: cs.textDecorationThickness || '',
|
|
547
|
+
textUnderlineOffset: cs.textUnderlineOffset || '',
|
|
548
|
+
pseudo,
|
|
549
|
+
sources,
|
|
292
550
|
});
|
|
293
551
|
}
|
|
294
552
|
|
|
@@ -348,6 +606,82 @@ async function extractPageData(page, ignoreSelectors) {
|
|
|
348
606
|
}
|
|
349
607
|
} catch { /* no access */ }
|
|
350
608
|
|
|
609
|
+
// Container queries (@container rules), env() usage, and modern colors
|
|
610
|
+
results.containerQueries = [];
|
|
611
|
+
results.envUsage = [];
|
|
612
|
+
results.modernColors = [];
|
|
613
|
+
const MODERN_COLOR_RE = /(oklch\([^)]+\)|oklab\([^)]+\)|color-mix\([^)]+\)|light-dark\([^)]+\)|color\(\s*display-p3[^)]+\)|color\(\s*rec2020[^)]+\))/gi;
|
|
614
|
+
function walkRulesForContainersAndEnv(rules) {
|
|
615
|
+
for (const rule of rules) {
|
|
616
|
+
try {
|
|
617
|
+
// Scan declarations for modern color functions
|
|
618
|
+
if (rule.style && rule.cssText) {
|
|
619
|
+
const css = rule.cssText;
|
|
620
|
+
for (const m of css.matchAll(MODERN_COLOR_RE)) {
|
|
621
|
+
const raw = m[1];
|
|
622
|
+
let type = 'other';
|
|
623
|
+
if (/^oklch/i.test(raw)) type = 'oklch';
|
|
624
|
+
else if (/^oklab/i.test(raw)) type = 'oklab';
|
|
625
|
+
else if (/^color-mix/i.test(raw)) type = 'color-mix';
|
|
626
|
+
else if (/^light-dark/i.test(raw)) type = 'light-dark';
|
|
627
|
+
else if (/display-p3/i.test(raw)) type = 'display-p3';
|
|
628
|
+
else if (/rec2020/i.test(raw)) type = 'rec2020';
|
|
629
|
+
// Try to infer property
|
|
630
|
+
let property = '';
|
|
631
|
+
for (let i = 0; i < rule.style.length; i++) {
|
|
632
|
+
const p = rule.style[i];
|
|
633
|
+
if ((rule.style.getPropertyValue(p) || '').includes(raw)) { property = p; break; }
|
|
634
|
+
}
|
|
635
|
+
results.modernColors.push({ raw, type, property, selector: rule.selectorText || '' });
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// Container query
|
|
639
|
+
if (typeof CSSContainerRule !== 'undefined' && rule instanceof CSSContainerRule) {
|
|
640
|
+
const inner = [];
|
|
641
|
+
try {
|
|
642
|
+
for (const inr of rule.cssRules) {
|
|
643
|
+
if (inr.selectorText) inner.push(inr.selectorText);
|
|
644
|
+
}
|
|
645
|
+
} catch {}
|
|
646
|
+
results.containerQueries.push({
|
|
647
|
+
condition: rule.conditionText || rule.containerQuery || '',
|
|
648
|
+
selectorText: inner.join(', '),
|
|
649
|
+
declarationCount: inner.length,
|
|
650
|
+
});
|
|
651
|
+
} else if (rule.cssText && rule.cssText.startsWith('@container')) {
|
|
652
|
+
results.containerQueries.push({
|
|
653
|
+
condition: rule.conditionText || '',
|
|
654
|
+
selectorText: '',
|
|
655
|
+
declarationCount: 0,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
// env() scan on declaration text
|
|
659
|
+
if (rule.style) {
|
|
660
|
+
const css = rule.cssText || '';
|
|
661
|
+
const envMatches = css.match(/env\(\s*(safe-area-inset-[a-z]+|viewport-[a-z-]+|[a-z-]+)/gi);
|
|
662
|
+
if (envMatches) {
|
|
663
|
+
for (const m of envMatches) {
|
|
664
|
+
results.envUsage.push(m.replace(/^env\(\s*/, '').trim());
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// Recurse into grouping rules (media, supports, container)
|
|
669
|
+
if (rule.cssRules) {
|
|
670
|
+
walkRulesForContainersAndEnv(rule.cssRules);
|
|
671
|
+
}
|
|
672
|
+
} catch { /* ignore per-rule errors */ }
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
for (const sheet of document.styleSheets) {
|
|
677
|
+
try {
|
|
678
|
+
walkRulesForContainersAndEnv(sheet.cssRules);
|
|
679
|
+
} catch { /* cross-origin — already tracked */ }
|
|
680
|
+
}
|
|
681
|
+
} catch { /* no access */ }
|
|
682
|
+
// dedupe envUsage
|
|
683
|
+
results.envUsage = [...new Set(results.envUsage)];
|
|
684
|
+
|
|
351
685
|
// Component clusters (v7): per-element features for similarity-based grouping.
|
|
352
686
|
function colorToChannels(str) {
|
|
353
687
|
if (!str) return [0, 0, 0, 0];
|
|
@@ -533,6 +867,31 @@ function parseCrossOriginCSS(cssText, data) {
|
|
|
533
867
|
for (const m of cssText.matchAll(/@media\s*([^{]+)\{/g)) {
|
|
534
868
|
data.mediaQueries.push(m[1].trim());
|
|
535
869
|
}
|
|
870
|
+
// Container queries
|
|
871
|
+
if (!data.containerQueries) data.containerQueries = [];
|
|
872
|
+
for (const m of cssText.matchAll(/@container\s*([^{]*)\{/g)) {
|
|
873
|
+
data.containerQueries.push({ condition: m[1].trim(), selectorText: '', declarationCount: 0 });
|
|
874
|
+
}
|
|
875
|
+
// env() usage
|
|
876
|
+
if (!data.envUsage) data.envUsage = [];
|
|
877
|
+
for (const m of cssText.matchAll(/env\(\s*(safe-area-inset-[a-z]+|viewport-[a-z-]+)/gi)) {
|
|
878
|
+
data.envUsage.push(m[1]);
|
|
879
|
+
}
|
|
880
|
+
data.envUsage = [...new Set(data.envUsage)];
|
|
881
|
+
// Modern colors
|
|
882
|
+
if (!data.modernColors) data.modernColors = [];
|
|
883
|
+
const modernRe = /(oklch\([^)]+\)|oklab\([^)]+\)|color-mix\([^)]+\)|light-dark\([^)]+\)|color\(\s*display-p3[^)]+\)|color\(\s*rec2020[^)]+\))/gi;
|
|
884
|
+
for (const m of cssText.matchAll(modernRe)) {
|
|
885
|
+
const raw = m[1];
|
|
886
|
+
let type = 'other';
|
|
887
|
+
if (/^oklch/i.test(raw)) type = 'oklch';
|
|
888
|
+
else if (/^oklab/i.test(raw)) type = 'oklab';
|
|
889
|
+
else if (/^color-mix/i.test(raw)) type = 'color-mix';
|
|
890
|
+
else if (/^light-dark/i.test(raw)) type = 'light-dark';
|
|
891
|
+
else if (/display-p3/i.test(raw)) type = 'display-p3';
|
|
892
|
+
else if (/rec2020/i.test(raw)) type = 'rec2020';
|
|
893
|
+
data.modernColors.push({ raw, type, property: '', selector: '' });
|
|
894
|
+
}
|
|
536
895
|
// Keyframes
|
|
537
896
|
for (const m of cssText.matchAll(/@keyframes\s+([\w-]+)\s*\{([\s\S]*?)\n\}/g)) {
|
|
538
897
|
const steps = [];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Structured catalog of transition styles captured by the Tier-2 interaction
|
|
2
|
+
// pass — hover deltas, modal appearance, menu styling.
|
|
3
|
+
|
|
4
|
+
function diffStyles(before, after) {
|
|
5
|
+
const diff = {};
|
|
6
|
+
if (!before || !after) return diff;
|
|
7
|
+
for (const k of Object.keys(after)) {
|
|
8
|
+
if (before[k] !== after[k]) {
|
|
9
|
+
diff[k] = { from: before[k], to: after[k] };
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return diff;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function extractInteractionStates(interactState) {
|
|
16
|
+
if (!interactState || typeof interactState !== 'object') {
|
|
17
|
+
return {
|
|
18
|
+
scrollSettled: false,
|
|
19
|
+
menusOpened: 0,
|
|
20
|
+
hover: { sampled: 0, changed: 0, deltas: [] },
|
|
21
|
+
accordionsOpened: 0,
|
|
22
|
+
modals: [],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const deltas = [];
|
|
27
|
+
const samples = Array.isArray(interactState.hoverSamples) ? interactState.hoverSamples : [];
|
|
28
|
+
for (const s of samples) {
|
|
29
|
+
const d = diffStyles(s.before, s.after);
|
|
30
|
+
if (Object.keys(d).length > 0) {
|
|
31
|
+
deltas.push({ selector: s.selector, changes: d });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const modals = Array.isArray(interactState.modals) ? interactState.modals.map(m => ({
|
|
36
|
+
trigger: m.trigger || '',
|
|
37
|
+
bg: m.snapshot?.bg || '',
|
|
38
|
+
color: m.snapshot?.color || '',
|
|
39
|
+
boxShadow: m.snapshot?.boxShadow || '',
|
|
40
|
+
borderRadius: m.snapshot?.borderRadius || '',
|
|
41
|
+
width: m.snapshot?.width || 0,
|
|
42
|
+
height: m.snapshot?.height || 0,
|
|
43
|
+
role: m.snapshot?.role || '',
|
|
44
|
+
})) : [];
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
scrollSettled: !!interactState.scrollSettled,
|
|
48
|
+
menusOpened: interactState.menusOpened || 0,
|
|
49
|
+
hover: {
|
|
50
|
+
sampled: samples.length,
|
|
51
|
+
changed: deltas.length,
|
|
52
|
+
deltas,
|
|
53
|
+
},
|
|
54
|
+
accordionsOpened: interactState.accordionsOpened || 0,
|
|
55
|
+
modals,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Extractor for modern CSS features captured by the crawler:
|
|
2
|
+
// - Pseudo-elements (::before / ::after)
|
|
3
|
+
// - Variable-font axes (font-variation-settings)
|
|
4
|
+
// - OpenType features (font-feature-settings)
|
|
5
|
+
// - Modern text layout (text-wrap, text-decoration-*)
|
|
6
|
+
// - Container queries (@container)
|
|
7
|
+
// - env() usage (safe-area-inset-*, viewport-*)
|
|
8
|
+
|
|
9
|
+
export function extractModernCss(payload) {
|
|
10
|
+
const light = (payload && payload.light) || payload || {};
|
|
11
|
+
const styles = Array.isArray(light.computedStyles) ? light.computedStyles : [];
|
|
12
|
+
|
|
13
|
+
// Pseudo-elements
|
|
14
|
+
const pseudoSamples = [];
|
|
15
|
+
let pseudoCount = 0;
|
|
16
|
+
for (const s of styles) {
|
|
17
|
+
const p = s && s.pseudo;
|
|
18
|
+
if (!p) continue;
|
|
19
|
+
if (p.before) { pseudoCount++; if (pseudoSamples.length < 20) pseudoSamples.push({ tag: s.tag, classList: s.classList, which: '::before', style: p.before }); }
|
|
20
|
+
if (p.after) { pseudoCount++; if (pseudoSamples.length < 20) pseudoSamples.push({ tag: s.tag, classList: s.classList, which: '::after', style: p.after }); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Variable fonts
|
|
24
|
+
const axesMap = new Map();
|
|
25
|
+
let variableFontCount = 0;
|
|
26
|
+
for (const s of styles) {
|
|
27
|
+
const v = s && s.fontVariationSettings;
|
|
28
|
+
if (!v || v === 'normal' || v === '') continue;
|
|
29
|
+
variableFontCount++;
|
|
30
|
+
// e.g. "\"wght\" 600, \"slnt\" -4"
|
|
31
|
+
for (const m of String(v).matchAll(/"([^"]+)"\s+(-?\d+(?:\.\d+)?)/g)) {
|
|
32
|
+
const axis = m[1];
|
|
33
|
+
const val = parseFloat(m[2]);
|
|
34
|
+
if (!axesMap.has(axis)) axesMap.set(axis, { axis, min: val, max: val, count: 0 });
|
|
35
|
+
const a = axesMap.get(axis);
|
|
36
|
+
a.min = Math.min(a.min, val);
|
|
37
|
+
a.max = Math.max(a.max, val);
|
|
38
|
+
a.count++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// OpenType features
|
|
43
|
+
const featMap = new Map();
|
|
44
|
+
for (const s of styles) {
|
|
45
|
+
const f = s && s.fontFeatureSettings;
|
|
46
|
+
if (!f || f === 'normal' || f === '') continue;
|
|
47
|
+
for (const m of String(f).matchAll(/"([^"]+)"(?:\s+(on|off|\d+))?/g)) {
|
|
48
|
+
const key = m[1];
|
|
49
|
+
featMap.set(key, (featMap.get(key) || 0) + 1);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Text-wrap / decoration
|
|
54
|
+
const textWrapMap = new Map();
|
|
55
|
+
const decStyleMap = new Map();
|
|
56
|
+
const thicknessMap = new Map();
|
|
57
|
+
const offsetMap = new Map();
|
|
58
|
+
for (const s of styles) {
|
|
59
|
+
if (s.textWrap && s.textWrap !== 'wrap' && s.textWrap !== '') {
|
|
60
|
+
textWrapMap.set(s.textWrap, (textWrapMap.get(s.textWrap) || 0) + 1);
|
|
61
|
+
}
|
|
62
|
+
if (s.textDecorationStyle && s.textDecorationStyle !== 'solid' && s.textDecorationStyle !== '') {
|
|
63
|
+
decStyleMap.set(s.textDecorationStyle, (decStyleMap.get(s.textDecorationStyle) || 0) + 1);
|
|
64
|
+
}
|
|
65
|
+
if (s.textDecorationThickness && s.textDecorationThickness !== 'auto' && s.textDecorationThickness !== '') {
|
|
66
|
+
thicknessMap.set(s.textDecorationThickness, (thicknessMap.get(s.textDecorationThickness) || 0) + 1);
|
|
67
|
+
}
|
|
68
|
+
if (s.textUnderlineOffset && s.textUnderlineOffset !== 'auto' && s.textUnderlineOffset !== '') {
|
|
69
|
+
offsetMap.set(s.textUnderlineOffset, (offsetMap.get(s.textUnderlineOffset) || 0) + 1);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const containerQueries = Array.isArray(light.containerQueries) ? light.containerQueries : [];
|
|
74
|
+
const envUsage = Array.isArray(light.envUsage) ? light.envUsage : [];
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
pseudoElements: {
|
|
78
|
+
count: pseudoCount,
|
|
79
|
+
samples: pseudoSamples,
|
|
80
|
+
},
|
|
81
|
+
variableFonts: {
|
|
82
|
+
count: variableFontCount,
|
|
83
|
+
axes: [...axesMap.values()].sort((a, b) => b.count - a.count),
|
|
84
|
+
},
|
|
85
|
+
openTypeFeatures: [...featMap.entries()]
|
|
86
|
+
.map(([feature, count]) => ({ feature, count }))
|
|
87
|
+
.sort((a, b) => b.count - a.count),
|
|
88
|
+
textWrap: {
|
|
89
|
+
wrap: [...textWrapMap.entries()].map(([value, count]) => ({ value, count })),
|
|
90
|
+
decorationStyle: [...decStyleMap.entries()].map(([value, count]) => ({ value, count })),
|
|
91
|
+
decorationThickness: [...thicknessMap.entries()].map(([value, count]) => ({ value, count })),
|
|
92
|
+
underlineOffset: [...offsetMap.entries()].map(([value, count]) => ({ value, count })),
|
|
93
|
+
},
|
|
94
|
+
containerQueries: {
|
|
95
|
+
count: containerQueries.length,
|
|
96
|
+
rules: containerQueries,
|
|
97
|
+
},
|
|
98
|
+
envUsage: [...new Set(envUsage)],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Attribute top design tokens back to the stylesheet URL that most likely
|
|
2
|
+
// contributed them. Uses the per-element `sources` captured by the crawler.
|
|
3
|
+
|
|
4
|
+
import { parseColor, rgbToHex } from '../utils.js';
|
|
5
|
+
|
|
6
|
+
function firstSourceUrlWhere(styles, predicate) {
|
|
7
|
+
for (const s of styles) {
|
|
8
|
+
if (!s || !predicate(s)) continue;
|
|
9
|
+
const src = Array.isArray(s.sources) ? s.sources[0] : null;
|
|
10
|
+
if (src && src.url) return src.url;
|
|
11
|
+
}
|
|
12
|
+
return '';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function extractTokenSources(design, computedStyles) {
|
|
16
|
+
const styles = Array.isArray(computedStyles) ? computedStyles : [];
|
|
17
|
+
const out = [];
|
|
18
|
+
|
|
19
|
+
// Primary color
|
|
20
|
+
const primaryHex = design.colors?.primary?.hex;
|
|
21
|
+
if (primaryHex) {
|
|
22
|
+
const url = firstSourceUrlWhere(styles, s => {
|
|
23
|
+
const p = parseColor(s.color);
|
|
24
|
+
return p && rgbToHex(p) === primaryHex;
|
|
25
|
+
});
|
|
26
|
+
out.push({ token: 'color.primary', path: 'colors.primary', sourceUrl: url });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Text color (first in design.colors.text[])
|
|
30
|
+
const textHex = (design.colors?.text || [])[0];
|
|
31
|
+
if (textHex) {
|
|
32
|
+
const url = firstSourceUrlWhere(styles, s => {
|
|
33
|
+
const p = parseColor(s.color);
|
|
34
|
+
return p && rgbToHex(p) === textHex;
|
|
35
|
+
});
|
|
36
|
+
out.push({ token: 'color.text', path: 'colors.text[0]', sourceUrl: url });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Body font — first typography family
|
|
40
|
+
const bodyFont = design.typography?.families?.[0]?.name;
|
|
41
|
+
if (bodyFont) {
|
|
42
|
+
const url = firstSourceUrlWhere(styles, s => typeof s.fontFamily === 'string' && s.fontFamily.includes(bodyFont));
|
|
43
|
+
out.push({ token: 'font.body', path: 'typography.families[0]', sourceUrl: url });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Spacing base
|
|
47
|
+
const spacingBase = design.spacing?.base;
|
|
48
|
+
if (spacingBase != null) {
|
|
49
|
+
const target = `${spacingBase}px`;
|
|
50
|
+
const url = firstSourceUrlWhere(styles,
|
|
51
|
+
s => s.paddingTop === target || s.paddingLeft === target || s.marginTop === target || s.gap === target);
|
|
52
|
+
out.push({ token: 'spacing.base', path: 'spacing.base', sourceUrl: url });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Radius base — first non-zero from design.borders.radii
|
|
56
|
+
const radii = design.borders?.radii || [];
|
|
57
|
+
const firstRadius = radii.find(r => (r.value || r) && (r.value || r) !== '0px');
|
|
58
|
+
if (firstRadius) {
|
|
59
|
+
const target = typeof firstRadius === 'string' ? firstRadius : (firstRadius.value || '');
|
|
60
|
+
const url = firstSourceUrlWhere(styles, s => s.borderRadius === target);
|
|
61
|
+
out.push({ token: 'radius.base', path: 'borders.radii[0]', sourceUrl: url });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return out;
|
|
65
|
+
}
|