designlang 7.1.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.
Binary file
@@ -0,0 +1,17 @@
1
+ # Managed by bot-manavarya/reviewer — edits will be overwritten.
2
+ name: manavarya-bot review
3
+
4
+ on:
5
+ pull_request:
6
+ types: [opened, synchronize, reopened, ready_for_review]
7
+
8
+ jobs:
9
+ review:
10
+ if: ${{ github.event.pull_request.draft == false }}
11
+ uses: bot-manavarya/reviewer/.github/workflows/review.yml@main
12
+ with:
13
+ provider: 'gemini'
14
+ model: 'gemini-2.5-flash'
15
+ secrets:
16
+ bot-token: ${{ secrets.MANAVARYA_BOT_TOKEN }}
17
+ gemini-api-key: ${{ secrets.GEMINI_API_KEY }}
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [7.2.0] — 2026-04-19
4
+
5
+ ### Added
6
+
7
+ - **Modern CSS surfacing (Tier 1a)** — crawler now captures pseudo-elements, variable-font axes (`font-variation-settings`), `@container` queries, and `env()` usage. Surfaced on `design.modernCss`. (#33)
8
+ - **Wide-gamut color + CSS source attribution (Tier 1b)** — `oklch()`, `oklab()`, `color-mix()`, `light-dark()`, Display P3, and Rec2020 references are collected on `design.wideGamut`. A new `design.tokenSources` maps each extracted token to the stylesheet URL it first appeared in. (#34)
9
+ - **Auto-interact pass (Tier 2)** — new `--deep-interact` flag (implied by `--full`) runs an interaction pass before extraction: full-page scroll in 4 steps, menu/dropdown opens, hover snapshots for the first batch of buttons/links with computed-style diffs, accordion clicks, and first-match modal trigger. Results populate `design.interactionStates` (hover deltas, menu/modal snapshots). Every step is wrapped in try/catch with per-step timeouts so interaction failures never kill the crawl.
10
+ - **Multi-page token reconciliation (Tier 2)** — when `--depth >= 1` the extractor now emits three new artifacts alongside the merged baseline: `*-tokens-shared.json` (tokens shared across every route), `*-tokens-routes/<slug>.json` (per-route `added` and `changed` deltas), and `*-routes-report.md` (readable summary). Slugs are derived from the route path (`/` → `index`) with automatic collision handling.
11
+
12
+ ### Changed
13
+
14
+ - `--full` now also enables `--deep-interact`.
15
+ - `--depth <n>` description updated to mention the new reconciliation outputs.
16
+
3
17
  ## [7.1.0] — 2026-04-19
4
18
 
5
19
  ### Added
package/README.md CHANGED
@@ -311,6 +311,26 @@ A dedicated audit pass surfaced on `design.cssHealth`:
311
311
 
312
312
  Also contributes a `cssHealth` dimension to the overall design score.
313
313
 
314
+ ### 22. Chrome Extension (NEW in v7.1)
315
+
316
+ A Manifest-v3 popup lives in [`chrome-extension/`](chrome-extension/). One click on any tab opens `designlang.manavaryasingh.com` with the URL prefilled — no copy-paste, no context switch. There is also a **Copy CLI** button that puts `npx designlang <url>` in your clipboard.
317
+
318
+ - **Permissions:** `activeTab` only, plus host access to the hosted extractor.
319
+ - **Install:** toggle developer mode at `chrome://extensions`, click *Load unpacked*, pick the `chrome-extension/` folder.
320
+ - **Firefox + Edge** work with the same MV3 manifest.
321
+
322
+ ### 23. Better Auth + Network Control (NEW in v7.1)
323
+
324
+ Extracting from authenticated, self-signed, or non-default environments now takes one flag:
325
+
326
+ - **`--cookie-file <path>`** — loads cookies from JSON array, Playwright `storageState.json`, or Netscape `cookies.txt` (browser extensions, curl exports). Merges cleanly with the existing `--cookie name=value` flag.
327
+ - **`--insecure`** — ignore HTTPS/SSL certificate errors for self-signed dev servers, corporate staging, or MITM tools.
328
+ - **`--user-agent <ua>`** — override the browser User-Agent string.
329
+
330
+ ```bash
331
+ designlang https://staging.internal --cookie-file ./session.json --insecure
332
+ ```
333
+
314
334
  ## All Features
315
335
 
316
336
  | Feature | Flag / Command | Description |
@@ -325,12 +345,16 @@ Also contributes a `cssHealth` dimension to the overall design score.
325
345
  | Font files | automatic | Source detection (Google/self-hosted/CDN/system), @font-face CSS |
326
346
  | Image styles | automatic | Aspect ratios, shapes, filters, pattern classification |
327
347
  | Dark mode | `--dark` | Extracts dark color scheme + light/dark diff |
328
- | Auth pages | `--cookie`, `--header` | Extract from authenticated/protected pages |
329
- | Multi-page | `--depth <n>` | Crawl N internal pages for site-wide tokens |
348
+ | Auth pages | `--cookie`, `--cookie-file`, `--header` | Extract from authenticated/protected pages; cookie files in JSON / Playwright storageState / Netscape formats |
349
+ | Self-signed / dev TLS | `--insecure` | Ignore HTTPS/SSL certificate errors |
350
+ | User-Agent override | `--user-agent <ua>` | Set a custom User-Agent string |
351
+ | Chrome extension | `chrome-extension/` | One-click handoff from any tab, MV3, `activeTab` only |
352
+ | Multi-page | `--depth <n>` | Crawl N internal pages; emits shared-vs-per-route token reconciliation (`*-tokens-shared.json`, `*-tokens-routes/<slug>.json`, `*-routes-report.md`) |
330
353
  | Screenshots | `--screenshots` | Capture buttons, cards, inputs, nav, hero, full page |
331
354
  | Responsive | `--responsive` | Crawl at 4 viewports, map breakpoint changes |
332
355
  | Interactions | `--interactions` | Capture hover/focus/active state transitions |
333
- | Everything | `--full` | Enable screenshots + responsive + interactions |
356
+ | Auto-interact | `--deep-interact` | Scroll, open menus/modals/accordions, hover CTAs before extraction |
357
+ | Everything | `--full` | Enable screenshots + responsive + interactions + deep-interact |
334
358
  | Apply | `designlang apply <url>` | Auto-detect framework and write tokens to your project |
335
359
  | Clone | `designlang clone <url>` | Generate a working Next.js starter with extracted design |
336
360
  | Score | `designlang score <url>` | Rate design quality with visual bar chart breakdown |
@@ -365,7 +389,8 @@ Options:
365
389
  --screenshots Capture component screenshots
366
390
  --responsive Capture at multiple breakpoints
367
391
  --interactions Capture hover/focus/active states
368
- --full Enable all captures
392
+ --deep-interact Auto-interact pass (scroll, menus, modals, accordions, hover CTAs)
393
+ --full Enable all captures (implies --deep-interact)
369
394
  --cookie <cookies...> Cookies for authenticated pages (name=value)
370
395
  --cookie-file <path> Load cookies from JSON / storageState / Netscape cookies.txt
371
396
  --header <headers...> Custom headers (name:value)
@@ -21,6 +21,7 @@ import { formatFlutterDart } from '../src/formatters/flutter-dart.js';
21
21
  import { formatVueTheme } from '../src/formatters/vue-theme.js';
22
22
  import { formatSvelteTheme } from '../src/formatters/svelte-theme.js';
23
23
  import { formatAgentRules } from '../src/formatters/agent-rules.js';
24
+ import { reconcileRoutes, formatRoutesReport } from '../src/formatters/routes-reconciliation.js';
24
25
  import { loadConfig, mergeConfig } from '../src/config.js';
25
26
  import { diffDesigns, formatDiffMarkdown, formatDiffHtml } from '../src/diff.js';
26
27
  import { saveSnapshot, getHistory, formatHistoryMarkdown } from '../src/history.js';
@@ -63,7 +64,8 @@ program
63
64
  .option('--framework <type>', 'generate framework theme (react, shadcn, vue, svelte)')
64
65
  .option('--responsive', 'capture design at multiple breakpoints')
65
66
  .option('--interactions', 'capture hover/focus/active states')
66
- .option('--full', 'enable all extra captures (screenshots, responsive, interactions)')
67
+ .option('--deep-interact', 'auto-interact pass: scroll, open menus/modals/accordions, hover CTAs (implies --interactions)')
68
+ .option('--full', 'enable all extra captures (screenshots, responsive, interactions, deep-interact)')
67
69
  .option('--cookie <cookies...>', 'cookies for authenticated pages (name=value)')
68
70
  .option('--cookie-file <path>', 'load cookies from JSON, Playwright storageState, or Netscape cookies.txt')
69
71
  .option('--header <headers...>', 'custom headers (name:value)')
@@ -152,6 +154,7 @@ program
152
154
  headers: Object.keys(headers).length > 0 ? headers : undefined,
153
155
  insecure: merged.insecure || false,
154
156
  userAgent: merged.userAgent,
157
+ deepInteract: merged.deepInteract || merged.full,
155
158
  });
156
159
 
157
160
  // Responsive capture
@@ -258,6 +261,25 @@ program
258
261
  }
259
262
  }
260
263
 
264
+ // Multi-route token reconciliation (Tier 2). Only when --depth >= 1 and
265
+ // the crawler actually returned per-route token data.
266
+ if (merged.depth >= 1 && Array.isArray(design.routes) && design.routes.length > 0) {
267
+ const reconciled = reconcileRoutes(design.routes);
268
+ const sharedPath = join(outDir, `${prefix}-tokens-shared.json`);
269
+ writeFileSync(sharedPath, JSON.stringify(reconciled.shared, null, 2), 'utf-8');
270
+ platformFiles.push({ path: sharedPath, label: 'Shared tokens (multi-route)' });
271
+ const routesDir = join(outDir, `${prefix}-tokens-routes`);
272
+ mkdirSync(routesDir, { recursive: true });
273
+ for (const [slug, entry] of Object.entries(reconciled.perRoute)) {
274
+ const rp = join(routesDir, `${slug}.json`);
275
+ writeFileSync(rp, JSON.stringify({ url: entry.url, path: entry.path, added: entry.added, changed: entry.changed }, null, 2), 'utf-8');
276
+ platformFiles.push({ path: rp, label: `Route tokens (${slug})` });
277
+ }
278
+ const reportPath = join(outDir, `${prefix}-routes-report.md`);
279
+ writeFileSync(reportPath, formatRoutesReport(reconciled), 'utf-8');
280
+ platformFiles.push({ path: reportPath, label: 'Routes report (markdown)' });
281
+ }
282
+
261
283
  // Agent rules (opt-in, also enabled by --full)
262
284
  if (merged.emitAgentRules || merged.full) {
263
285
  const agentFiles = formatAgentRules({ design, tokens: dtcgTokens, url });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "designlang",
3
- "version": "7.1.0",
3
+ "version": "7.2.0",
4
4
  "description": "Extract the complete design language from any website — colors, typography, spacing, shadows, and more. Outputs AI-optimized markdown, W3C design tokens, Tailwind config, and CSS variables.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.js CHANGED
@@ -27,7 +27,8 @@ export function mergeConfig(cliOpts, config) {
27
27
  screenshots: cliOpts.screenshots || config.screenshots || false,
28
28
  framework: cliOpts.framework || config.framework,
29
29
  responsive: cliOpts.responsive || config.responsive || false,
30
- interactions: cliOpts.interactions || config.interactions || false,
30
+ interactions: cliOpts.interactions || cliOpts.deepInteract || config.interactions || false,
31
+ deepInteract: cliOpts.deepInteract || config.deepInteract || false,
31
32
  full: cliOpts.full || config.full || false,
32
33
  cookie: cliOpts.cookie || config.cookies,
33
34
  header: cliOpts.header || config.headers,
package/src/crawler.js CHANGED
@@ -23,6 +23,7 @@ export async function crawlPage(url, options = {}) {
23
23
  cookies, headers, ignore,
24
24
  insecure = false,
25
25
  userAgent,
26
+ deepInteract = false,
26
27
  } = options;
27
28
 
28
29
  const launchArgs = [
@@ -89,8 +90,16 @@ export async function crawlPage(url, options = {}) {
89
90
  }
90
91
 
91
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
+
92
100
  const lightData = await extractPageData(page, ignore);
93
101
  lightData.cssCoverage = cssCoverage;
102
+ if (interactState) lightData.interactState = interactState;
94
103
 
95
104
  // Component screenshots
96
105
  let componentScreenshots = {};
@@ -100,7 +109,17 @@ export async function crawlPage(url, options = {}) {
100
109
 
101
110
  // Multi-page crawl: discover internal links and extract from them
102
111
  let additionalPages = [];
112
+ const routes = [];
103
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 */ }
104
123
  const internalLinks = await discoverInternalLinks(page, url, depth);
105
124
  for (const link of internalLinks) {
106
125
  try {
@@ -109,6 +128,14 @@ export async function crawlPage(url, options = {}) {
109
128
  await page.evaluate(() => document.fonts.ready).catch(() => {});
110
129
  const pageData = await extractPageData(page);
111
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 */ }
112
139
  } catch { /* skip failed pages */ }
113
140
  }
114
141
  }
@@ -153,6 +180,8 @@ export async function crawlPage(url, options = {}) {
153
180
  url, title,
154
181
  light: lightData,
155
182
  dark: darkData,
183
+ interactState,
184
+ routes: routes.length > 0 ? routes : undefined,
156
185
  pagesAnalyzed: 1 + additionalPages.length,
157
186
  componentScreenshots,
158
187
  };
@@ -228,6 +257,137 @@ export async function captureComponentScreenshots(page, outDir) {
228
257
  return result;
229
258
  }
230
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
+
231
391
  async function extractPageData(page, ignoreSelectors) {
232
392
  const data = await page.evaluate(({ maxElements, ignoreSelectors }) => {
233
393
  // Remove ignored elements before extraction
@@ -262,6 +422,67 @@ async function extractPageData(page, ignoreSelectors) {
262
422
  }
263
423
  const elements = collectElements(document, []);
264
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;
265
486
  for (const el of elements) {
266
487
  const cs = getComputedStyle(el);
267
488
  const tag = el.tagName.toLowerCase();
@@ -270,6 +491,17 @@ async function extractPageData(page, ignoreSelectors) {
270
491
  const rect = el.getBoundingClientRect();
271
492
  const area = rect.width * rect.height;
272
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
+
273
505
  results.computedStyles.push({
274
506
  tag, classList, role, area,
275
507
  color: cs.color,
@@ -307,6 +539,14 @@ async function extractPageData(page, ignoreSelectors) {
307
539
  gridTemplateColumns: cs.gridTemplateColumns,
308
540
  gridTemplateRows: cs.gridTemplateRows,
309
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,
310
550
  });
311
551
  }
312
552
 
@@ -366,6 +606,82 @@ async function extractPageData(page, ignoreSelectors) {
366
606
  }
367
607
  } catch { /* no access */ }
368
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
+
369
685
  // Component clusters (v7): per-element features for similarity-based grouping.
370
686
  function colorToChannels(str) {
371
687
  if (!str) return [0, 0, 0, 0];
@@ -551,6 +867,31 @@ function parseCrossOriginCSS(cssText, data) {
551
867
  for (const m of cssText.matchAll(/@media\s*([^{]+)\{/g)) {
552
868
  data.mediaQueries.push(m[1].trim());
553
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
+ }
554
895
  // Keyframes
555
896
  for (const m of cssText.matchAll(/@keyframes\s+([\w-]+)\s*\{([\s\S]*?)\n\}/g)) {
556
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
+ }