@tangle-network/browser-agent-driver 0.6.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.
- package/README.md +20 -2
- package/dist/brain/index.d.ts +1 -1
- package/dist/brain/index.d.ts.map +1 -1
- package/dist/brain/index.js +17 -3
- package/dist/brain/index.js.map +1 -1
- package/dist/cli-design-audit.d.ts +59 -0
- package/dist/cli-design-audit.d.ts.map +1 -0
- package/dist/cli-design-audit.js +1223 -0
- package/dist/cli-design-audit.js.map +1 -0
- package/dist/cli.js +134 -2
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/recovery.d.ts.map +1 -1
- package/dist/recovery.js +27 -1
- package/dist/recovery.js.map +1 -1
- package/dist/runner/runner.d.ts.map +1 -1
- package/dist/runner/runner.js +13 -0
- package/dist/runner/runner.js.map +1 -1
- package/dist/types.d.ts +119 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +12 -1
|
@@ -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
|