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.
- package/.github/og-preview.png +0 -0
- package/.github/workflows/manavarya-bot.yml +17 -0
- package/CHANGELOG.md +14 -0
- package/README.md +29 -4
- package/bin/design-extract.js +23 -1
- package/package.json +1 -1
- package/src/config.js +2 -1
- package/src/crawler.js +341 -0
- 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/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/page.js +55 -8
|
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
|
-
|
|
|
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
|
-
|
|
|
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
|
-
--
|
|
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)
|
package/bin/design-extract.js
CHANGED
|
@@ -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('--
|
|
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.
|
|
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
|
+
}
|