designlang 4.0.1 → 5.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,80 @@
1
+ export function extractIcons(iconData) {
2
+ if (!iconData || !iconData.length) {
3
+ return { icons: [], sizeDistribution: { xs: 0, sm: 0, md: 0, lg: 0, xl: 0 }, dominantStyle: 'none', colorPalette: [], count: 0 };
4
+ }
5
+
6
+ function classifySize(w, h) {
7
+ const s = Math.max(w || 0, h || 0);
8
+ if (s < 16) return 'xs';
9
+ if (s < 20) return 'sm';
10
+ if (s < 28) return 'md';
11
+ if (s < 40) return 'lg';
12
+ return 'xl';
13
+ }
14
+
15
+ function cleanSvg(svg) {
16
+ return svg.replace(/\s*(data-[a-z-]*|class|id)="[^"]*"/g, '').replace(/\s+/g, ' ').trim();
17
+ }
18
+ function simplify(svg) { return cleanSvg(svg); }
19
+
20
+ function detectStyle(svg) {
21
+ const hasStroke = /stroke="(?!none)[^"]+"|stroke-width="[^0"][^"]*"/.test(svg);
22
+ const hasFill = /fill="(?!none|transparent)[^"]+"|<(rect|circle|path)[^>]*(?!fill="none")/.test(svg);
23
+ const fillNone = /fill="none"/.test(svg);
24
+ if (hasStroke && (fillNone || !hasFill)) return 'outlined';
25
+ if (hasStroke && hasFill) return 'duo-tone';
26
+ return 'filled';
27
+ }
28
+
29
+ function extractColors(svg) {
30
+ const colors = new Set();
31
+ for (const m of svg.matchAll(/(?:fill|stroke)="([^"]+)"/g)) {
32
+ if (m[1] !== 'none' && m[1] !== 'transparent') colors.add(m[1]);
33
+ }
34
+ return [...colors];
35
+ }
36
+
37
+ // Deduplicate
38
+ const seen = new Map();
39
+ for (const icon of iconData) {
40
+ const key = simplify(icon.svg);
41
+ if (!seen.has(key)) seen.set(key, icon);
42
+ }
43
+
44
+ const sizeDistribution = { xs: 0, sm: 0, md: 0, lg: 0, xl: 0 };
45
+ const styleCounts = { outlined: 0, filled: 0, 'duo-tone': 0 };
46
+ const allColors = new Set();
47
+
48
+ const icons = [];
49
+ for (const icon of seen.values()) {
50
+ const cleaned = cleanSvg(icon.svg);
51
+ const sc = classifySize(icon.width, icon.height);
52
+ const style = detectStyle(icon.svg);
53
+ const colors = extractColors(icon.svg);
54
+ if (icon.fill && icon.fill !== 'none') colors.push(icon.fill);
55
+ if (icon.stroke && icon.stroke !== 'none') colors.push(icon.stroke);
56
+ const uniqueColors = [...new Set(colors)];
57
+
58
+ sizeDistribution[sc]++;
59
+ styleCounts[style]++;
60
+ uniqueColors.forEach(c => allColors.add(c));
61
+
62
+ icons.push({
63
+ svg: cleaned,
64
+ size: { width: icon.width, height: icon.height },
65
+ sizeClass: sc,
66
+ style,
67
+ colors: uniqueColors,
68
+ });
69
+ }
70
+
71
+ const dominantStyle = Object.entries(styleCounts).sort((a, b) => b[1] - a[1])[0][0];
72
+
73
+ return {
74
+ icons,
75
+ sizeDistribution,
76
+ dominantStyle,
77
+ colorPalette: [...allColors],
78
+ count: icons.length,
79
+ };
80
+ }
@@ -0,0 +1,76 @@
1
+ export function extractImageStyles(imageData) {
2
+ const ratioCount = new Map();
3
+ const shapeCount = new Map();
4
+ const filterCount = new Map();
5
+ const fitCount = new Map();
6
+ const patternCount = new Map();
7
+
8
+ const knownRatios = [
9
+ [1, 1, '1:1'], [4, 3, '4:3'], [3, 4, '3:4'], [16, 9, '16:9'], [9, 16, '9:16'],
10
+ [3, 2, '3:2'], [2, 3, '2:3'], [21, 9, '21:9'],
11
+ ];
12
+
13
+ function closestRatio(w, h) {
14
+ if (!w || !h) return null;
15
+ const r = w / h;
16
+ let best = null, bestDiff = 0.15;
17
+ for (const [rw, rh, label] of knownRatios) {
18
+ const diff = Math.abs(r - rw / rh);
19
+ if (diff < bestDiff) { best = label; bestDiff = diff; }
20
+ }
21
+ return best || `${Math.round(r * 100) / 100}:1`;
22
+ }
23
+
24
+ function classifyShape(borderRadius) {
25
+ const br = parseFloat(borderRadius) || 0;
26
+ if (br >= 50) return 'circular';
27
+ if (br >= 20) return 'pill';
28
+ if (br > 0) return 'rounded';
29
+ return 'square';
30
+ }
31
+
32
+ function classifyPattern(img, shape) {
33
+ const w = img.width || 0, h = img.height || 0;
34
+ const area = w * h;
35
+ if (shape === 'circular' && area <= 22500) return 'avatar';
36
+ if (w >= 600 && h >= 200 && img.objectFit === 'cover') return 'hero';
37
+ if (area <= 40000 && (shape === 'rounded' || shape === 'square')) return 'thumbnail';
38
+ if (w >= 400 && h >= 400 && shape === 'square') return 'gallery';
39
+ return 'general';
40
+ }
41
+
42
+ function incMap(map, key, extra) {
43
+ if (!map.has(key)) map.set(key, { count: 0, ...extra });
44
+ map.get(key).count++;
45
+ }
46
+
47
+ for (const img of imageData) {
48
+ const ratio = closestRatio(img.width, img.height);
49
+ if (ratio) incMap(ratioCount, ratio);
50
+
51
+ const shape = classifyShape(img.borderRadius);
52
+ incMap(shapeCount, shape, { borderRadius: img.borderRadius || '0' });
53
+
54
+ if (img.filter && img.filter !== 'none') {
55
+ for (const f of img.filter.match(/[a-z-]+\(/g) || [img.filter]) {
56
+ incMap(filterCount, f.replace('(', ''));
57
+ }
58
+ }
59
+
60
+ if (img.objectFit && img.objectFit !== 'initial') incMap(fitCount, img.objectFit);
61
+
62
+ const pattern = classifyPattern(img, shape);
63
+ incMap(patternCount, pattern, { styles: { objectFit: img.objectFit, borderRadius: img.borderRadius, shape } });
64
+ }
65
+
66
+ const toArray = (map, keyName) =>
67
+ Array.from(map.entries()).map(([k, v]) => ({ [keyName]: k, ...v })).sort((a, b) => b.count - a.count);
68
+
69
+ return {
70
+ patterns: toArray(patternCount, 'name'),
71
+ aspectRatios: toArray(ratioCount, 'ratio'),
72
+ shapes: toArray(shapeCount, 'shape'),
73
+ filters: toArray(filterCount, 'filter'),
74
+ objectFitUsage: toArray(fitCount, 'value'),
75
+ };
76
+ }
@@ -0,0 +1,65 @@
1
+ const LAYER_DEFS = [
2
+ { name: 'modal', min: 1000, max: Infinity },
3
+ { name: 'dropdown', min: 100, max: 999 },
4
+ { name: 'sticky', min: 10, max: 99 },
5
+ { name: 'base', min: -Infinity, max: 9 },
6
+ ];
7
+
8
+ function elLabel(el) {
9
+ const cls = el.classList?.length ? '.' + [...el.classList].join('.') : '';
10
+ return el.tag + cls;
11
+ }
12
+
13
+ export function extractZIndex(styles) {
14
+ // Filter and parse explicit z-index values
15
+ const entries = styles
16
+ .filter(el => el.zIndex !== 'auto')
17
+ .map(el => ({ value: parseInt(el.zIndex, 10), el }))
18
+ .filter(e => !isNaN(e.value));
19
+
20
+ // Group by z-index value
21
+ const byValue = new Map();
22
+ for (const { value, el } of entries) {
23
+ if (!byValue.has(value)) byValue.set(value, []);
24
+ byValue.get(value).push(el);
25
+ }
26
+
27
+ const allValues = [...byValue.keys()].sort((a, b) => a - b);
28
+
29
+ // Build scale: each unique value with count and representative elements
30
+ const scale = allValues.map(value => ({
31
+ value,
32
+ count: byValue.get(value).length,
33
+ elements: byValue.get(value).map(elLabel),
34
+ }));
35
+
36
+ // Build layers from predefined ranges
37
+ const layers = LAYER_DEFS
38
+ .map(def => {
39
+ const matching = allValues.filter(v => v >= def.min && v <= def.max);
40
+ if (!matching.length) return null;
41
+ const elements = matching.flatMap(v => byValue.get(v).map(elLabel));
42
+ return {
43
+ name: def.name,
44
+ range: [Math.min(...matching), Math.max(...matching)],
45
+ elements,
46
+ };
47
+ })
48
+ .filter(Boolean);
49
+
50
+ // Detect issues
51
+ const issues = [];
52
+ const highValues = allValues.filter(v => v > 9999);
53
+ if (highValues.length) {
54
+ issues.push({ type: 'excessive', message: `Very high z-index values: ${highValues.join(', ')}` });
55
+ }
56
+ if (allValues.length >= 5) {
57
+ const spread = allValues[allValues.length - 1] - allValues[0];
58
+ const density = allValues.length / (spread || 1);
59
+ if (density > 0.3) {
60
+ issues.push({ type: 'z-index-war', message: `${allValues.length} unique values in a narrow range (${allValues[0]}-${allValues[allValues.length - 1]})` });
61
+ }
62
+ }
63
+
64
+ return { layers, allValues, issues, scale };
65
+ }
@@ -500,6 +500,104 @@ export function formatMarkdown(design) {
500
500
  }
501
501
  }
502
502
 
503
+ // ── Gradients ──
504
+ if (design.gradients && design.gradients.count > 0) {
505
+ lines.push('## Gradients');
506
+ lines.push('');
507
+ lines.push(`**${design.gradients.count} unique gradients** detected.`);
508
+ lines.push('');
509
+ lines.push('| Type | Direction | Stops | Classification |');
510
+ lines.push('|------|-----------|-------|----------------|');
511
+ for (const g of design.gradients.gradients.slice(0, 15)) {
512
+ lines.push(`| ${g.type} | ${g.direction || '—'} | ${g.stops.length} | ${g.classification} |`);
513
+ }
514
+ lines.push('');
515
+ lines.push('```css');
516
+ for (const g of design.gradients.gradients.slice(0, 5)) {
517
+ lines.push(`background: ${g.raw};`);
518
+ }
519
+ lines.push('```');
520
+ lines.push('');
521
+ }
522
+
523
+ // ── Z-Index Map ──
524
+ if (design.zIndex && design.zIndex.allValues.length > 0) {
525
+ lines.push('## Z-Index Map');
526
+ lines.push('');
527
+ lines.push(`**${design.zIndex.allValues.length} unique z-index values** across ${design.zIndex.layers.length} layers.`);
528
+ lines.push('');
529
+ if (design.zIndex.layers.length > 0) {
530
+ lines.push('| Layer | Range | Elements |');
531
+ lines.push('|-------|-------|----------|');
532
+ for (const l of design.zIndex.layers) {
533
+ const elNames = l.elements.slice(0, 3).join(', ');
534
+ lines.push(`| ${l.name} | ${l.range} | ${elNames} |`);
535
+ }
536
+ lines.push('');
537
+ }
538
+ if (design.zIndex.issues.length > 0) {
539
+ lines.push('**Issues:**');
540
+ for (const issue of design.zIndex.issues) {
541
+ lines.push(`- ${issue}`);
542
+ }
543
+ lines.push('');
544
+ }
545
+ }
546
+
547
+ // ── Icons ──
548
+ if (design.icons && design.icons.count > 0) {
549
+ lines.push('## SVG Icons');
550
+ lines.push('');
551
+ lines.push(`**${design.icons.count} unique SVG icons** detected. Dominant style: **${design.icons.dominantStyle || 'mixed'}**.`);
552
+ lines.push('');
553
+ const dist = design.icons.sizeDistribution;
554
+ if (dist) {
555
+ lines.push('| Size Class | Count |');
556
+ lines.push('|------------|-------|');
557
+ for (const [cls, count] of Object.entries(dist)) {
558
+ if (count > 0) lines.push(`| ${cls} | ${count} |`);
559
+ }
560
+ lines.push('');
561
+ }
562
+ if (design.icons.colorPalette.length > 0) {
563
+ lines.push(`**Icon colors:** ${design.icons.colorPalette.slice(0, 10).map(c => `\`${c}\``).join(', ')}`);
564
+ lines.push('');
565
+ }
566
+ }
567
+
568
+ // ── Font Files ──
569
+ if (design.fonts && design.fonts.fonts.length > 0) {
570
+ lines.push('## Font Files');
571
+ lines.push('');
572
+ lines.push('| Family | Source | Weights | Styles |');
573
+ lines.push('|--------|--------|---------|--------|');
574
+ for (const f of design.fonts.fonts) {
575
+ lines.push(`| ${f.family} | ${f.source} | ${f.weights.join(', ')} | ${f.styles.join(', ')} |`);
576
+ }
577
+ lines.push('');
578
+ if (design.fonts.googleFontsUrl) {
579
+ lines.push(`**Google Fonts URL:** \`${design.fonts.googleFontsUrl}\``);
580
+ lines.push('');
581
+ }
582
+ }
583
+
584
+ // ── Image Styles ──
585
+ if (design.images && design.images.patterns.length > 0) {
586
+ lines.push('## Image Style Patterns');
587
+ lines.push('');
588
+ lines.push('| Pattern | Count | Key Styles |');
589
+ lines.push('|---------|-------|------------|');
590
+ for (const p of design.images.patterns) {
591
+ const styles = Object.entries(p.styles || {}).map(([k, v]) => `${k}: ${v}`).join(', ');
592
+ lines.push(`| ${p.name} | ${p.count} | ${styles || '—'} |`);
593
+ }
594
+ lines.push('');
595
+ if (design.images.aspectRatios.length > 0) {
596
+ lines.push(`**Aspect ratios:** ${design.images.aspectRatios.slice(0, 8).map(a => `${a.ratio} (${a.count}x)`).join(', ')}`);
597
+ lines.push('');
598
+ }
599
+ }
600
+
503
601
  // ── Quick Start ──
504
602
  lines.push('## Quick Start');
505
603
  lines.push('');
package/src/index.js CHANGED
@@ -11,6 +11,11 @@ import { extractComponents } from './extractors/components.js';
11
11
  import { extractAccessibility } from './extractors/accessibility.js';
12
12
  import { extractLayout } from './extractors/layout.js';
13
13
  import { scoreDesignSystem } from './extractors/scoring.js';
14
+ import { extractGradients } from './extractors/gradients.js';
15
+ import { extractZIndex } from './extractors/zindex.js';
16
+ import { extractIcons } from './extractors/icons.js';
17
+ import { extractFonts } from './extractors/fonts.js';
18
+ import { extractImageStyles } from './extractors/images.js';
14
19
 
15
20
  export async function extractDesignLanguage(url, options = {}) {
16
21
  const rawData = await crawlPage(url, options);
@@ -35,8 +40,13 @@ export async function extractDesignLanguage(url, options = {}) {
35
40
  components: extractComponents(styles),
36
41
  accessibility: extractAccessibility(styles),
37
42
  layout: extractLayout(styles),
43
+ gradients: extractGradients(styles),
44
+ zIndex: extractZIndex(styles),
45
+ icons: rawData.light.icons ? extractIcons(rawData.light.icons) : { icons: [], count: 0 },
46
+ fonts: rawData.light.fontData ? extractFonts(rawData.light.fontData) : { fonts: [], systemFonts: [] },
47
+ images: rawData.light.images ? extractImageStyles(rawData.light.images) : { patterns: [], aspectRatios: [] },
38
48
  componentScreenshots: rawData.componentScreenshots || {},
39
- score: null, // populated below
49
+ score: null,
40
50
  };
41
51
 
42
52
  if (rawData.dark) {
@@ -68,3 +78,5 @@ export { compareBrands, formatBrandMatrix, formatBrandMatrixHtml } from './multi
68
78
  export { generateClone } from './clone.js';
69
79
  export { scoreDesignSystem } from './extractors/scoring.js';
70
80
  export { watchSite } from './watch.js';
81
+ export { diffDarkMode } from './darkdiff.js';
82
+ export { applyDesign } from './apply.js';
@@ -0,0 +1,85 @@
1
+ import { extractDesignLanguage } from '../../../../src/index.js';
2
+ import { formatMarkdown } from '../../../../src/formatters/markdown.js';
3
+ import { formatTokens } from '../../../../src/formatters/tokens.js';
4
+ import { formatTailwind } from '../../../../src/formatters/tailwind.js';
5
+ import { formatCssVars } from '../../../../src/formatters/css-vars.js';
6
+ import { formatPreview } from '../../../../src/formatters/preview.js';
7
+ import { formatFigma } from '../../../../src/formatters/figma.js';
8
+ import { formatReactTheme, formatShadcnTheme } from '../../../../src/formatters/theme.js';
9
+ import { nameFromUrl } from '../../../../src/utils.js';
10
+
11
+ export const maxDuration = 60;
12
+ export const dynamic = 'force-dynamic';
13
+
14
+ async function getBrowserOptions() {
15
+ // On Vercel/Lambda, use @sparticuz/chromium; locally, use playwright's bundled browser
16
+ if (process.env.VERCEL || process.env.AWS_LAMBDA_FUNCTION_NAME) {
17
+ const chromium = (await import('@sparticuz/chromium')).default;
18
+ return {
19
+ executablePath: await chromium.executablePath(),
20
+ browserArgs: chromium.args,
21
+ };
22
+ }
23
+ return {};
24
+ }
25
+
26
+ export async function POST(request) {
27
+ try {
28
+ const { url } = await request.json();
29
+
30
+ if (!url) {
31
+ return Response.json({ error: 'URL is required' }, { status: 400 });
32
+ }
33
+
34
+ let targetUrl = url;
35
+ if (!targetUrl.startsWith('http')) targetUrl = `https://${targetUrl}`;
36
+
37
+ // Validate URL
38
+ try {
39
+ new URL(targetUrl);
40
+ } catch {
41
+ return Response.json({ error: 'Invalid URL' }, { status: 400 });
42
+ }
43
+
44
+ const browserOpts = await getBrowserOptions();
45
+ const design = await extractDesignLanguage(targetUrl, browserOpts);
46
+
47
+ const prefix = nameFromUrl(targetUrl);
48
+
49
+ const files = {
50
+ [`${prefix}-design-language.md`]: formatMarkdown(design),
51
+ [`${prefix}-design-tokens.json`]: formatTokens(design),
52
+ [`${prefix}-tailwind.config.js`]: formatTailwind(design),
53
+ [`${prefix}-variables.css`]: formatCssVars(design),
54
+ [`${prefix}-preview.html`]: formatPreview(design),
55
+ [`${prefix}-figma-variables.json`]: formatFigma(design),
56
+ [`${prefix}-theme.js`]: formatReactTheme(design),
57
+ [`${prefix}-shadcn-theme.css`]: formatShadcnTheme(design),
58
+ };
59
+
60
+ const summary = {
61
+ url: design.meta.url,
62
+ title: design.meta.title,
63
+ colors: design.colors.all.length,
64
+ colorList: design.colors.all.slice(0, 20).map(c => c.hex),
65
+ fonts: design.typography.families.map(f => f.name).join(', ') || 'none detected',
66
+ spacingCount: design.spacing.scale.length,
67
+ spacingBase: design.spacing.base,
68
+ shadowCount: design.shadows.values.length,
69
+ radiiCount: design.borders.radii.length,
70
+ componentCount: Object.keys(design.components).length,
71
+ cssVarCount: Object.values(design.variables).reduce((s, v) => s + Object.keys(v).length, 0),
72
+ a11yScore: design.accessibility?.score ?? null,
73
+ a11yFailCount: design.accessibility?.failCount ?? 0,
74
+ score: design.score,
75
+ };
76
+
77
+ return Response.json({ summary, files });
78
+ } catch (err) {
79
+ console.error('Extraction failed:', err);
80
+ return Response.json(
81
+ { error: err.message || 'Extraction failed' },
82
+ { status: 500 }
83
+ );
84
+ }
85
+ }
@@ -0,0 +1,184 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+
5
+ export default function Extractor() {
6
+ const [url, setUrl] = useState('');
7
+ const [loading, setLoading] = useState(false);
8
+ const [error, setError] = useState(null);
9
+ const [result, setResult] = useState(null);
10
+
11
+ const handleExtract = async (e) => {
12
+ e.preventDefault();
13
+ if (!url.trim()) return;
14
+
15
+ setLoading(true);
16
+ setError(null);
17
+ setResult(null);
18
+
19
+ try {
20
+ const res = await fetch('/api/extract', {
21
+ method: 'POST',
22
+ headers: { 'Content-Type': 'application/json' },
23
+ body: JSON.stringify({ url: url.trim() }),
24
+ });
25
+
26
+ const data = await res.json();
27
+ if (!res.ok) throw new Error(data.error || 'Extraction failed');
28
+ setResult(data);
29
+ } catch (err) {
30
+ setError(err.message);
31
+ } finally {
32
+ setLoading(false);
33
+ }
34
+ };
35
+
36
+ const handleDownload = async () => {
37
+ if (!result) return;
38
+
39
+ const JSZip = (await import('jszip')).default;
40
+ const zip = new JSZip();
41
+
42
+ for (const [filename, content] of Object.entries(result.files)) {
43
+ zip.file(filename, content);
44
+ }
45
+
46
+ const blob = await zip.generateAsync({ type: 'blob' });
47
+ const a = document.createElement('a');
48
+ a.href = URL.createObjectURL(blob);
49
+ a.download = `designlang-${new Date().toISOString().slice(0, 10)}.zip`;
50
+ a.click();
51
+ URL.revokeObjectURL(a.href);
52
+ };
53
+
54
+ return (
55
+ <div className="extractor">
56
+ <form onSubmit={handleExtract} className="extractor-form">
57
+ <input
58
+ type="text"
59
+ value={url}
60
+ onChange={(e) => setUrl(e.target.value)}
61
+ placeholder="https://vercel.com"
62
+ className="extractor-input"
63
+ disabled={loading}
64
+ />
65
+ <button type="submit" className="extractor-btn" disabled={loading || !url.trim()}>
66
+ {loading ? 'Extracting...' : 'Extract'}
67
+ </button>
68
+ </form>
69
+
70
+ {loading && (
71
+ <div className="extractor-loading">
72
+ <div className="extractor-spinner" />
73
+ <p>Launching headless browser, crawling DOM, extracting styles...</p>
74
+ <p className="extractor-loading-sub">This takes 15–30 seconds</p>
75
+ </div>
76
+ )}
77
+
78
+ {error && (
79
+ <div className="extractor-error">
80
+ <p>{error}</p>
81
+ <p className="extractor-error-hint">
82
+ Server too slow? Run it locally — it hits different:<br />
83
+ <code>npx designlang {url || 'https://example.com'}</code>
84
+ </p>
85
+ </div>
86
+ )}
87
+
88
+ {result && (
89
+ <div className="extractor-results">
90
+ <div className="extractor-results-header">
91
+ <h3>{result.summary.title || result.summary.url}</h3>
92
+ <button onClick={handleDownload} className="extractor-download">
93
+ Download ZIP ({Object.keys(result.files).length} files)
94
+ </button>
95
+ </div>
96
+
97
+ <div className="extractor-stats-grid">
98
+ <div className="extractor-stat">
99
+ <div className="extractor-stat-value">{result.summary.colors}</div>
100
+ <div className="extractor-stat-label">Colors</div>
101
+ </div>
102
+ <div className="extractor-stat">
103
+ <div className="extractor-stat-value">{result.summary.spacingCount}</div>
104
+ <div className="extractor-stat-label">Spacing Values</div>
105
+ </div>
106
+ <div className="extractor-stat">
107
+ <div className="extractor-stat-value">{result.summary.shadowCount}</div>
108
+ <div className="extractor-stat-label">Shadows</div>
109
+ </div>
110
+ <div className="extractor-stat">
111
+ <div className="extractor-stat-value">{result.summary.componentCount}</div>
112
+ <div className="extractor-stat-label">Components</div>
113
+ </div>
114
+ <div className="extractor-stat">
115
+ <div className="extractor-stat-value">{result.summary.cssVarCount}</div>
116
+ <div className="extractor-stat-label">CSS Variables</div>
117
+ </div>
118
+ <div className="extractor-stat">
119
+ <div className="extractor-stat-value">
120
+ {result.summary.score ? `${result.summary.score.overall}` : '—'}
121
+ </div>
122
+ <div className="extractor-stat-label">
123
+ Design Score {result.summary.score ? `(${result.summary.score.grade})` : ''}
124
+ </div>
125
+ </div>
126
+ </div>
127
+
128
+ {/* Color swatches */}
129
+ {result.summary.colorList && result.summary.colorList.length > 0 && (
130
+ <div className="extractor-section">
131
+ <div className="extractor-section-title">Colors</div>
132
+ <div className="extractor-colors">
133
+ {result.summary.colorList.map((hex, i) => (
134
+ <div key={i} className="extractor-swatch" title={hex}>
135
+ <div className="extractor-swatch-color" style={{ backgroundColor: hex }} />
136
+ <div className="extractor-swatch-hex">{hex}</div>
137
+ </div>
138
+ ))}
139
+ </div>
140
+ </div>
141
+ )}
142
+
143
+ {/* Fonts */}
144
+ {result.summary.fonts && result.summary.fonts !== 'none detected' && (
145
+ <div className="extractor-section">
146
+ <div className="extractor-section-title">Typography</div>
147
+ <div className="extractor-fonts">{result.summary.fonts}</div>
148
+ </div>
149
+ )}
150
+
151
+ {/* Accessibility */}
152
+ {result.summary.a11yScore !== null && (
153
+ <div className="extractor-section">
154
+ <div className="extractor-section-title">Accessibility</div>
155
+ <div className="extractor-a11y">
156
+ <span className={`extractor-a11y-score ${result.summary.a11yScore >= 80 ? 'good' : result.summary.a11yScore >= 50 ? 'ok' : 'bad'}`}>
157
+ {result.summary.a11yScore}% WCAG
158
+ </span>
159
+ {result.summary.a11yFailCount > 0 && (
160
+ <span className="extractor-a11y-fails">{result.summary.a11yFailCount} failing contrast pairs</span>
161
+ )}
162
+ </div>
163
+ </div>
164
+ )}
165
+
166
+ {/* Files list */}
167
+ <div className="extractor-section">
168
+ <div className="extractor-section-title">Output Files</div>
169
+ <div className="extractor-files">
170
+ {Object.entries(result.files).map(([name, content]) => (
171
+ <div key={name} className="extractor-file">
172
+ <span className="extractor-file-name">{name}</span>
173
+ <span className="extractor-file-size">
174
+ {content.length > 1024 ? `${(content.length / 1024).toFixed(1)}KB` : `${content.length}B`}
175
+ </span>
176
+ </div>
177
+ ))}
178
+ </div>
179
+ </div>
180
+ </div>
181
+ )}
182
+ </div>
183
+ );
184
+ }