designlang 2.0.0 → 4.0.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,132 @@
1
+ // Responsive multi-breakpoint capture — extract at multiple viewports and diff
2
+
3
+ import { chromium } from 'playwright';
4
+
5
+ const VIEWPORTS = [
6
+ { name: 'mobile', width: 375, height: 812 },
7
+ { name: 'tablet', width: 768, height: 1024 },
8
+ { name: 'desktop', width: 1280, height: 800 },
9
+ { name: 'wide', width: 1920, height: 1080 },
10
+ ];
11
+
12
+ export async function captureResponsive(url, options = {}) {
13
+ const { wait = 0 } = options;
14
+ const browser = await chromium.launch({ headless: true });
15
+
16
+ const snapshots = [];
17
+
18
+ for (const vp of VIEWPORTS) {
19
+ const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height } });
20
+ const page = await context.newPage();
21
+
22
+ try {
23
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 });
24
+ if (wait > 0) await page.waitForTimeout(wait);
25
+ await page.evaluate(() => document.fonts.ready);
26
+
27
+ const data = await page.evaluate(() => {
28
+ const body = document.body;
29
+ const cs = getComputedStyle(body);
30
+ const html = document.documentElement;
31
+ const htmlCs = getComputedStyle(html);
32
+
33
+ // Collect key metrics at this viewport
34
+ const headings = {};
35
+ for (let i = 1; i <= 3; i++) {
36
+ const h = document.querySelector(`h1, h2, h3`.split(',')[i - 1]);
37
+ if (h) {
38
+ const hcs = getComputedStyle(h);
39
+ headings[`h${i}`] = { fontSize: hcs.fontSize, lineHeight: hcs.lineHeight };
40
+ }
41
+ }
42
+
43
+ // Body font size
44
+ const bodyFontSize = cs.fontSize;
45
+
46
+ // Navigation visibility
47
+ const nav = document.querySelector('nav, [role="navigation"]');
48
+ const navVisible = nav ? getComputedStyle(nav).display !== 'none' : false;
49
+ const navHeight = nav ? nav.getBoundingClientRect().height : 0;
50
+
51
+ // Count visible grid/flex containers
52
+ let gridCount = 0, flexCount = 0;
53
+ const allEls = document.querySelectorAll('*');
54
+ const sample = Array.from(allEls).slice(0, 2000);
55
+ for (const el of sample) {
56
+ const d = getComputedStyle(el).display;
57
+ if (d === 'grid' || d === 'inline-grid') gridCount++;
58
+ if (d === 'flex' || d === 'inline-flex') flexCount++;
59
+ }
60
+
61
+ // Count columns in grids
62
+ const grids = document.querySelectorAll('*');
63
+ let maxColumns = 0;
64
+ for (const el of Array.from(grids).slice(0, 1000)) {
65
+ const gcs = getComputedStyle(el);
66
+ if (gcs.display === 'grid' && gcs.gridTemplateColumns && gcs.gridTemplateColumns !== 'none') {
67
+ const cols = gcs.gridTemplateColumns.split(/\s+/).length;
68
+ if (cols > maxColumns) maxColumns = cols;
69
+ }
70
+ }
71
+
72
+ // Check for hamburger menu
73
+ const hamburger = document.querySelector('[class*="hamburger"], [class*="menu-toggle"], [aria-label*="menu"], button[class*="mobile"]');
74
+ const hasHamburger = hamburger ? getComputedStyle(hamburger).display !== 'none' : false;
75
+
76
+ return {
77
+ bodyFontSize,
78
+ headings,
79
+ navVisible,
80
+ navHeight,
81
+ gridCount,
82
+ flexCount,
83
+ maxColumns,
84
+ hasHamburger,
85
+ scrollHeight: document.documentElement.scrollHeight,
86
+ };
87
+ });
88
+
89
+ snapshots.push({ ...vp, ...data });
90
+ } catch {
91
+ snapshots.push({ ...vp, error: true });
92
+ }
93
+
94
+ await context.close();
95
+ }
96
+
97
+ await browser.close();
98
+
99
+ // Build responsive map — what changes between breakpoints
100
+ const changes = [];
101
+ for (let i = 1; i < snapshots.length; i++) {
102
+ const prev = snapshots[i - 1];
103
+ const curr = snapshots[i];
104
+ if (prev.error || curr.error) continue;
105
+
106
+ const diffs = [];
107
+ if (prev.bodyFontSize !== curr.bodyFontSize) {
108
+ diffs.push({ property: 'Body font size', from: prev.bodyFontSize, to: curr.bodyFontSize });
109
+ }
110
+ if (prev.headings?.h1?.fontSize !== curr.headings?.h1?.fontSize) {
111
+ diffs.push({ property: 'H1 size', from: prev.headings?.h1?.fontSize || 'n/a', to: curr.headings?.h1?.fontSize || 'n/a' });
112
+ }
113
+ if (prev.navVisible !== curr.navVisible) {
114
+ diffs.push({ property: 'Nav visibility', from: prev.navVisible ? 'visible' : 'hidden', to: curr.navVisible ? 'visible' : 'hidden' });
115
+ }
116
+ if (prev.hasHamburger !== curr.hasHamburger) {
117
+ diffs.push({ property: 'Hamburger menu', from: prev.hasHamburger ? 'shown' : 'hidden', to: curr.hasHamburger ? 'shown' : 'hidden' });
118
+ }
119
+ if (prev.maxColumns !== curr.maxColumns) {
120
+ diffs.push({ property: 'Max grid columns', from: String(prev.maxColumns), to: String(curr.maxColumns) });
121
+ }
122
+ if (Math.abs(prev.scrollHeight - curr.scrollHeight) > 200) {
123
+ diffs.push({ property: 'Page height', from: `${prev.scrollHeight}px`, to: `${curr.scrollHeight}px` });
124
+ }
125
+
126
+ if (diffs.length > 0) {
127
+ changes.push({ from: prev.name, to: curr.name, breakpoint: `${prev.width}px → ${curr.width}px`, diffs });
128
+ }
129
+ }
130
+
131
+ return { viewports: snapshots.filter(s => !s.error), changes };
132
+ }
@@ -0,0 +1,132 @@
1
+ // Design system scoring — rate consistency and quality
2
+
3
+ export function scoreDesignSystem(design) {
4
+ const scores = {};
5
+ const issues = [];
6
+
7
+ // 1. Color discipline (0-100)
8
+ // Fewer unique colors = more disciplined
9
+ const colorCount = design.colors.all.length;
10
+ if (colorCount <= 8) scores.colorDiscipline = 100;
11
+ else if (colorCount <= 15) scores.colorDiscipline = 85;
12
+ else if (colorCount <= 25) scores.colorDiscipline = 70;
13
+ else if (colorCount <= 40) scores.colorDiscipline = 50;
14
+ else { scores.colorDiscipline = 30; issues.push(`${colorCount} unique colors detected — consider consolidating to a tighter palette`); }
15
+
16
+ if (!design.colors.primary) {
17
+ scores.colorDiscipline -= 15;
18
+ issues.push('No clear primary brand color detected');
19
+ }
20
+
21
+ // 2. Typography consistency (0-100)
22
+ const fontCount = design.typography.families.length;
23
+ if (fontCount <= 2) scores.typographyConsistency = 100;
24
+ else if (fontCount <= 3) scores.typographyConsistency = 80;
25
+ else { scores.typographyConsistency = 50; issues.push(`${fontCount} font families — consider limiting to 2 (heading + body)`); }
26
+
27
+ const weightCount = design.typography.weights.length;
28
+ if (weightCount <= 3) scores.typographyConsistency = Math.min(scores.typographyConsistency, 100);
29
+ else if (weightCount <= 5) scores.typographyConsistency = Math.min(scores.typographyConsistency, 80);
30
+ else { scores.typographyConsistency -= 15; issues.push(`${weightCount} font weights in use — consider standardizing to 3 (regular, medium, bold)`); }
31
+
32
+ const scaleSize = design.typography.scale.length;
33
+ if (scaleSize <= 6) scores.typographyConsistency = Math.min(scores.typographyConsistency, 100);
34
+ else if (scaleSize <= 10) scores.typographyConsistency = Math.min(scores.typographyConsistency, 85);
35
+ else { scores.typographyConsistency -= 10; issues.push(`${scaleSize} distinct font sizes — consider a tighter type scale`); }
36
+
37
+ // 3. Spacing system (0-100)
38
+ if (design.spacing.base) {
39
+ scores.spacingSystem = 90;
40
+ // Check how many values fit the base
41
+ const fittingValues = design.spacing.scale.filter(v => v % design.spacing.base === 0).length;
42
+ const fitRatio = fittingValues / design.spacing.scale.length;
43
+ if (fitRatio >= 0.8) scores.spacingSystem = 100;
44
+ else if (fitRatio >= 0.6) scores.spacingSystem = 80;
45
+ else scores.spacingSystem = 65;
46
+ } else {
47
+ scores.spacingSystem = 40;
48
+ issues.push('No consistent spacing base unit detected — values appear arbitrary');
49
+ }
50
+
51
+ if (design.spacing.scale.length > 20) {
52
+ scores.spacingSystem -= 15;
53
+ issues.push(`${design.spacing.scale.length} unique spacing values — too many one-off values`);
54
+ }
55
+
56
+ // 4. Shadow consistency (0-100)
57
+ const shadowCount = design.shadows.values.length;
58
+ if (shadowCount === 0) scores.shadowConsistency = 80; // no shadows is fine
59
+ else if (shadowCount <= 4) scores.shadowConsistency = 100;
60
+ else if (shadowCount <= 8) scores.shadowConsistency = 75;
61
+ else { scores.shadowConsistency = 50; issues.push(`${shadowCount} unique shadows — consider a 3-level elevation scale (sm/md/lg)`); }
62
+
63
+ // 5. Border radius consistency (0-100)
64
+ const radiiCount = design.borders.radii.length;
65
+ if (radiiCount <= 3) scores.radiusConsistency = 100;
66
+ else if (radiiCount <= 5) scores.radiusConsistency = 85;
67
+ else if (radiiCount <= 8) scores.radiusConsistency = 65;
68
+ else { scores.radiusConsistency = 40; issues.push(`${radiiCount} unique border radii — standardize to 3-4 values`); }
69
+
70
+ // 6. Accessibility (from existing extractor)
71
+ scores.accessibility = design.accessibility?.score || 0;
72
+ if (design.accessibility?.failCount > 0) {
73
+ issues.push(`${design.accessibility.failCount} WCAG contrast failures`);
74
+ }
75
+
76
+ // 7. CSS variable usage (0-100)
77
+ const varCount = Object.values(design.variables).reduce((s, v) => s + Object.keys(v).length, 0);
78
+ if (varCount >= 20) scores.tokenization = 100;
79
+ else if (varCount >= 10) scores.tokenization = 75;
80
+ else if (varCount >= 1) scores.tokenization = 50;
81
+ else { scores.tokenization = 20; issues.push('No CSS custom properties found — design is not tokenized'); }
82
+
83
+ // Overall score (weighted average)
84
+ const weights = {
85
+ colorDiscipline: 20,
86
+ typographyConsistency: 20,
87
+ spacingSystem: 20,
88
+ shadowConsistency: 10,
89
+ radiusConsistency: 10,
90
+ accessibility: 15,
91
+ tokenization: 5,
92
+ };
93
+
94
+ let totalWeight = 0;
95
+ let weightedSum = 0;
96
+ for (const [key, weight] of Object.entries(weights)) {
97
+ if (scores[key] !== undefined) {
98
+ weightedSum += Math.max(0, scores[key]) * weight;
99
+ totalWeight += weight;
100
+ }
101
+ }
102
+
103
+ const overall = totalWeight > 0 ? Math.round(weightedSum / totalWeight) : 0;
104
+
105
+ // Grade
106
+ let grade;
107
+ if (overall >= 90) grade = 'A';
108
+ else if (overall >= 80) grade = 'B';
109
+ else if (overall >= 70) grade = 'C';
110
+ else if (overall >= 60) grade = 'D';
111
+ else grade = 'F';
112
+
113
+ return {
114
+ overall,
115
+ grade,
116
+ scores,
117
+ issues,
118
+ strengths: getStrengths(scores),
119
+ };
120
+ }
121
+
122
+ function getStrengths(scores) {
123
+ const strengths = [];
124
+ if (scores.colorDiscipline >= 85) strengths.push('Tight, disciplined color palette');
125
+ if (scores.typographyConsistency >= 85) strengths.push('Consistent typography system');
126
+ if (scores.spacingSystem >= 85) strengths.push('Well-defined spacing scale');
127
+ if (scores.shadowConsistency >= 85) strengths.push('Clean elevation system');
128
+ if (scores.radiusConsistency >= 85) strengths.push('Consistent border radii');
129
+ if (scores.accessibility >= 90) strengths.push('Strong accessibility compliance');
130
+ if (scores.tokenization >= 75) strengths.push('Good CSS variable tokenization');
131
+ return strengths;
132
+ }
@@ -259,6 +259,153 @@ export function formatMarkdown(design) {
259
259
  }
260
260
  }
261
261
 
262
+ // ── Layout ──
263
+ if (design.layout) {
264
+ const l = design.layout;
265
+ lines.push('## Layout System');
266
+ lines.push('');
267
+ lines.push(`**${l.gridCount} grid containers** and **${l.flexCount} flex containers** detected.`);
268
+ lines.push('');
269
+
270
+ if (l.containerWidths.length > 0) {
271
+ lines.push('### Container Widths');
272
+ lines.push('');
273
+ lines.push('| Max Width | Padding |');
274
+ lines.push('|-----------|---------|');
275
+ for (const c of l.containerWidths) {
276
+ lines.push(`| ${c.maxWidth} | ${c.padding} |`);
277
+ }
278
+ lines.push('');
279
+ }
280
+
281
+ if (l.gridColumns.length > 0) {
282
+ lines.push('### Grid Column Patterns');
283
+ lines.push('');
284
+ lines.push('| Columns | Usage Count |');
285
+ lines.push('|---------|-------------|');
286
+ for (const g of l.gridColumns) {
287
+ lines.push(`| ${g.columns}-column | ${g.count}x |`);
288
+ }
289
+ lines.push('');
290
+ }
291
+
292
+ if (l.topGrids.length > 0) {
293
+ lines.push('### Grid Templates');
294
+ lines.push('');
295
+ lines.push('```css');
296
+ for (const g of l.topGrids) {
297
+ lines.push(`grid-template-columns: ${g.columns};`);
298
+ if (g.gap !== 'normal' && g.gap !== '0px') lines.push(`gap: ${g.gap};`);
299
+ }
300
+ lines.push('```');
301
+ lines.push('');
302
+ }
303
+
304
+ if (Object.keys(l.flexDirections).length > 0) {
305
+ lines.push('### Flex Patterns');
306
+ lines.push('');
307
+ lines.push('| Direction/Wrap | Count |');
308
+ lines.push('|----------------|-------|');
309
+ for (const [pattern, count] of Object.entries(l.flexDirections)) {
310
+ lines.push(`| ${pattern} | ${count}x |`);
311
+ }
312
+ lines.push('');
313
+ }
314
+
315
+ if (l.gaps.length > 0) {
316
+ lines.push(`**Gap values:** ${l.gaps.map(g => `\`${g}\``).join(', ')}`);
317
+ lines.push('');
318
+ }
319
+ }
320
+
321
+ // ── Responsive ──
322
+ if (design.responsive) {
323
+ const r = design.responsive;
324
+ lines.push('## Responsive Design');
325
+ lines.push('');
326
+
327
+ if (r.viewports.length > 0) {
328
+ lines.push('### Viewport Snapshots');
329
+ lines.push('');
330
+ lines.push('| Viewport | Body Font | Nav Visible | Max Columns | Hamburger | Page Height |');
331
+ lines.push('|----------|-----------|-------------|-------------|-----------|-------------|');
332
+ for (const vp of r.viewports) {
333
+ lines.push(`| ${vp.name} (${vp.width}px) | ${vp.bodyFontSize} | ${vp.navVisible ? 'Yes' : 'No'} | ${vp.maxColumns} | ${vp.hasHamburger ? 'Yes' : 'No'} | ${vp.scrollHeight}px |`);
334
+ }
335
+ lines.push('');
336
+ }
337
+
338
+ if (r.changes.length > 0) {
339
+ lines.push('### Breakpoint Changes');
340
+ lines.push('');
341
+ for (const change of r.changes) {
342
+ lines.push(`**${change.breakpoint}** (${change.from} → ${change.to}):`);
343
+ for (const d of change.diffs) {
344
+ lines.push(`- ${d.property}: \`${d.from}\` → \`${d.to}\``);
345
+ }
346
+ lines.push('');
347
+ }
348
+ }
349
+ }
350
+
351
+ // ── Interaction States ──
352
+ if (design.interactions) {
353
+ const hasContent = design.interactions.buttons.length > 0 || design.interactions.links.length > 0 || design.interactions.inputs.length > 0;
354
+ if (hasContent) {
355
+ lines.push('## Interaction States');
356
+ lines.push('');
357
+
358
+ if (design.interactions.buttons.length > 0) {
359
+ lines.push('### Button States');
360
+ lines.push('');
361
+ for (const btn of design.interactions.buttons.slice(0, 3)) {
362
+ lines.push(`**"${btn.text}"**`);
363
+ if (Object.keys(btn.hover).length > 0) {
364
+ lines.push('```css');
365
+ lines.push('/* Hover */');
366
+ for (const [prop, val] of Object.entries(btn.hover)) {
367
+ lines.push(`${prop.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${val.from} → ${val.to};`);
368
+ }
369
+ lines.push('```');
370
+ }
371
+ if (Object.keys(btn.focus).length > 0) {
372
+ lines.push('```css');
373
+ lines.push('/* Focus */');
374
+ for (const [prop, val] of Object.entries(btn.focus)) {
375
+ lines.push(`${prop.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${val.from} → ${val.to};`);
376
+ }
377
+ lines.push('```');
378
+ }
379
+ lines.push('');
380
+ }
381
+ }
382
+
383
+ if (design.interactions.links.length > 0) {
384
+ lines.push('### Link Hover');
385
+ lines.push('');
386
+ const link = design.interactions.links[0];
387
+ lines.push('```css');
388
+ for (const [prop, val] of Object.entries(link.hover)) {
389
+ lines.push(`${prop.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${val.from} → ${val.to};`);
390
+ }
391
+ lines.push('```');
392
+ lines.push('');
393
+ }
394
+
395
+ if (design.interactions.inputs.length > 0) {
396
+ lines.push('### Input Focus');
397
+ lines.push('');
398
+ const input = design.interactions.inputs[0];
399
+ lines.push('```css');
400
+ for (const [prop, val] of Object.entries(input.focus)) {
401
+ lines.push(`${prop.replace(/([A-Z])/g, '-$1').toLowerCase()}: ${val.from} → ${val.to};`);
402
+ }
403
+ lines.push('```');
404
+ lines.push('');
405
+ }
406
+ }
407
+ }
408
+
262
409
  // ── Accessibility ──
263
410
  if (design.accessibility) {
264
411
  const a = design.accessibility;
@@ -322,6 +469,37 @@ export function formatMarkdown(design) {
322
469
  }
323
470
  }
324
471
 
472
+ // ── Design Score ──
473
+ if (design.score) {
474
+ const s = design.score;
475
+ lines.push('## Design System Score');
476
+ lines.push('');
477
+ lines.push(`**Overall: ${s.overall}/100 (Grade: ${s.grade})**`);
478
+ lines.push('');
479
+ lines.push('| Category | Score |');
480
+ lines.push('|----------|-------|');
481
+ if (s.scores.colorDiscipline !== undefined) lines.push(`| Color Discipline | ${s.scores.colorDiscipline}/100 |`);
482
+ if (s.scores.typographyConsistency !== undefined) lines.push(`| Typography Consistency | ${s.scores.typographyConsistency}/100 |`);
483
+ if (s.scores.spacingSystem !== undefined) lines.push(`| Spacing System | ${s.scores.spacingSystem}/100 |`);
484
+ if (s.scores.shadowConsistency !== undefined) lines.push(`| Shadow Consistency | ${s.scores.shadowConsistency}/100 |`);
485
+ if (s.scores.radiusConsistency !== undefined) lines.push(`| Border Radius Consistency | ${s.scores.radiusConsistency}/100 |`);
486
+ if (s.scores.accessibility !== undefined) lines.push(`| Accessibility | ${s.scores.accessibility}/100 |`);
487
+ if (s.scores.tokenization !== undefined) lines.push(`| CSS Tokenization | ${s.scores.tokenization}/100 |`);
488
+ lines.push('');
489
+
490
+ if (s.strengths.length > 0) {
491
+ lines.push('**Strengths:** ' + s.strengths.join(', '));
492
+ lines.push('');
493
+ }
494
+ if (s.issues.length > 0) {
495
+ lines.push('**Issues:**');
496
+ for (const issue of s.issues) {
497
+ lines.push(`- ${issue}`);
498
+ }
499
+ lines.push('');
500
+ }
501
+ }
502
+
325
503
  // ── Quick Start ──
326
504
  lines.push('## Quick Start');
327
505
  lines.push('');
package/src/index.js CHANGED
@@ -9,6 +9,8 @@ import { extractBreakpoints } from './extractors/breakpoints.js';
9
9
  import { extractAnimations } from './extractors/animations.js';
10
10
  import { extractComponents } from './extractors/components.js';
11
11
  import { extractAccessibility } from './extractors/accessibility.js';
12
+ import { extractLayout } from './extractors/layout.js';
13
+ import { scoreDesignSystem } from './extractors/scoring.js';
12
14
 
13
15
  export async function extractDesignLanguage(url, options = {}) {
14
16
  const rawData = await crawlPage(url, options);
@@ -32,7 +34,9 @@ export async function extractDesignLanguage(url, options = {}) {
32
34
  animations: extractAnimations(styles, rawData.light.keyframes),
33
35
  components: extractComponents(styles),
34
36
  accessibility: extractAccessibility(styles),
37
+ layout: extractLayout(styles),
35
38
  componentScreenshots: rawData.componentScreenshots || {},
39
+ score: null, // populated below
36
40
  };
37
41
 
38
42
  if (rawData.dark) {
@@ -42,6 +46,8 @@ export async function extractDesignLanguage(url, options = {}) {
42
46
  };
43
47
  }
44
48
 
49
+ design.score = scoreDesignSystem(design);
50
+
45
51
  return design;
46
52
  }
47
53
 
@@ -55,3 +61,10 @@ export { formatFigma } from './formatters/figma.js';
55
61
  export { formatReactTheme, formatShadcnTheme } from './formatters/theme.js';
56
62
  export { diffDesigns, formatDiffMarkdown, formatDiffHtml } from './diff.js';
57
63
  export { saveSnapshot, getHistory, formatHistoryMarkdown } from './history.js';
64
+ export { captureResponsive } from './extractors/responsive.js';
65
+ export { captureInteractions } from './extractors/interactions.js';
66
+ export { syncDesign } from './sync.js';
67
+ export { compareBrands, formatBrandMatrix, formatBrandMatrixHtml } from './multibrand.js';
68
+ export { generateClone } from './clone.js';
69
+ export { scoreDesignSystem } from './extractors/scoring.js';
70
+ export { watchSite } from './watch.js';
@@ -0,0 +1,151 @@
1
+ // Multi-brand N-site comparison matrix
2
+
3
+ import { extractDesignLanguage } from './index.js';
4
+
5
+ export async function compareBrands(urls, options = {}) {
6
+ const brands = [];
7
+
8
+ for (const url of urls) {
9
+ const normalized = url.startsWith('http') ? url : `https://${url}`;
10
+ try {
11
+ const design = await extractDesignLanguage(normalized, options);
12
+ const hostname = new URL(normalized).hostname.replace(/^www\./, '');
13
+ brands.push({ url: normalized, hostname, design });
14
+ } catch (err) {
15
+ brands.push({ url: normalized, hostname: url, error: err.message });
16
+ }
17
+ }
18
+
19
+ return brands;
20
+ }
21
+
22
+ export function formatBrandMatrix(brands) {
23
+ const lines = [];
24
+ const valid = brands.filter(b => !b.error);
25
+
26
+ lines.push('# Multi-Brand Design Comparison');
27
+ lines.push('');
28
+ lines.push(`Comparing ${valid.length} brands.`);
29
+ lines.push('');
30
+
31
+ // Overview table
32
+ const headers = ['Property', ...valid.map(b => b.hostname)];
33
+ lines.push(`| ${headers.join(' | ')} |`);
34
+ lines.push(`| ${headers.map(() => '---').join(' | ')} |`);
35
+
36
+ // Primary color
37
+ lines.push(`| Primary Color | ${valid.map(b => `\`${b.design.colors.primary?.hex || 'none'}\``).join(' | ')} |`);
38
+ // Secondary color
39
+ lines.push(`| Secondary Color | ${valid.map(b => `\`${b.design.colors.secondary?.hex || 'none'}\``).join(' | ')} |`);
40
+ // Fonts
41
+ lines.push(`| Fonts | ${valid.map(b => b.design.typography.families.map(f => f.name).join(', ') || 'none').join(' | ')} |`);
42
+ // Color count
43
+ lines.push(`| Color Count | ${valid.map(b => b.design.colors.all.length).join(' | ')} |`);
44
+ // Spacing base
45
+ lines.push(`| Spacing Base | ${valid.map(b => b.design.spacing.base ? `${b.design.spacing.base}px` : 'none').join(' | ')} |`);
46
+ // A11y score
47
+ lines.push(`| A11y Score | ${valid.map(b => b.design.accessibility ? `${b.design.accessibility.score}%` : 'n/a').join(' | ')} |`);
48
+ // Shadows
49
+ lines.push(`| Shadows | ${valid.map(b => b.design.shadows.values.length).join(' | ')} |`);
50
+ // Radii
51
+ lines.push(`| Border Radii | ${valid.map(b => b.design.borders.radii.length).join(' | ')} |`);
52
+ // CSS vars
53
+ lines.push(`| CSS Variables | ${valid.map(b => Object.values(b.design.variables).reduce((s, v) => s + Object.keys(v).length, 0)).join(' | ')} |`);
54
+ // Components
55
+ lines.push(`| Components | ${valid.map(b => Object.keys(b.design.components).join(', ') || 'none').join(' | ')} |`);
56
+
57
+ lines.push('');
58
+
59
+ // Color overlap matrix
60
+ lines.push('## Color Overlap');
61
+ lines.push('');
62
+ if (valid.length >= 2) {
63
+ for (let i = 0; i < valid.length; i++) {
64
+ for (let j = i + 1; j < valid.length; j++) {
65
+ const colorsA = new Set(valid[i].design.colors.all.map(c => c.hex));
66
+ const colorsB = new Set(valid[j].design.colors.all.map(c => c.hex));
67
+ const shared = [...colorsA].filter(c => colorsB.has(c));
68
+ lines.push(`**${valid[i].hostname} vs ${valid[j].hostname}:** ${shared.length} shared colors${shared.length > 0 ? ` (${shared.slice(0, 5).map(c => `\`${c}\``).join(', ')})` : ''}`);
69
+ }
70
+ }
71
+ lines.push('');
72
+ }
73
+
74
+ // Font comparison
75
+ lines.push('## Typography Comparison');
76
+ lines.push('');
77
+ const allFonts = new Set();
78
+ for (const b of valid) {
79
+ for (const f of b.design.typography.families) allFonts.add(f.name);
80
+ }
81
+ lines.push(`| Font | ${valid.map(b => b.hostname).join(' | ')} |`);
82
+ lines.push(`| --- | ${valid.map(() => '---').join(' | ')} |`);
83
+ for (const font of allFonts) {
84
+ lines.push(`| ${font} | ${valid.map(b => b.design.typography.families.some(f => f.name === font) ? 'Yes' : '-').join(' | ')} |`);
85
+ }
86
+ lines.push('');
87
+
88
+ // Errors
89
+ const errored = brands.filter(b => b.error);
90
+ if (errored.length > 0) {
91
+ lines.push('## Errors');
92
+ lines.push('');
93
+ for (const b of errored) {
94
+ lines.push(`- **${b.url}**: ${b.error}`);
95
+ }
96
+ lines.push('');
97
+ }
98
+
99
+ return lines.join('\n');
100
+ }
101
+
102
+ export function formatBrandMatrixHtml(brands) {
103
+ const valid = brands.filter(b => !b.error);
104
+
105
+ const swatchCell = (hex) => hex
106
+ ? `<td><span style="display:inline-block;width:16px;height:16px;border-radius:4px;background:${hex};border:1px solid #333;vertical-align:middle;margin-right:6px"></span><code>${hex}</code></td>`
107
+ : `<td>-</td>`;
108
+
109
+ return `<!DOCTYPE html>
110
+ <html><head><meta charset="UTF-8"><title>Multi-Brand Comparison</title>
111
+ <style>
112
+ * { margin:0; padding:0; box-sizing:border-box; }
113
+ body { font-family:-apple-system,sans-serif; background:#0a0a0a; color:#e5e5e5; padding:40px; }
114
+ h1 { font-size:28px; color:#fff; margin-bottom:24px; }
115
+ h2 { font-size:18px; color:#fff; margin:32px 0 12px; }
116
+ table { width:100%; border-collapse:collapse; margin:12px 0; }
117
+ th { text-align:left; padding:10px 12px; background:#141414; color:#888; font-size:12px; text-transform:uppercase; letter-spacing:0.05em; border-bottom:1px solid #222; }
118
+ td { padding:10px 12px; border-bottom:1px solid #1a1a1a; font-size:13px; }
119
+ tr:hover td { background:#111; }
120
+ code { background:#1e1e2e; padding:2px 6px; border-radius:4px; font-size:12px; color:#a78bfa; }
121
+ .score-good { color:#22c55e; } .score-warn { color:#eab308; } .score-bad { color:#ef4444; }
122
+ </style></head><body>
123
+ <h1>Multi-Brand Design Comparison</h1>
124
+ <p style="color:#666;margin-bottom:24px">${valid.length} brands analyzed</p>
125
+
126
+ <table>
127
+ <tr><th>Property</th>${valid.map(b => `<th>${b.hostname}</th>`).join('')}</tr>
128
+ <tr><td>Primary Color</td>${valid.map(b => swatchCell(b.design.colors.primary?.hex)).join('')}</tr>
129
+ <tr><td>Secondary Color</td>${valid.map(b => swatchCell(b.design.colors.secondary?.hex)).join('')}</tr>
130
+ <tr><td>Fonts</td>${valid.map(b => `<td>${b.design.typography.families.map(f => `<code>${f.name}</code>`).join(' ')}</td>`).join('')}</tr>
131
+ <tr><td>Colors</td>${valid.map(b => `<td>${b.design.colors.all.length}</td>`).join('')}</tr>
132
+ <tr><td>Spacing Base</td>${valid.map(b => `<td>${b.design.spacing.base ? b.design.spacing.base + 'px' : '-'}</td>`).join('')}</tr>
133
+ <tr><td>A11y Score</td>${valid.map(b => {
134
+ const s = b.design.accessibility?.score;
135
+ const cls = s >= 80 ? 'score-good' : s >= 50 ? 'score-warn' : 'score-bad';
136
+ return `<td class="${cls}">${s ?? 'n/a'}%</td>`;
137
+ }).join('')}</tr>
138
+ <tr><td>Shadows</td>${valid.map(b => `<td>${b.design.shadows.values.length}</td>`).join('')}</tr>
139
+ <tr><td>Border Radii</td>${valid.map(b => `<td>${b.design.borders.radii.length}</td>`).join('')}</tr>
140
+ <tr><td>CSS Variables</td>${valid.map(b => `<td>${Object.values(b.design.variables).reduce((s, v) => s + Object.keys(v).length, 0)}</td>`).join('')}</tr>
141
+ </table>
142
+
143
+ <h2>Full Color Palettes</h2>
144
+ ${valid.map(b => `
145
+ <h3 style="color:#888;font-size:14px;margin:16px 0 8px">${b.hostname}</h3>
146
+ <div style="display:flex;gap:4px;flex-wrap:wrap">
147
+ ${b.design.colors.all.slice(0, 15).map(c => `<div style="width:32px;height:32px;border-radius:6px;background:${c.hex};border:1px solid #333" title="${c.hex}"></div>`).join('')}
148
+ </div>`).join('')}
149
+
150
+ </body></html>`;
151
+ }