@tangle-network/browser-agent-driver 0.7.0 → 0.8.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.
@@ -0,0 +1,1223 @@
1
+ /**
2
+ * CLI handler for `bad design-audit` — multi-page design quality audit.
3
+ *
4
+ * Crawls a site, captures screenshots at each page, and produces a
5
+ * structured report with findings scored by severity.
6
+ */
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import { chromium } from 'playwright';
10
+ import { Brain } from './brain/index.js';
11
+ import { PlaywrightDriver } from './drivers/playwright.js';
12
+ import { resolveProviderApiKey, resolveProviderModelName } from './provider-defaults.js';
13
+ import { loadLocalEnvFiles } from './env-loader.js';
14
+ // ---------------------------------------------------------------------------
15
+ // Audit profiles — domain-specific rubrics injected into the system prompt
16
+ // ---------------------------------------------------------------------------
17
+ const PROFILE_RUBRICS = {
18
+ general: `
19
+ CALIBRATION — be ruthlessly honest:
20
+ - 9-10: Only world-class design (Linear, Stripe, Vercel). Exceptional typography, spacing, micro-interactions.
21
+ - 7-8: Professional and polished. Minor inconsistencies tolerated if the overall system is coherent.
22
+ - 5-6: Functional but clearly lacks design investment. Default component libraries with no customization.
23
+ - 3-4: Noticeably broken or amateurish. Inconsistent spacing, clashing colors, poor hierarchy.
24
+ - 1-2: Unusable or visually broken. Overlapping elements, unreadable text, broken layouts.
25
+
26
+ Most production apps score 5-7. Very few deserve 8+. Do NOT grade on a curve.`,
27
+ saas: `
28
+ SaaS APPLICATION AUDIT — evaluate as a paying customer would:
29
+ - Information density: is data presented efficiently without clutter?
30
+ - Navigation: can I find features in <3 clicks? Is the sidebar/nav intuitive?
31
+ - Empty states: what happens with no data? Are there helpful onboarding prompts?
32
+ - Loading states: are there skeleton screens or spinners, or does content pop in?
33
+ - Form design: inline validation? Clear labels? Logical tab order?
34
+ - Dashboard layout: is the most important data prominent? Card hierarchy clear?
35
+ - Error states: are errors actionable with clear recovery paths?
36
+ - Consistency: do buttons, inputs, modals follow the same patterns throughout?
37
+
38
+ CALIBRATION: Linear, Notion, Figma = 9. Generic admin templates = 5. Broken CRUD apps = 3.`,
39
+ defi: `
40
+ DeFi/CRYPTO APPLICATION AUDIT — evaluate as a trader managing real money:
41
+ - Trust signals: does the UI feel safe to connect a wallet to? Professional or sketchy?
42
+ - Token displays: are balances formatted correctly? Right decimal places? USD equivalents?
43
+ - Transaction clarity: is it clear what you're signing? Amount, fees, slippage shown?
44
+ - Loading states: RPC calls are slow — are there proper loading indicators for balances, quotes, gas estimates?
45
+ - Mobile responsiveness: most DeFi usage is mobile — does it work on small viewports?
46
+ - Dark mode: most DeFi apps use dark mode — is contrast sufficient? Are borders visible?
47
+ - Error handling: what happens when RPC fails? Wallet rejects? Insufficient balance?
48
+ - Swap UX: is token selection intuitive? Can you paste addresses? Is the price impact shown?
49
+ - Wallet connection: is the connect flow smooth? Are supported wallets clearly shown?
50
+ - Gas/fee transparency: are gas estimates shown before confirmation?
51
+
52
+ CALIBRATION: Uniswap v4, Aave v3 = 8. Average DEX = 5. Rug-pull-looking sites = 2.
53
+ Uniswap is good but not perfect — dense token lists, some spacing issues, swap review could be clearer.`,
54
+ marketing: `
55
+ MARKETING/LANDING PAGE AUDIT — evaluate as a potential customer deciding in 10 seconds:
56
+ - Hero clarity: in 5 seconds, can I tell what this product does and who it's for?
57
+ - Visual hierarchy: does the eye flow naturally from headline → subtext → CTA?
58
+ - CTA prominence: is the primary call-to-action obvious and compelling?
59
+ - Social proof: are testimonials, logos, or metrics shown convincingly?
60
+ - Typography: is the headline typography impactful? Body text readable?
61
+ - Imagery: are images/illustrations high quality and relevant, or stock photo generic?
62
+ - Whitespace: does the page breathe, or is it cramped?
63
+ - Mobile: does the hero and CTA work on mobile without scrolling?
64
+ - Performance perception: does it feel fast? Are images optimized? No layout shift?
65
+ - Footer: is navigation complete? Legal links present?
66
+
67
+ CALIBRATION: Stripe, Linear, Vercel = 9. Average startup landing page = 5. Template sites = 3.`,
68
+ };
69
+ // ---------------------------------------------------------------------------
70
+ // Upgraded system prompt — much more opinionated than the original
71
+ // ---------------------------------------------------------------------------
72
+ function buildAuditPrompt(profile) {
73
+ const rubric = PROFILE_RUBRICS[profile] || PROFILE_RUBRICS.general;
74
+ return `You are a brutal, honest design critic with 15 years of experience at top design studios.
75
+ You have zero tolerance for mediocrity. You call out every flaw you see.
76
+
77
+ Your job: audit this page's visual design, UX, and polish. Be specific — reference exact elements, colors, spacing values, and positions.
78
+
79
+ EVALUATION CRITERIA:
80
+ 1. LAYOUT — Grid consistency, alignment, responsive behavior, content hierarchy
81
+ 2. TYPOGRAPHY — Font pairing, size scale, line height, letter spacing, readability
82
+ 3. COLOR — Palette coherence, contrast ratios (WCAG AA: 4.5:1 text, 3:1 large text), semantic usage
83
+ 4. SPACING — Consistent rhythm (4/8px grid), padding/margin consistency, breathing room
84
+ 5. COMPONENTS — Button styles, input fields, cards, modals — are they consistent?
85
+ 6. INTERACTIONS — Hover states, focus indicators, transitions, loading states
86
+ 7. ACCESSIBILITY — Alt text, labels, keyboard navigation, screen reader compatibility
87
+ 8. VISUAL POLISH — Border radius consistency, shadow depth, icon style, micro-details
88
+
89
+ ${rubric}
90
+
91
+ IMPORTANT RULES:
92
+ - Be SPECIFIC. "Spacing is inconsistent" is useless. "The gap between the header and hero section is 48px but between hero and features is 24px — inconsistent vertical rhythm" is useful.
93
+ - Reference element positions: "top-left navigation", "hero CTA button", "footer column 3".
94
+ - Call out GOOD design too — note what works well alongside what doesn't.
95
+ - If the page looks like it uses a default component library (shadcn, MUI, Ant) with no customization, say so.
96
+ - Compare to best-in-class: "The token selector dropdown lacks the polish of Uniswap's — no token icons, no search, no recent tokens."
97
+
98
+ RESPOND WITH ONLY a JSON object:
99
+ {
100
+ "score": 6,
101
+ "summary": "One-sentence overall assessment",
102
+ "strengths": ["Specific thing done well", "Another strength"],
103
+ "findings": [
104
+ {
105
+ "category": "spacing",
106
+ "severity": "major",
107
+ "description": "Hero section has 64px top padding but only 16px bottom padding before the feature grid, creating visual imbalance",
108
+ "location": "Hero section → feature grid transition",
109
+ "suggestion": "Use consistent 48px vertical sections throughout"
110
+ }
111
+ ]
112
+ }
113
+
114
+ Categories: visual-bug, layout, contrast, alignment, spacing, typography, accessibility, ux
115
+ Severities: critical (blocks usage), major (looks unprofessional), minor (polish issue)
116
+ Score: 1-10 per calibration above. Most sites are 5-7. Be honest.`;
117
+ }
118
+ // ---------------------------------------------------------------------------
119
+ // Page discovery — find key pages by crawling links
120
+ // ---------------------------------------------------------------------------
121
+ async function discoverPages(page, startUrl, maxPages) {
122
+ const origin = new URL(startUrl).origin;
123
+ const visited = new Set();
124
+ const toVisit = [startUrl];
125
+ const discovered = [];
126
+ while (toVisit.length > 0 && discovered.length < maxPages) {
127
+ const url = toVisit.shift();
128
+ const normalized = url.split('#')[0].split('?')[0].replace(/\/$/, '');
129
+ if (visited.has(normalized))
130
+ continue;
131
+ visited.add(normalized);
132
+ discovered.push(url);
133
+ if (discovered.length >= maxPages)
134
+ break;
135
+ try {
136
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 });
137
+ await page.waitForTimeout(1500);
138
+ const links = await page.evaluate((orig) => {
139
+ return Array.from(document.querySelectorAll('a[href]'))
140
+ .map(a => {
141
+ try {
142
+ return new URL(a.href, document.location.href).href;
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ })
148
+ .filter((href) => href !== null &&
149
+ href.startsWith(orig) &&
150
+ !href.includes('#') &&
151
+ !href.match(/\.(png|jpg|jpeg|gif|svg|pdf|zip|css|js)$/i));
152
+ }, origin);
153
+ for (const link of [...new Set(links)]) {
154
+ const norm = link.split('#')[0].split('?')[0].replace(/\/$/, '');
155
+ if (!visited.has(norm))
156
+ toVisit.push(link);
157
+ }
158
+ }
159
+ catch {
160
+ // Page failed to load — skip
161
+ }
162
+ }
163
+ return discovered;
164
+ }
165
+ // ---------------------------------------------------------------------------
166
+ // Report generation
167
+ // ---------------------------------------------------------------------------
168
+ function generateReport(results, profile) {
169
+ const lines = [];
170
+ const avgScore = results.length > 0
171
+ ? results.reduce((sum, r) => sum + r.score, 0) / results.length
172
+ : 0;
173
+ const allFindings = results.flatMap(r => r.findings);
174
+ const critical = allFindings.filter(f => f.severity === 'critical').length;
175
+ const major = allFindings.filter(f => f.severity === 'major').length;
176
+ const minor = allFindings.filter(f => f.severity === 'minor').length;
177
+ const totalTokens = results.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
178
+ lines.push('# Design Audit Report');
179
+ lines.push('');
180
+ lines.push(`**Profile:** ${profile}`);
181
+ lines.push(`**Pages audited:** ${results.length}`);
182
+ lines.push(`**Overall score:** ${avgScore.toFixed(1)}/10`);
183
+ lines.push(`**Findings:** ${allFindings.length} (${critical} critical, ${major} major, ${minor} minor)`);
184
+ if (totalTokens > 0)
185
+ lines.push(`**Tokens used:** ${totalTokens.toLocaleString()}`);
186
+ lines.push('');
187
+ // Score bar
188
+ const scoreBar = '█'.repeat(Math.round(avgScore)) + '░'.repeat(10 - Math.round(avgScore));
189
+ lines.push(`\`${scoreBar}\` ${avgScore.toFixed(1)}/10`);
190
+ lines.push('');
191
+ // Per-page results
192
+ for (const result of results) {
193
+ lines.push(`## ${result.url}`);
194
+ lines.push('');
195
+ const pageBar = '█'.repeat(Math.round(result.score)) + '░'.repeat(10 - Math.round(result.score));
196
+ lines.push(`**Score:** \`${pageBar}\` ${result.score}/10`);
197
+ if (result.summary)
198
+ lines.push(`**Summary:** ${result.summary}`);
199
+ if (result.error)
200
+ lines.push(`**Error:** ${result.error}`);
201
+ lines.push('');
202
+ if (result.strengths.length > 0) {
203
+ lines.push('**Strengths:**');
204
+ for (const s of result.strengths)
205
+ lines.push(`- ${s}`);
206
+ lines.push('');
207
+ }
208
+ if (result.findings.length > 0) {
209
+ lines.push('**Findings:**');
210
+ lines.push('');
211
+ lines.push('| Sev | Category | Description | Location | Fix |');
212
+ lines.push('|-----|----------|-------------|----------|-----|');
213
+ for (const f of result.findings) {
214
+ const esc = (s) => s.replace(/\|/g, '\\|').replace(/\n/g, ' ').slice(0, 120);
215
+ const icon = f.severity === 'critical' ? '🔴' : f.severity === 'major' ? '🟡' : '⚪';
216
+ lines.push(`| ${icon} ${f.severity} | ${f.category} | ${esc(f.description)} | ${esc(f.location)} | ${esc(f.suggestion)} |`);
217
+ }
218
+ lines.push('');
219
+ }
220
+ else if (!result.error) {
221
+ lines.push('No issues found.');
222
+ lines.push('');
223
+ }
224
+ }
225
+ return lines.join('\n');
226
+ }
227
+ export async function runDesignAudit(opts) {
228
+ loadLocalEnvFiles(process.cwd());
229
+ // Token extraction mode — no LLM, pure DOM extraction
230
+ if (opts.extractTokens) {
231
+ await runTokenExtraction(opts);
232
+ return;
233
+ }
234
+ const profile = opts.profile ?? 'general';
235
+ if (!PROFILE_RUBRICS[profile]) {
236
+ console.error(`Unknown profile: ${profile}. Options: ${Object.keys(PROFILE_RUBRICS).join(', ')}`);
237
+ process.exit(1);
238
+ }
239
+ const maxPages = opts.pages ?? 5;
240
+ const provider = (opts.provider ?? 'openai');
241
+ const modelName = resolveProviderModelName(provider, opts.model);
242
+ const apiKey = opts.apiKey ?? resolveProviderApiKey(provider);
243
+ const [vw, vh] = (opts.viewport ?? '1440x900').split('x').map(Number);
244
+ console.log('=== Design Audit ===');
245
+ console.log(`URL: ${opts.url}`);
246
+ console.log(`Profile: ${profile}`);
247
+ console.log(`Model: ${modelName}`);
248
+ console.log(`Pages: up to ${maxPages}`);
249
+ console.log(`Viewport: ${vw}x${vh}`);
250
+ console.log('');
251
+ const browser = await chromium.launch({ headless: opts.headless ?? true });
252
+ const context = await browser.newContext({ viewport: { width: vw, height: vh } });
253
+ const page = await context.newPage();
254
+ // Discover pages
255
+ console.log('Discovering pages...');
256
+ const pages = await discoverPages(page, opts.url, maxPages);
257
+ console.log(`Found ${pages.length} page(s):`);
258
+ for (const p of pages)
259
+ console.log(` ${p}`);
260
+ console.log('');
261
+ // Set up output directory
262
+ let screenshotDir;
263
+ const outputDir = opts.output ?? `./audit-results/${new URL(opts.url).hostname}-${Date.now()}`;
264
+ fs.mkdirSync(outputDir, { recursive: true });
265
+ screenshotDir = path.join(outputDir, 'screenshots');
266
+ fs.mkdirSync(screenshotDir, { recursive: true });
267
+ const brain = new Brain({
268
+ model: modelName,
269
+ apiKey,
270
+ provider: opts.provider,
271
+ vision: true,
272
+ debug: opts.debug,
273
+ });
274
+ const driver = new PlaywrightDriver(page);
275
+ // Audit each page
276
+ const results = [];
277
+ for (let i = 0; i < pages.length; i++) {
278
+ const url = pages[i];
279
+ console.log(`[${i + 1}/${pages.length}] Auditing ${url}...`);
280
+ const result = await auditSinglePage(brain, driver, page, url, profile, screenshotDir);
281
+ results.push(result);
282
+ const icon = result.score >= 8 ? '✓' : result.score >= 5 ? '~' : '✗';
283
+ console.log(` ${icon} Score: ${result.score}/10 — ${result.findings.length} findings`);
284
+ }
285
+ // Generate report
286
+ const report = generateReport(results, profile);
287
+ const reportPath = path.join(outputDir, 'report.md');
288
+ fs.writeFileSync(reportPath, report);
289
+ if (opts.json) {
290
+ const jsonPath = path.join(outputDir, 'report.json');
291
+ fs.writeFileSync(jsonPath, JSON.stringify({
292
+ timestamp: new Date().toISOString(),
293
+ profile,
294
+ url: opts.url,
295
+ pages: results,
296
+ summary: {
297
+ avgScore: results.reduce((s, r) => s + r.score, 0) / results.length,
298
+ totalFindings: results.flatMap(r => r.findings).length,
299
+ critical: results.flatMap(r => r.findings).filter(f => f.severity === 'critical').length,
300
+ major: results.flatMap(r => r.findings).filter(f => f.severity === 'major').length,
301
+ minor: results.flatMap(r => r.findings).filter(f => f.severity === 'minor').length,
302
+ },
303
+ }, null, 2));
304
+ console.log(`\nJSON: ${jsonPath}`);
305
+ }
306
+ console.log(`\nReport: ${reportPath}`);
307
+ console.log(`Screenshots: ${screenshotDir}`);
308
+ // Print summary
309
+ console.log('\n' + report.split('\n').slice(0, 15).join('\n'));
310
+ await browser.close();
311
+ }
312
+ // Direct page audit using brain.generate with custom prompt
313
+ async function auditSinglePage(brain, driver, page, url, profile, screenshotDir) {
314
+ try {
315
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 20_000 }).catch(() => page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 }));
316
+ await page.waitForTimeout(2000);
317
+ // Dismiss cookie banners
318
+ for (const sel of ['button:has-text("Accept all")', 'button:has-text("Accept")', 'button:has-text("Reject all")', 'button:has-text("Got it")', 'button:has-text("Close")']) {
319
+ const btn = page.locator(sel).first();
320
+ if (await btn.isVisible({ timeout: 500 }).catch(() => false)) {
321
+ await btn.click({ timeout: 2000 }).catch(() => null);
322
+ await page.waitForTimeout(500);
323
+ break;
324
+ }
325
+ }
326
+ const state = await driver.observe();
327
+ // Save screenshot
328
+ let screenshotPath;
329
+ if (screenshotDir) {
330
+ const slug = new URL(url).pathname.replace(/\//g, '_').replace(/^_/, '') || 'index';
331
+ screenshotPath = path.join(screenshotDir, `${slug}.png`);
332
+ await page.screenshot({ path: screenshotPath, fullPage: false });
333
+ }
334
+ const result = await brain.auditDesign(state, `Audit the design quality of this page: ${url}`, [], buildAuditPrompt(profile));
335
+ let summary = '';
336
+ let strengths = [];
337
+ try {
338
+ let text = result.raw.trim();
339
+ if (text.startsWith('```'))
340
+ text = text.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
341
+ const parsed = JSON.parse(text);
342
+ summary = parsed.summary || '';
343
+ strengths = Array.isArray(parsed.strengths) ? parsed.strengths : [];
344
+ }
345
+ catch { /* use defaults */ }
346
+ return {
347
+ url,
348
+ score: result.score,
349
+ summary,
350
+ strengths,
351
+ findings: result.findings,
352
+ screenshotPath,
353
+ tokensUsed: result.tokensUsed,
354
+ };
355
+ }
356
+ catch (err) {
357
+ return {
358
+ url,
359
+ score: 0,
360
+ summary: 'Failed to audit',
361
+ strengths: [],
362
+ findings: [],
363
+ error: err instanceof Error ? err.message : String(err),
364
+ };
365
+ }
366
+ }
367
+ // ---------------------------------------------------------------------------
368
+ // Design Token Extraction — pure DOM, no LLM calls
369
+ // ---------------------------------------------------------------------------
370
+ const VIEWPORTS = [
371
+ { name: 'mobile', width: 375, height: 812 },
372
+ { name: 'tablet', width: 768, height: 1024 },
373
+ { name: 'desktop', width: 1440, height: 900 },
374
+ ];
375
+ // The in-page extraction function — runs inside page.evaluate()
376
+ function extractTokensFromDOM() {
377
+ const MAX_ELEMENTS = 5000;
378
+ // --- CSS Custom Properties & Font Files ---
379
+ const customProperties = {};
380
+ const fontFiles = [];
381
+ for (const sheet of document.styleSheets) {
382
+ try {
383
+ for (const rule of sheet.cssRules) {
384
+ if (rule instanceof CSSStyleRule &&
385
+ (rule.selectorText === ':root' || rule.selectorText?.includes('html') || rule.selectorText?.includes('body'))) {
386
+ for (let i = 0; i < rule.style.length; i++) {
387
+ const prop = rule.style[i];
388
+ if (prop.startsWith('--')) {
389
+ customProperties[prop] = rule.style.getPropertyValue(prop).trim();
390
+ }
391
+ }
392
+ }
393
+ if (rule instanceof CSSFontFaceRule) {
394
+ const family = rule.style.getPropertyValue('font-family').replace(/['"]/g, '').trim();
395
+ const weight = rule.style.getPropertyValue('font-weight') || '400';
396
+ const style = rule.style.getPropertyValue('font-style') || 'normal';
397
+ const src = rule.style.getPropertyValue('src');
398
+ const urlMatch = src.match(/url\(["']?([^"')]+)["']?\)/);
399
+ const formatMatch = src.match(/format\(["']?([^"')]+)["']?\)/);
400
+ if (urlMatch) {
401
+ fontFiles.push({
402
+ family,
403
+ weight,
404
+ style,
405
+ src: urlMatch[1],
406
+ format: formatMatch?.[1] || 'unknown',
407
+ });
408
+ }
409
+ }
410
+ }
411
+ }
412
+ catch { /* cross-origin stylesheet */ }
413
+ }
414
+ // Supplement with document.fonts API (catches cross-origin @font-face)
415
+ const seenFontSrcs = new Set(fontFiles.map(f => f.src));
416
+ try {
417
+ for (const face of document.fonts) {
418
+ if (face.status === 'loaded') {
419
+ // face.family is the CSS font-family, face.weight/style are strings
420
+ // Check if we already captured this via @font-face rules
421
+ const family = face.family.replace(/['"]/g, '').trim();
422
+ const weight = face.weight || '400';
423
+ const style = face.style || 'normal';
424
+ // document.fonts doesn't expose the URL, but we can match against loaded resources
425
+ if (!fontFiles.some(f => f.family === family && f.weight === weight && f.style === style)) {
426
+ // Will be supplemented by Performance API below
427
+ }
428
+ }
429
+ }
430
+ }
431
+ catch { /* older browser */ }
432
+ // Use Performance API to find all loaded font resources (works cross-origin)
433
+ try {
434
+ const resources = performance.getEntriesByType('resource');
435
+ for (const r of resources) {
436
+ if (/\.(woff2?|ttf|otf|eot)(\?|$)/i.test(r.name) && !seenFontSrcs.has(r.name)) {
437
+ seenFontSrcs.add(r.name);
438
+ const formatMatch = r.name.match(/\.(woff2?|ttf|otf|eot)/i);
439
+ fontFiles.push({
440
+ family: '', // can't determine from URL alone
441
+ weight: '',
442
+ style: '',
443
+ src: r.name,
444
+ format: formatMatch?.[1] || 'unknown',
445
+ });
446
+ }
447
+ }
448
+ }
449
+ catch { /* Performance API not available */ }
450
+ // --- Element traversal ---
451
+ const colorMap = new Map();
452
+ const spacingMap = new Map();
453
+ const borderRadiusMap = new Map();
454
+ const shadowMap = new Map();
455
+ const fontFamilyMap = new Map();
456
+ const typeScaleMap = new Map();
457
+ const animationMap = new Map();
458
+ function addColor(value, property) {
459
+ if (!value || value === 'transparent' || value === 'rgba(0, 0, 0, 0)' || value === 'inherit' || value === 'initial')
460
+ return;
461
+ const entry = colorMap.get(value);
462
+ if (entry) {
463
+ entry.count++;
464
+ entry.properties.add(property);
465
+ }
466
+ else {
467
+ colorMap.set(value, { count: 1, properties: new Set([property]) });
468
+ }
469
+ }
470
+ function addSpacing(value, property) {
471
+ if (!value || value === '0px' || value === 'auto' || value === 'normal')
472
+ return;
473
+ // Normalize multi-value shorthands — take individual values
474
+ const vals = value.split(/\s+/).filter(v => v !== '0px' && v !== 'auto');
475
+ for (const v of vals) {
476
+ if (!v.endsWith('px') && !v.endsWith('rem') && !v.endsWith('em'))
477
+ continue;
478
+ const entry = spacingMap.get(v);
479
+ if (entry) {
480
+ entry.count++;
481
+ entry.properties.add(property);
482
+ }
483
+ else {
484
+ spacingMap.set(v, { count: 1, properties: new Set([property]) });
485
+ }
486
+ }
487
+ }
488
+ function parseGradientColors(bg) {
489
+ const colorRegex = /(?:rgba?\([^)]+\)|hsla?\([^)]+\)|#[0-9a-fA-F]{3,8})/g;
490
+ const matches = bg.match(colorRegex) || [];
491
+ for (const c of matches)
492
+ addColor(c, 'gradient');
493
+ }
494
+ function parseShadowColors(shadow) {
495
+ if (shadow === 'none')
496
+ return;
497
+ const colorRegex = /(?:rgba?\([^)]+\)|hsla?\([^)]+\)|#[0-9a-fA-F]{3,8})/g;
498
+ const matches = shadow.match(colorRegex) || [];
499
+ for (const c of matches)
500
+ addColor(c, 'boxShadow');
501
+ }
502
+ const HEADING_TAGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
503
+ const MONO_FAMILIES = /mono|consolas|courier|fira\s*code|jetbrains|source\s*code/i;
504
+ let elementCount = 0;
505
+ // Per-element extraction — shared between light DOM walker and shadow DOM walker
506
+ function processElement(el) {
507
+ const htmlEl = el;
508
+ if (htmlEl.offsetWidth === 0 && htmlEl.offsetHeight === 0)
509
+ return;
510
+ const cs = getComputedStyle(el);
511
+ // Colors
512
+ addColor(cs.color, 'color');
513
+ addColor(cs.backgroundColor, 'backgroundColor');
514
+ addColor(cs.borderTopColor, 'borderColor');
515
+ addColor(cs.borderRightColor, 'borderColor');
516
+ addColor(cs.borderBottomColor, 'borderColor');
517
+ addColor(cs.borderLeftColor, 'borderColor');
518
+ addColor(cs.outlineColor, 'outlineColor');
519
+ if (cs.backgroundImage.includes('gradient'))
520
+ parseGradientColors(cs.backgroundImage);
521
+ if (cs.boxShadow !== 'none')
522
+ parseShadowColors(cs.boxShadow);
523
+ // Spacing
524
+ addSpacing(cs.paddingTop, 'padding');
525
+ addSpacing(cs.paddingRight, 'padding');
526
+ addSpacing(cs.paddingBottom, 'padding');
527
+ addSpacing(cs.paddingLeft, 'padding');
528
+ addSpacing(cs.marginTop, 'margin');
529
+ addSpacing(cs.marginRight, 'margin');
530
+ addSpacing(cs.marginBottom, 'margin');
531
+ addSpacing(cs.marginLeft, 'margin');
532
+ addSpacing(cs.gap, 'gap');
533
+ addSpacing(cs.rowGap, 'gap');
534
+ addSpacing(cs.columnGap, 'gap');
535
+ // Border radius
536
+ const br = cs.borderRadius;
537
+ if (br && br !== '0px') {
538
+ borderRadiusMap.set(br, (borderRadiusMap.get(br) ?? 0) + 1);
539
+ }
540
+ // Shadows
541
+ if (cs.boxShadow !== 'none') {
542
+ shadowMap.set(cs.boxShadow, (shadowMap.get(cs.boxShadow) ?? 0) + 1);
543
+ }
544
+ // Typography — only for elements with text content
545
+ if (el.childNodes.length > 0 && Array.from(el.childNodes).some(n => n.nodeType === Node.TEXT_NODE && n.textContent?.trim())) {
546
+ const family = cs.fontFamily;
547
+ const weight = parseInt(cs.fontWeight) || 400;
548
+ const isHeading = HEADING_TAGS.has(el.tagName);
549
+ const isMono = MONO_FAMILIES.test(family);
550
+ const fEntry = fontFamilyMap.get(family);
551
+ if (fEntry) {
552
+ fEntry.weights.add(weight);
553
+ if (isHeading)
554
+ fEntry.headingUse = true;
555
+ if (isMono)
556
+ fEntry.monoUse = true;
557
+ }
558
+ else {
559
+ fontFamilyMap.set(family, { weights: new Set([weight]), headingUse: isHeading, monoUse: isMono });
560
+ }
561
+ const scaleKey = `${cs.fontSize}|${cs.fontWeight}|${cs.lineHeight}|${cs.letterSpacing}|${family}`;
562
+ const scaleEntry = typeScaleMap.get(scaleKey);
563
+ if (scaleEntry) {
564
+ scaleEntry.count++;
565
+ }
566
+ else {
567
+ typeScaleMap.set(scaleKey, {
568
+ fontSize: cs.fontSize,
569
+ fontWeight: cs.fontWeight,
570
+ lineHeight: cs.lineHeight,
571
+ letterSpacing: cs.letterSpacing,
572
+ fontFamily: family,
573
+ tag: el.tagName.toLowerCase(),
574
+ count: 1,
575
+ });
576
+ }
577
+ }
578
+ // Animations/transitions
579
+ if (cs.transition && cs.transition !== 'all 0s ease 0s' && cs.transition !== 'none') {
580
+ const key = cs.transition;
581
+ const aEntry = animationMap.get(key);
582
+ if (aEntry) {
583
+ aEntry.count++;
584
+ }
585
+ else {
586
+ animationMap.set(key, { property: 'transition', value: cs.transition, count: 1 });
587
+ }
588
+ }
589
+ if (cs.animationName && cs.animationName !== 'none') {
590
+ const key = `animation:${cs.animationName}`;
591
+ const aEntry = animationMap.get(key);
592
+ if (aEntry) {
593
+ aEntry.count++;
594
+ }
595
+ else {
596
+ animationMap.set(key, { property: 'animation', value: `${cs.animationName} ${cs.animationDuration} ${cs.animationTimingFunction}`, count: 1 });
597
+ }
598
+ }
599
+ }
600
+ // --- Light DOM walk ---
601
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
602
+ let el = walker.currentNode;
603
+ while (el && elementCount < MAX_ELEMENTS) {
604
+ elementCount++;
605
+ processElement(el);
606
+ el = walker.nextNode();
607
+ }
608
+ // --- Shadow DOM walk ---
609
+ function walkShadowRoots(root) {
610
+ const shadowWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
611
+ // currentNode starts at root (ShadowRoot, not Element) — advance to first element
612
+ let shadowEl = shadowWalker.nextNode();
613
+ while (shadowEl && elementCount < MAX_ELEMENTS) {
614
+ elementCount++;
615
+ processElement(shadowEl);
616
+ if (shadowEl.shadowRoot) {
617
+ walkShadowRoots(shadowEl.shadowRoot);
618
+ }
619
+ shadowEl = shadowWalker.nextNode();
620
+ }
621
+ }
622
+ const allElements = document.body.querySelectorAll('*');
623
+ for (const bodyEl of allElements) {
624
+ if (bodyEl.shadowRoot && elementCount < MAX_ELEMENTS) {
625
+ walkShadowRoots(bodyEl.shadowRoot);
626
+ }
627
+ }
628
+ // --- Components: Buttons ---
629
+ const buttonStyles = [];
630
+ const btnFingerprints = new Map();
631
+ const BUTTON_STYLE_KEYS = ['backgroundColor', 'color', 'borderRadius', 'padding', 'fontSize', 'fontWeight', 'border', 'boxShadow', 'textTransform', 'letterSpacing'];
632
+ for (const btn of document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]')) {
633
+ const htmlBtn = btn;
634
+ if (htmlBtn.offsetWidth === 0 && htmlBtn.offsetHeight === 0)
635
+ continue;
636
+ const cs = getComputedStyle(btn);
637
+ const styles = {};
638
+ for (const k of BUTTON_STYLE_KEYS)
639
+ styles[k] = cs[k];
640
+ const fp = JSON.stringify(styles);
641
+ const existing = btnFingerprints.get(fp);
642
+ if (existing) {
643
+ existing.count++;
644
+ }
645
+ else {
646
+ const entry = { fingerprint: fp, count: 1, exampleText: htmlBtn.textContent?.trim().slice(0, 60) || '', styles };
647
+ btnFingerprints.set(fp, entry);
648
+ }
649
+ }
650
+ buttonStyles.push(...btnFingerprints.values());
651
+ // --- Components: Inputs ---
652
+ const inputStyles = [];
653
+ const inputFingerprints = new Map();
654
+ const INPUT_STYLE_KEYS = ['backgroundColor', 'color', 'borderRadius', 'padding', 'fontSize', 'border', 'boxShadow', 'outline'];
655
+ for (const inp of document.querySelectorAll('input[type="text"], input[type="email"], input[type="password"], input[type="search"], input[type="number"], input[type="tel"], input[type="url"], textarea, select, [role="textbox"], [role="combobox"]')) {
656
+ const htmlInp = inp;
657
+ if (htmlInp.offsetWidth === 0 && htmlInp.offsetHeight === 0)
658
+ continue;
659
+ const cs = getComputedStyle(inp);
660
+ const styles = {};
661
+ for (const k of INPUT_STYLE_KEYS)
662
+ styles[k] = cs[k];
663
+ const fp = JSON.stringify(styles);
664
+ const existing = inputFingerprints.get(fp);
665
+ if (existing) {
666
+ existing.count++;
667
+ }
668
+ else {
669
+ const entry = { fingerprint: fp, count: 1, exampleText: inp.placeholder?.slice(0, 60) || '', styles };
670
+ inputFingerprints.set(fp, entry);
671
+ }
672
+ }
673
+ inputStyles.push(...inputFingerprints.values());
674
+ // --- Components: Cards (elements with shadow or border-radius + multiple children) ---
675
+ const cardStyles = [];
676
+ const cardFingerprints = new Map();
677
+ const CARD_STYLE_KEYS = ['borderRadius', 'boxShadow', 'padding', 'backgroundColor', 'border'];
678
+ for (const el of document.querySelectorAll('div, section, article, li')) {
679
+ const htmlEl = el;
680
+ if (htmlEl.offsetWidth === 0 || htmlEl.children.length < 2)
681
+ continue;
682
+ const cs = getComputedStyle(el);
683
+ if (cs.boxShadow === 'none' && cs.borderRadius === '0px' && cs.border === '0px none rgb(0, 0, 0)')
684
+ continue;
685
+ const styles = {};
686
+ for (const k of CARD_STYLE_KEYS)
687
+ styles[k] = cs[k];
688
+ const fp = JSON.stringify(styles);
689
+ const existing = cardFingerprints.get(fp);
690
+ if (existing) {
691
+ existing.count++;
692
+ }
693
+ else {
694
+ const entry = { fingerprint: fp, count: 1, styles };
695
+ cardFingerprints.set(fp, entry);
696
+ }
697
+ }
698
+ // Only keep card patterns with 2+ instances (skip one-off containers)
699
+ for (const c of cardFingerprints.values()) {
700
+ if (c.count >= 2)
701
+ cardStyles.push(c);
702
+ }
703
+ // --- Components: Navigation ---
704
+ const navPatterns = [];
705
+ for (const nav of document.querySelectorAll('nav, [role="navigation"], header')) {
706
+ const cs = getComputedStyle(nav);
707
+ const links = nav.querySelectorAll('a');
708
+ if (links.length === 0)
709
+ continue;
710
+ const firstLinkCs = getComputedStyle(links[0]);
711
+ navPatterns.push({
712
+ selector: nav.tagName.toLowerCase() + (nav.className ? '.' + nav.className.split(/\s+/)[0] : ''),
713
+ layout: {
714
+ display: cs.display,
715
+ flexDirection: cs.flexDirection,
716
+ gap: cs.gap,
717
+ alignItems: cs.alignItems,
718
+ justifyContent: cs.justifyContent,
719
+ backgroundColor: cs.backgroundColor,
720
+ padding: cs.padding,
721
+ },
722
+ linkCount: links.length,
723
+ linkStyles: {
724
+ color: firstLinkCs.color,
725
+ fontSize: firstLinkCs.fontSize,
726
+ fontWeight: firstLinkCs.fontWeight,
727
+ textDecoration: firstLinkCs.textDecoration,
728
+ },
729
+ });
730
+ }
731
+ // --- Logos & Icons ---
732
+ const logos = [];
733
+ const icons = [];
734
+ // SVG logos (in header/nav or with logo-related attributes)
735
+ for (const svg of document.querySelectorAll('svg')) {
736
+ const parent = svg.closest('header, nav, [role="banner"]');
737
+ const classes = (svg.getAttribute('class') || '') + ' ' + (svg.parentElement?.getAttribute('class') || '');
738
+ const ariaLabel = svg.getAttribute('aria-label') || '';
739
+ const isLogo = parent !== null || /logo|brand|mark/i.test(classes) || /logo|brand/i.test(ariaLabel);
740
+ if (isLogo) {
741
+ const content = svg.outerHTML.slice(0, 4096);
742
+ logos.push({
743
+ type: 'svg',
744
+ width: svg.width?.baseVal?.value || undefined,
745
+ height: svg.height?.baseVal?.value || undefined,
746
+ svgContent: content,
747
+ });
748
+ }
749
+ else if (svg.outerHTML.length < 2048) {
750
+ // Collect as icon (small SVGs)
751
+ icons.push({
752
+ selector: svg.tagName + (svg.getAttribute('class') ? '.' + svg.getAttribute('class').split(/\s+/)[0] : ''),
753
+ viewBox: svg.getAttribute('viewBox') || undefined,
754
+ width: svg.width?.baseVal?.value || undefined,
755
+ height: svg.height?.baseVal?.value || undefined,
756
+ content: svg.outerHTML,
757
+ });
758
+ }
759
+ }
760
+ // IMG logos
761
+ for (const img of document.querySelectorAll('img')) {
762
+ const parent = img.closest('header, nav, [role="banner"]');
763
+ const src = img.src || '';
764
+ const alt = img.alt || '';
765
+ const classes = img.className || '';
766
+ if (parent !== null || /logo|brand|mark/i.test(src) || /logo|brand/i.test(alt) || /logo|brand/i.test(classes)) {
767
+ logos.push({
768
+ type: 'img',
769
+ src: img.src,
770
+ alt: img.alt || undefined,
771
+ width: img.naturalWidth || undefined,
772
+ height: img.naturalHeight || undefined,
773
+ });
774
+ }
775
+ }
776
+ // Limit icons to top 50 unique by viewBox
777
+ const seenViewBoxes = new Set();
778
+ const dedupedIcons = icons.filter(icon => {
779
+ const key = icon.viewBox || icon.content.slice(0, 100);
780
+ if (seenViewBoxes.has(key))
781
+ return false;
782
+ seenViewBoxes.add(key);
783
+ return true;
784
+ }).slice(0, 50);
785
+ // --- Brand Meta ---
786
+ const getMeta = (sel) => document.querySelector(sel)?.getAttribute('content') || undefined;
787
+ const getLink = (sel) => {
788
+ const el = document.querySelector(sel);
789
+ return el ? el.href || el.getAttribute('href') || undefined : undefined;
790
+ };
791
+ const brand = {
792
+ title: getMeta('meta[property="og:title"]') || document.title || undefined,
793
+ description: getMeta('meta[property="og:description"]') || getMeta('meta[name="description"]'),
794
+ themeColor: getMeta('meta[name="theme-color"]'),
795
+ favicon: getLink('link[rel="icon"]') || getLink('link[rel="shortcut icon"]'),
796
+ ogImage: getMeta('meta[property="og:image"]'),
797
+ appleTouchIcon: getLink('link[rel="apple-touch-icon"]'),
798
+ manifestUrl: getLink('link[rel="manifest"]'),
799
+ };
800
+ // --- Assemble result ---
801
+ return {
802
+ customProperties,
803
+ colors: Array.from(colorMap.entries())
804
+ .map(([value, data]) => ({ value, count: data.count, properties: Array.from(data.properties) }))
805
+ .sort((a, b) => b.count - a.count),
806
+ typography: {
807
+ families: Array.from(fontFamilyMap.entries()).map(([family, data]) => ({
808
+ family,
809
+ weights: Array.from(data.weights).sort((a, b) => a - b),
810
+ headingUse: data.headingUse,
811
+ monoUse: data.monoUse,
812
+ })),
813
+ scale: Array.from(typeScaleMap.values()).sort((a, b) => b.count - a.count),
814
+ },
815
+ brand,
816
+ logos,
817
+ icons: dedupedIcons,
818
+ spacing: Array.from(spacingMap.entries())
819
+ .map(([value, data]) => ({ value, count: data.count, properties: Array.from(data.properties) }))
820
+ .sort((a, b) => b.count - a.count),
821
+ borders: Array.from(borderRadiusMap.entries())
822
+ .map(([borderRadius, count]) => ({ borderRadius, count }))
823
+ .sort((a, b) => b.count - a.count),
824
+ shadows: Array.from(shadowMap.entries())
825
+ .map(([value, count]) => ({ value, count }))
826
+ .sort((a, b) => b.count - a.count),
827
+ components: {
828
+ buttons: buttonStyles.sort((a, b) => b.count - a.count),
829
+ inputs: inputStyles.sort((a, b) => b.count - a.count),
830
+ cards: cardStyles.sort((a, b) => b.count - a.count),
831
+ nav: navPatterns,
832
+ },
833
+ animations: Array.from(animationMap.values()).sort((a, b) => b.count - a.count),
834
+ fontFiles,
835
+ };
836
+ }
837
+ // ---------------------------------------------------------------------------
838
+ // Color clustering — assign semantic roles to extracted colors
839
+ // ---------------------------------------------------------------------------
840
+ function rgbaToHsl(rgba) {
841
+ const match = rgba.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/);
842
+ if (!match)
843
+ return null;
844
+ const r = parseFloat(match[1]) / 255;
845
+ const g = parseFloat(match[2]) / 255;
846
+ const b = parseFloat(match[3]) / 255;
847
+ const a = match[4] !== undefined ? parseFloat(match[4]) : 1;
848
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
849
+ const l = (max + min) / 2;
850
+ if (max === min)
851
+ return { h: 0, s: 0, l, a };
852
+ const d = max - min;
853
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
854
+ let h = 0;
855
+ if (max === r)
856
+ h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
857
+ else if (max === g)
858
+ h = ((b - r) / d + 2) / 6;
859
+ else
860
+ h = ((r - g) / d + 4) / 6;
861
+ return { h: h * 360, s: s * 100, l: l * 100, a };
862
+ }
863
+ function rgbaToHex(rgba) {
864
+ const match = rgba.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/);
865
+ if (!match)
866
+ return rgba;
867
+ const r = Math.round(parseFloat(match[1]));
868
+ const g = Math.round(parseFloat(match[2]));
869
+ const b = Math.round(parseFloat(match[3]));
870
+ const a = match[4] !== undefined ? parseFloat(match[4]) : 1;
871
+ const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
872
+ if (a < 1)
873
+ return `${hex}${Math.round(a * 255).toString(16).padStart(2, '0')}`;
874
+ return hex;
875
+ }
876
+ function clusterColors(colors) {
877
+ const result = [];
878
+ let primaryHue = -1;
879
+ let secondaryHue = -1;
880
+ for (const c of colors) {
881
+ const hsl = rgbaToHsl(c.value);
882
+ const hex = rgbaToHex(c.value);
883
+ let cluster;
884
+ if (!hsl || hsl.s < 10) {
885
+ // Low saturation = neutral (grays, black, white)
886
+ if (hsl && hsl.l > 90)
887
+ cluster = 'background';
888
+ else
889
+ cluster = 'neutral';
890
+ }
891
+ else if (c.properties.includes('borderColor') && !c.properties.includes('color') && !c.properties.includes('backgroundColor')) {
892
+ cluster = 'border';
893
+ }
894
+ else if (primaryHue < 0) {
895
+ primaryHue = hsl.h;
896
+ cluster = 'primary';
897
+ }
898
+ else if (Math.abs(hsl.h - primaryHue) < 30 || Math.abs(hsl.h - primaryHue) > 330) {
899
+ cluster = 'primary';
900
+ }
901
+ else if (secondaryHue < 0) {
902
+ secondaryHue = hsl.h;
903
+ cluster = 'secondary';
904
+ }
905
+ else if (Math.abs(hsl.h - secondaryHue) < 30 || Math.abs(hsl.h - secondaryHue) > 330) {
906
+ cluster = 'secondary';
907
+ }
908
+ else {
909
+ cluster = 'accent';
910
+ }
911
+ result.push({ value: c.value, hex, count: c.count, properties: c.properties, cluster });
912
+ }
913
+ return result;
914
+ }
915
+ // ---------------------------------------------------------------------------
916
+ // Grid base unit detection
917
+ // ---------------------------------------------------------------------------
918
+ function detectGridUnit(spacingValues) {
919
+ // Build frequency-weighted pairs
920
+ const pairs = spacingValues
921
+ .map(s => ({ px: parseFloat(s.value), count: s.count }))
922
+ .filter(p => p.px > 0 && p.px <= 200 && Number.isInteger(p.px));
923
+ if (pairs.length < 3)
924
+ return undefined;
925
+ // Score common grid units by how much of the total usage they explain
926
+ const CANDIDATES = [4, 5, 6, 8, 10];
927
+ let bestUnit = 0;
928
+ let bestScore = 0;
929
+ for (const unit of CANDIDATES) {
930
+ let aligned = 0;
931
+ let total = 0;
932
+ for (const p of pairs) {
933
+ total += p.count;
934
+ if (p.px % unit === 0)
935
+ aligned += p.count;
936
+ }
937
+ const score = aligned / total;
938
+ if (score > bestScore) {
939
+ bestScore = score;
940
+ bestUnit = unit;
941
+ }
942
+ }
943
+ // Require at least 60% of usage aligns with the grid
944
+ return bestScore >= 0.6 ? bestUnit : undefined;
945
+ }
946
+ // ---------------------------------------------------------------------------
947
+ // Token extraction orchestrator
948
+ // ---------------------------------------------------------------------------
949
+ async function dismissCookieBanners(page) {
950
+ for (const sel of ['button:has-text("Accept all")', 'button:has-text("Accept")', 'button:has-text("Reject all")', 'button:has-text("Got it")', 'button:has-text("Close")']) {
951
+ const btn = page.locator(sel).first();
952
+ if (await btn.isVisible({ timeout: 500 }).catch(() => false)) {
953
+ await btn.click({ timeout: 2000 }).catch(() => null);
954
+ await page.waitForTimeout(500);
955
+ break;
956
+ }
957
+ }
958
+ }
959
+ /**
960
+ * Extract design tokens from a URL at multiple viewports.
961
+ * Pure DOM extraction — no LLM calls.
962
+ *
963
+ * ```typescript
964
+ * import { extractDesignTokens } from '@tangle-network/browser-agent-driver'
965
+ *
966
+ * const { tokens } = await extractDesignTokens({ url: 'https://stripe.com' })
967
+ * console.log(tokens.colors.filter(c => c.cluster === 'primary'))
968
+ * console.log(tokens.typography.families)
969
+ * console.log(tokens.brand)
970
+ * ```
971
+ */
972
+ export async function extractDesignTokens(opts) {
973
+ const viewports = opts.viewports ?? [...VIEWPORTS];
974
+ const outputDir = opts.outputDir ?? `./audit-results/${new URL(opts.url).hostname}-tokens-${Date.now()}`;
975
+ fs.mkdirSync(outputDir, { recursive: true });
976
+ const screenshotDir = path.join(outputDir, 'screenshots');
977
+ fs.mkdirSync(screenshotDir, { recursive: true });
978
+ const browser = await chromium.launch({ headless: opts.headless ?? true });
979
+ const allCustomProps = {};
980
+ const allColors = new Map();
981
+ const allFamilies = new Map();
982
+ const allScaleEntries = [];
983
+ let brand = {};
984
+ const allLogos = [];
985
+ const allIcons = [];
986
+ const allFontFiles = new Map();
987
+ const responsiveTokens = {};
988
+ const screenshotPaths = {};
989
+ for (const vp of viewports) {
990
+ const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height } });
991
+ const page = await context.newPage();
992
+ try {
993
+ await page.goto(opts.url, { waitUntil: 'networkidle', timeout: 20_000 }).catch(() => page.goto(opts.url, { waitUntil: 'domcontentloaded', timeout: 15_000 }));
994
+ await page.waitForTimeout(2000);
995
+ await dismissCookieBanners(page);
996
+ await page.waitForTimeout(500);
997
+ const screenshotPath = path.join(screenshotDir, `${vp.name}.png`);
998
+ await page.screenshot({ path: screenshotPath, fullPage: true }).catch(() => page.screenshot({ path: screenshotPath, fullPage: false }));
999
+ screenshotPaths[vp.name] = screenshotPath;
1000
+ const raw = await page.evaluate(extractTokensFromDOM);
1001
+ Object.assign(allCustomProps, raw.customProperties);
1002
+ for (const c of raw.colors) {
1003
+ const existing = allColors.get(c.value);
1004
+ if (existing) {
1005
+ existing.count += c.count;
1006
+ for (const p of c.properties)
1007
+ existing.properties.add(p);
1008
+ }
1009
+ else {
1010
+ allColors.set(c.value, { count: c.count, properties: new Set(c.properties) });
1011
+ }
1012
+ }
1013
+ for (const f of raw.typography.families) {
1014
+ const existing = allFamilies.get(f.family);
1015
+ if (existing) {
1016
+ for (const w of f.weights)
1017
+ existing.weights.add(w);
1018
+ if (f.headingUse)
1019
+ existing.headingUse = true;
1020
+ if (f.monoUse)
1021
+ existing.monoUse = true;
1022
+ }
1023
+ else {
1024
+ allFamilies.set(f.family, { weights: new Set(f.weights), headingUse: f.headingUse, monoUse: f.monoUse });
1025
+ }
1026
+ }
1027
+ allScaleEntries.push(...raw.typography.scale);
1028
+ if (vp.name === 'desktop' || !brand.title) {
1029
+ brand = raw.brand;
1030
+ }
1031
+ for (const logo of raw.logos) {
1032
+ if (!allLogos.some(l => l.src === logo.src && l.svgContent === logo.svgContent)) {
1033
+ allLogos.push(logo);
1034
+ }
1035
+ }
1036
+ for (const icon of raw.icons) {
1037
+ if (!allIcons.some(i => i.content === icon.content)) {
1038
+ allIcons.push(icon);
1039
+ }
1040
+ }
1041
+ for (const ff of raw.fontFiles) {
1042
+ if (!allFontFiles.has(ff.src)) {
1043
+ allFontFiles.set(ff.src, ff);
1044
+ }
1045
+ }
1046
+ responsiveTokens[vp.name] = {
1047
+ width: vp.width,
1048
+ height: vp.height,
1049
+ spacing: raw.spacing,
1050
+ gridBaseUnit: detectGridUnit(raw.spacing),
1051
+ borders: raw.borders,
1052
+ shadows: raw.shadows,
1053
+ components: raw.components,
1054
+ animations: raw.animations,
1055
+ screenshotPath,
1056
+ };
1057
+ opts.onProgress?.(vp.name, vp.width, vp.height, {
1058
+ colors: raw.colors.length,
1059
+ fonts: raw.typography.families.length,
1060
+ buttons: raw.components.buttons.length,
1061
+ inputs: raw.components.inputs.length,
1062
+ cards: raw.components.cards.length,
1063
+ });
1064
+ }
1065
+ finally {
1066
+ await context.close();
1067
+ }
1068
+ }
1069
+ await browser.close();
1070
+ // Deduplicate type scale
1071
+ const scaleMap = new Map();
1072
+ for (const entry of allScaleEntries) {
1073
+ const key = `${entry.fontSize}|${entry.fontWeight}|${entry.lineHeight}|${entry.fontFamily}`;
1074
+ const existing = scaleMap.get(key);
1075
+ if (existing) {
1076
+ existing.count += entry.count;
1077
+ }
1078
+ else {
1079
+ const isHeading = /^h[1-6]$/.test(entry.tag);
1080
+ const fontSize = parseFloat(entry.fontSize);
1081
+ let usage = 'body';
1082
+ if (isHeading)
1083
+ usage = 'heading';
1084
+ else if (fontSize <= 12)
1085
+ usage = 'caption';
1086
+ else if (fontSize <= 14 && entry.fontWeight >= '500')
1087
+ usage = 'label';
1088
+ scaleMap.set(key, { fontSize: entry.fontSize, fontWeight: entry.fontWeight, lineHeight: entry.lineHeight, letterSpacing: entry.letterSpacing, fontFamily: entry.fontFamily, usage, tag: entry.tag, count: entry.count });
1089
+ }
1090
+ }
1091
+ const mergedColors = Array.from(allColors.entries())
1092
+ .map(([value, data]) => ({ value, count: data.count, properties: Array.from(data.properties) }))
1093
+ .sort((a, b) => b.count - a.count);
1094
+ const clusteredColors = clusterColors(mergedColors);
1095
+ const families = Array.from(allFamilies.entries()).map(([family, data]) => ({
1096
+ family,
1097
+ weights: Array.from(data.weights).sort((a, b) => a - b),
1098
+ classification: data.monoUse ? 'mono' :
1099
+ (data.headingUse && allFamilies.size > 1) ? 'heading' :
1100
+ data.headingUse ? 'display' : 'body',
1101
+ }));
1102
+ // Download font files
1103
+ const downloadedFontFiles = [];
1104
+ if (allFontFiles.size > 0) {
1105
+ const fontDir = path.join(outputDir, 'fonts');
1106
+ fs.mkdirSync(fontDir, { recursive: true });
1107
+ for (const ff of allFontFiles.values()) {
1108
+ try {
1109
+ const res = await fetch(ff.src);
1110
+ if (!res.ok)
1111
+ continue;
1112
+ const buffer = Buffer.from(await res.arrayBuffer());
1113
+ const urlPath = new URL(ff.src).pathname;
1114
+ const filename = path.basename(urlPath) || `${ff.family.replace(/\s+/g, '-')}-${ff.weight}-${ff.style}.${ff.format === 'unknown' ? 'bin' : ff.format}`;
1115
+ const localPath = path.join(fontDir, filename);
1116
+ fs.writeFileSync(localPath, buffer);
1117
+ downloadedFontFiles.push({ ...ff, localPath });
1118
+ }
1119
+ catch {
1120
+ downloadedFontFiles.push({ ...ff });
1121
+ }
1122
+ }
1123
+ }
1124
+ const tokens = {
1125
+ url: opts.url,
1126
+ extractedAt: new Date().toISOString(),
1127
+ viewportsAudited: viewports.map(v => v.name),
1128
+ customProperties: allCustomProps,
1129
+ colors: clusteredColors,
1130
+ typography: { families, scale: Array.from(scaleMap.values()).sort((a, b) => b.count - a.count) },
1131
+ brand,
1132
+ logos: allLogos,
1133
+ icons: allIcons.slice(0, 50),
1134
+ fontFiles: downloadedFontFiles,
1135
+ responsive: responsiveTokens,
1136
+ };
1137
+ const tokenPath = path.join(outputDir, 'tokens.json');
1138
+ fs.writeFileSync(tokenPath, JSON.stringify(tokens, null, 2));
1139
+ return { tokens, outputDir, screenshotPaths };
1140
+ }
1141
+ // ---------------------------------------------------------------------------
1142
+ // Zip packaging
1143
+ // ---------------------------------------------------------------------------
1144
+ async function createZipBundle(outputDir, tokens) {
1145
+ const { execSync } = await import('node:child_process');
1146
+ const zipPath = `${outputDir}.zip`;
1147
+ // Use system zip (available on macOS/Linux)
1148
+ execSync(`cd "${path.dirname(outputDir)}" && zip -r "${path.basename(zipPath)}" "${path.basename(outputDir)}"`, { stdio: 'pipe' });
1149
+ return zipPath;
1150
+ }
1151
+ // ---------------------------------------------------------------------------
1152
+ // CLI wrapper for token extraction
1153
+ // ---------------------------------------------------------------------------
1154
+ async function runTokenExtraction(opts) {
1155
+ console.log('=== Design Token Extraction ===');
1156
+ console.log(`URL: ${opts.url}`);
1157
+ console.log('');
1158
+ const outputDir = opts.output ?? `./audit-results/${new URL(opts.url).hostname}-tokens-${Date.now()}`;
1159
+ const { tokens, screenshotPaths } = await extractDesignTokens({
1160
+ url: opts.url,
1161
+ headless: opts.headless,
1162
+ outputDir,
1163
+ onProgress: (name, width, height, stats) => {
1164
+ console.log(`[${name}] ${width}x${height} — ${stats.colors} colors, ${stats.fonts} fonts, ${stats.buttons} button styles, ${stats.inputs} input styles, ${stats.cards} card patterns`);
1165
+ },
1166
+ });
1167
+ // Print summary
1168
+ const primaryColors = tokens.colors.filter(c => c.cluster === 'primary');
1169
+ const secondaryColors = tokens.colors.filter(c => c.cluster === 'secondary');
1170
+ const accentColors = tokens.colors.filter(c => c.cluster === 'accent');
1171
+ const neutralColors = tokens.colors.filter(c => c.cluster === 'neutral');
1172
+ console.log('');
1173
+ console.log('=== Extraction Summary ===');
1174
+ console.log('');
1175
+ console.log(`Colors: ${tokens.colors.length} unique`);
1176
+ console.log(` Primary: ${primaryColors.length} (${primaryColors.slice(0, 3).map(c => c.hex).join(', ')})`);
1177
+ console.log(` Secondary: ${secondaryColors.length} (${secondaryColors.slice(0, 3).map(c => c.hex).join(', ')})`);
1178
+ console.log(` Accent: ${accentColors.length} (${accentColors.slice(0, 3).map(c => c.hex).join(', ')})`);
1179
+ console.log(` Neutral: ${neutralColors.length}`);
1180
+ console.log(`Font families: ${tokens.typography.families.length}`);
1181
+ for (const f of tokens.typography.families.slice(0, 5)) {
1182
+ console.log(` ${f.classification.padEnd(8)} ${f.family.slice(0, 50)} [${f.weights.join(', ')}]`);
1183
+ }
1184
+ console.log(`Type scale: ${tokens.typography.scale.length} entries`);
1185
+ console.log(`CSS variables: ${Object.keys(tokens.customProperties).length}`);
1186
+ console.log(`Logos found: ${tokens.logos.length}`);
1187
+ console.log(`Icons found: ${tokens.icons.length}`);
1188
+ const downloadedCount = tokens.fontFiles.filter(f => f.localPath).length;
1189
+ console.log(`Font files: ${tokens.fontFiles.length} found, ${downloadedCount} downloaded`);
1190
+ for (const ff of tokens.fontFiles.slice(0, 5)) {
1191
+ const status = ff.localPath ? path.basename(ff.localPath) : '(not downloaded)';
1192
+ console.log(` ${ff.family} ${ff.weight} ${ff.style} [${ff.format}] ${status}`);
1193
+ }
1194
+ console.log(`Brand:`);
1195
+ if (tokens.brand.title)
1196
+ console.log(` Title: ${tokens.brand.title}`);
1197
+ if (tokens.brand.themeColor)
1198
+ console.log(` Theme color: ${tokens.brand.themeColor}`);
1199
+ if (tokens.brand.favicon)
1200
+ console.log(` Favicon: ${tokens.brand.favicon}`);
1201
+ if (tokens.brand.ogImage)
1202
+ console.log(` OG image: ${tokens.brand.ogImage}`);
1203
+ console.log('');
1204
+ for (const [name, vt] of Object.entries(tokens.responsive)) {
1205
+ const gridUnit = vt.gridBaseUnit ? `${vt.gridBaseUnit}px grid` : 'no clear grid';
1206
+ console.log(`[${name}] ${vt.spacing.length} spacing values (${gridUnit}), ${vt.borders.length} border-radius values, ${vt.shadows.length} shadow styles`);
1207
+ console.log(` Components: ${vt.components.buttons.length} button styles, ${vt.components.inputs.length} input styles, ${vt.components.cards.length} card patterns, ${vt.components.nav.length} nav patterns`);
1208
+ }
1209
+ // Create zip bundle
1210
+ try {
1211
+ const zipPath = await createZipBundle(outputDir, tokens);
1212
+ const zipSize = fs.statSync(zipPath).size;
1213
+ const sizeStr = zipSize > 1024 * 1024 ? `${(zipSize / 1024 / 1024).toFixed(1)} MB` : `${(zipSize / 1024).toFixed(0)} KB`;
1214
+ console.log('');
1215
+ console.log(`Zip: ${zipPath} (${sizeStr})`);
1216
+ }
1217
+ catch {
1218
+ console.log('\n(zip not available — output directory contains all files)');
1219
+ }
1220
+ console.log(`Output: ${outputDir}`);
1221
+ console.log(`Screenshots: ${path.join(outputDir, 'screenshots')}`);
1222
+ }
1223
+ //# sourceMappingURL=cli-design-audit.js.map