designlang 4.0.1 → 6.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.
Files changed (40) hide show
  1. package/README.md +66 -5
  2. package/bin/design-extract.js +269 -70
  3. package/package.json +9 -4
  4. package/src/apply.js +65 -0
  5. package/src/config.js +36 -0
  6. package/src/crawler.js +247 -82
  7. package/src/darkdiff.js +65 -0
  8. package/src/extractors/animations.js +76 -8
  9. package/src/extractors/borders.js +40 -5
  10. package/src/extractors/components.js +100 -1
  11. package/src/extractors/fonts.js +82 -0
  12. package/src/extractors/gradients.js +100 -0
  13. package/src/extractors/icons.js +80 -0
  14. package/src/extractors/images.js +76 -0
  15. package/src/extractors/shadows.js +60 -17
  16. package/src/extractors/spacing.js +31 -2
  17. package/src/extractors/variables.js +20 -1
  18. package/src/extractors/zindex.js +65 -0
  19. package/src/formatters/figma.js +66 -47
  20. package/src/formatters/markdown.js +98 -0
  21. package/src/formatters/preview.js +65 -22
  22. package/src/formatters/svelte-theme.js +40 -0
  23. package/src/formatters/tailwind.js +57 -4
  24. package/src/formatters/theme.js +134 -0
  25. package/src/formatters/vue-theme.js +44 -0
  26. package/src/formatters/wordpress.js +84 -0
  27. package/src/history.js +8 -1
  28. package/src/index.js +54 -16
  29. package/src/utils.js +68 -0
  30. package/tests/cli.test.js +34 -0
  31. package/tests/extractors.test.js +661 -0
  32. package/tests/formatters.test.js +477 -0
  33. package/tests/utils.test.js +413 -0
  34. package/website/app/api/extract/route.js +85 -0
  35. package/website/app/components/Extractor.js +184 -0
  36. package/website/app/globals.css +291 -0
  37. package/website/app/page.js +13 -0
  38. package/website/next.config.mjs +10 -1
  39. package/website/package-lock.json +356 -0
  40. package/website/package.json +4 -1
@@ -0,0 +1,413 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ parseColor,
5
+ rgbToHex,
6
+ rgbToHsl,
7
+ colorDistance,
8
+ clusterColors,
9
+ clusterValues,
10
+ parseCSSValue,
11
+ detectScale,
12
+ nameFromUrl,
13
+ isSaturated,
14
+ safeName,
15
+ remToPx,
16
+ pxToRem,
17
+ } from '../src/utils.js';
18
+
19
+ // ── parseColor ──────────────────────────────────────────────────
20
+
21
+ describe('parseColor', () => {
22
+ it('returns null for null/undefined/empty/none/inherit', () => {
23
+ assert.equal(parseColor(null), null);
24
+ assert.equal(parseColor(undefined), null);
25
+ assert.equal(parseColor(''), null);
26
+ assert.equal(parseColor('none'), null);
27
+ assert.equal(parseColor('inherit'), null);
28
+ assert.equal(parseColor('initial'), null);
29
+ assert.equal(parseColor('currentcolor'), null);
30
+ });
31
+
32
+ it('parses 3-digit hex (#RGB)', () => {
33
+ const c = parseColor('#f00');
34
+ assert.deepEqual(c, { r: 255, g: 0, b: 0, a: 1 });
35
+ });
36
+
37
+ it('parses 6-digit hex (#RRGGBB)', () => {
38
+ const c = parseColor('#0066cc');
39
+ assert.deepEqual(c, { r: 0, g: 102, b: 204, a: 1 });
40
+ });
41
+
42
+ it('parses 8-digit hex (#RRGGBBAA)', () => {
43
+ const c = parseColor('#0066cc80');
44
+ assert.equal(c.r, 0);
45
+ assert.equal(c.g, 102);
46
+ assert.equal(c.b, 204);
47
+ assert.ok(Math.abs(c.a - 128 / 255) < 0.01);
48
+ });
49
+
50
+ it('parses 4-digit hex (#RGBA)', () => {
51
+ const c = parseColor('#f008');
52
+ assert.equal(c.r, 255);
53
+ assert.equal(c.g, 0);
54
+ assert.equal(c.b, 0);
55
+ assert.ok(Math.abs(c.a - 0x88 / 255) < 0.01);
56
+ });
57
+
58
+ it('parses rgb(r, g, b)', () => {
59
+ const c = parseColor('rgb(100, 200, 50)');
60
+ assert.deepEqual(c, { r: 100, g: 200, b: 50, a: 1 });
61
+ });
62
+
63
+ it('parses rgba(r, g, b, a)', () => {
64
+ const c = parseColor('rgba(100, 200, 50, 0.5)');
65
+ assert.deepEqual(c, { r: 100, g: 200, b: 50, a: 0.5 });
66
+ });
67
+
68
+ it('parses hsl(h, s%, l%)', () => {
69
+ const c = parseColor('hsl(0, 100%, 50%)');
70
+ assert.equal(c.r, 255);
71
+ assert.equal(c.g, 0);
72
+ assert.equal(c.b, 0);
73
+ assert.equal(c.a, 1);
74
+ });
75
+
76
+ it('parses hsla(h, s%, l%, a)', () => {
77
+ const c = parseColor('hsla(0, 100%, 50%, 0.5)');
78
+ assert.equal(c.r, 255);
79
+ assert.equal(c.a, 0.5);
80
+ });
81
+
82
+ it('parses modern hsl syntax: hsl(210 50% 40%)', () => {
83
+ const c = parseColor('hsl(210 50% 40%)');
84
+ assert.ok(c);
85
+ assert.equal(c.a, 1);
86
+ // hsl(210, 50%, 40%) -> some shade of blue
87
+ assert.ok(c.b > c.r);
88
+ });
89
+
90
+ it('parses modern hsl syntax with alpha: hsl(210 50% 40% / 0.5)', () => {
91
+ const c = parseColor('hsl(210 50% 40% / 0.5)');
92
+ assert.ok(c);
93
+ assert.equal(c.a, 0.5);
94
+ });
95
+
96
+ it('parses oklch(L C H)', () => {
97
+ const c = parseColor('oklch(0.5 0.2 270)');
98
+ assert.ok(c);
99
+ assert.equal(c.a, 1);
100
+ assert.ok(c.r >= 0 && c.r <= 255);
101
+ });
102
+
103
+ it('parses oklch with alpha', () => {
104
+ const c = parseColor('oklch(0.5 0.2 270 / 0.8)');
105
+ assert.ok(c);
106
+ assert.equal(c.a, 0.8);
107
+ });
108
+
109
+ it('parses oklab(L a b)', () => {
110
+ const c = parseColor('oklab(0.5 0.1 -0.1)');
111
+ assert.ok(c);
112
+ assert.equal(c.a, 1);
113
+ assert.ok(c.r >= 0 && c.r <= 255);
114
+ });
115
+
116
+ it('parses color-mix(in srgb, ...)', () => {
117
+ const c = parseColor('color-mix(in srgb, #ff0000 50%, #0000ff)');
118
+ assert.ok(c);
119
+ // Should be roughly halfway between red and blue
120
+ assert.ok(c.r > 100);
121
+ assert.ok(c.b > 100);
122
+ });
123
+
124
+ it('parses named colors', () => {
125
+ const c = parseColor('red');
126
+ assert.deepEqual(c, { r: 255, g: 0, b: 0, a: 1 });
127
+ });
128
+
129
+ it('parses named color white', () => {
130
+ const c = parseColor('white');
131
+ assert.deepEqual(c, { r: 255, g: 255, b: 255, a: 1 });
132
+ });
133
+
134
+ it('parses named colors case-insensitively', () => {
135
+ const c = parseColor('RED');
136
+ assert.deepEqual(c, { r: 255, g: 0, b: 0, a: 1 });
137
+ });
138
+
139
+ it('returns null for unrecognized strings', () => {
140
+ assert.equal(parseColor('not-a-color'), null);
141
+ assert.equal(parseColor('blahblah'), null);
142
+ });
143
+ });
144
+
145
+ // ── rgbToHex ────────────────────────────────────────────────────
146
+
147
+ describe('rgbToHex', () => {
148
+ it('converts black', () => {
149
+ assert.equal(rgbToHex({ r: 0, g: 0, b: 0 }), '#000000');
150
+ });
151
+
152
+ it('converts white', () => {
153
+ assert.equal(rgbToHex({ r: 255, g: 255, b: 255 }), '#ffffff');
154
+ });
155
+
156
+ it('converts a mid color', () => {
157
+ assert.equal(rgbToHex({ r: 0, g: 102, b: 204 }), '#0066cc');
158
+ });
159
+ });
160
+
161
+ // ── rgbToHsl ────────────────────────────────────────────────────
162
+
163
+ describe('rgbToHsl', () => {
164
+ it('converts pure red', () => {
165
+ const hsl = rgbToHsl({ r: 255, g: 0, b: 0 });
166
+ assert.equal(hsl.h, 0);
167
+ assert.equal(hsl.s, 100);
168
+ assert.equal(hsl.l, 50);
169
+ });
170
+
171
+ it('converts pure green (CSS green = 0,128,0)', () => {
172
+ const hsl = rgbToHsl({ r: 0, g: 128, b: 0 });
173
+ assert.equal(hsl.h, 120);
174
+ assert.equal(hsl.l, 25);
175
+ });
176
+
177
+ it('converts gray (achromatic)', () => {
178
+ const hsl = rgbToHsl({ r: 128, g: 128, b: 128 });
179
+ assert.equal(hsl.h, 0);
180
+ assert.equal(hsl.s, 0);
181
+ assert.equal(hsl.l, 50);
182
+ });
183
+
184
+ it('converts white', () => {
185
+ const hsl = rgbToHsl({ r: 255, g: 255, b: 255 });
186
+ assert.equal(hsl.l, 100);
187
+ assert.equal(hsl.s, 0);
188
+ });
189
+ });
190
+
191
+ // ── colorDistance ────────────────────────────────────────────────
192
+
193
+ describe('colorDistance', () => {
194
+ it('returns 0 for identical colors', () => {
195
+ assert.equal(colorDistance({ r: 100, g: 100, b: 100 }, { r: 100, g: 100, b: 100 }), 0);
196
+ });
197
+
198
+ it('returns correct Euclidean distance', () => {
199
+ const d = colorDistance({ r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 });
200
+ assert.ok(Math.abs(d - Math.sqrt(3 * 255 * 255)) < 0.01);
201
+ });
202
+
203
+ it('returns small distance for similar colors', () => {
204
+ const d = colorDistance({ r: 100, g: 100, b: 100 }, { r: 105, g: 100, b: 100 });
205
+ assert.ok(d < 10);
206
+ });
207
+ });
208
+
209
+ // ── clusterColors ───────────────────────────────────────────────
210
+
211
+ describe('clusterColors', () => {
212
+ it('groups similar colors together', () => {
213
+ const colors = [
214
+ { hex: '#ff0000', parsed: { r: 255, g: 0, b: 0 }, count: 10 },
215
+ { hex: '#ff0505', parsed: { r: 255, g: 5, b: 5 }, count: 5 },
216
+ { hex: '#0000ff', parsed: { r: 0, g: 0, b: 255 }, count: 3 },
217
+ ];
218
+ const clusters = clusterColors(colors, 15);
219
+ // Red shades should be grouped, blue separate
220
+ assert.equal(clusters.length, 2);
221
+ assert.equal(clusters[0].count, 15); // red cluster total
222
+ assert.equal(clusters[1].count, 3); // blue cluster
223
+ });
224
+
225
+ it('returns each color as its own cluster when threshold is 0', () => {
226
+ const colors = [
227
+ { hex: '#ff0000', parsed: { r: 255, g: 0, b: 0 }, count: 10 },
228
+ { hex: '#ff0505', parsed: { r: 255, g: 5, b: 5 }, count: 5 },
229
+ ];
230
+ const clusters = clusterColors(colors, 0);
231
+ assert.equal(clusters.length, 2);
232
+ });
233
+
234
+ it('sorts clusters by count descending', () => {
235
+ const colors = [
236
+ { hex: '#0000ff', parsed: { r: 0, g: 0, b: 255 }, count: 3 },
237
+ { hex: '#ff0000', parsed: { r: 255, g: 0, b: 0 }, count: 10 },
238
+ ];
239
+ const clusters = clusterColors(colors, 15);
240
+ assert.ok(clusters[0].count >= clusters[clusters.length - 1].count);
241
+ });
242
+ });
243
+
244
+ // ── clusterValues ───────────────────────────────────────────────
245
+
246
+ describe('clusterValues', () => {
247
+ it('groups nearby numbers', () => {
248
+ const result = clusterValues([4, 5, 8, 16, 17, 32], 2);
249
+ // 4 and 5 cluster -> 4; 8 standalone; 16 and 17 cluster -> 16; 32 standalone
250
+ assert.ok(result.includes(4));
251
+ assert.ok(result.includes(8));
252
+ assert.ok(result.includes(16));
253
+ assert.ok(result.includes(32));
254
+ assert.ok(!result.includes(5));
255
+ assert.ok(!result.includes(17));
256
+ });
257
+
258
+ it('returns all values when threshold is 0', () => {
259
+ const result = clusterValues([1, 2, 3], 0);
260
+ assert.deepEqual(result, [1, 2, 3]);
261
+ });
262
+ });
263
+
264
+ // ── parseCSSValue ───────────────────────────────────────────────
265
+
266
+ describe('parseCSSValue', () => {
267
+ it('parses px values', () => {
268
+ const r = parseCSSValue('16px');
269
+ assert.deepEqual(r, { value: 16, unit: 'px' });
270
+ });
271
+
272
+ it('parses rem values', () => {
273
+ const r = parseCSSValue('1.5rem');
274
+ assert.deepEqual(r, { value: 1.5, unit: 'rem' });
275
+ });
276
+
277
+ it('parses em values', () => {
278
+ const r = parseCSSValue('2em');
279
+ assert.deepEqual(r, { value: 2, unit: 'em' });
280
+ });
281
+
282
+ it('parses percentage values', () => {
283
+ const r = parseCSSValue('100%');
284
+ assert.deepEqual(r, { value: 100, unit: '%' });
285
+ });
286
+
287
+ it('parses unitless numbers', () => {
288
+ const r = parseCSSValue('1.5');
289
+ assert.deepEqual(r, { value: 1.5, unit: '' });
290
+ });
291
+
292
+ it('returns null for normal/auto/none', () => {
293
+ assert.equal(parseCSSValue('normal'), null);
294
+ assert.equal(parseCSSValue('auto'), null);
295
+ assert.equal(parseCSSValue('none'), null);
296
+ });
297
+
298
+ it('returns null for null/undefined', () => {
299
+ assert.equal(parseCSSValue(null), null);
300
+ assert.equal(parseCSSValue(undefined), null);
301
+ });
302
+ });
303
+
304
+ // ── detectScale ─────────────────────────────────────────────────
305
+
306
+ describe('detectScale', () => {
307
+ it('detects a base unit for multiples-of-4 scale', () => {
308
+ // All values are divisible by 2 and 4; the algorithm picks the first
309
+ // candidate (2) that reaches the 60% threshold, so base is 2.
310
+ const result = detectScale([4, 8, 12, 16, 24, 32, 48, 64]);
311
+ assert.ok(result.base !== null);
312
+ assert.ok([2, 4].includes(result.base));
313
+ });
314
+
315
+ it('detects a base unit for multiples-of-8 scale', () => {
316
+ const result = detectScale([8, 16, 24, 32, 40, 48, 56, 64]);
317
+ assert.ok(result.base !== null);
318
+ assert.ok([2, 4, 8].includes(result.base));
319
+ });
320
+
321
+ it('returns null base for fewer than 3 values', () => {
322
+ const result = detectScale([10, 20]);
323
+ assert.equal(result.base, null);
324
+ });
325
+
326
+ it('returns null base for arbitrary values', () => {
327
+ const result = detectScale([3, 7, 11, 19, 37, 53]);
328
+ assert.equal(result.base, null);
329
+ });
330
+ });
331
+
332
+ // ── nameFromUrl ─────────────────────────────────────────────────
333
+
334
+ describe('nameFromUrl', () => {
335
+ it('extracts hostname and makes it safe', () => {
336
+ // safeName replaces dots with hyphens
337
+ assert.equal(nameFromUrl('https://www.example.com/page'), 'example-com');
338
+ });
339
+
340
+ it('strips www prefix', () => {
341
+ const name = nameFromUrl('https://www.google.com');
342
+ assert.ok(!name.startsWith('www'));
343
+ });
344
+
345
+ it('returns unknown-site for invalid URLs', () => {
346
+ assert.equal(nameFromUrl('not a url'), 'unknown-site');
347
+ });
348
+
349
+ it('handles complex domains', () => {
350
+ const name = nameFromUrl('https://my-app.vercel.app/dashboard');
351
+ assert.equal(name, 'my-app-vercel-app');
352
+ });
353
+ });
354
+
355
+ // ── isSaturated ─────────────────────────────────────────────────
356
+
357
+ describe('isSaturated', () => {
358
+ it('returns true for a saturated color', () => {
359
+ assert.equal(isSaturated({ r: 255, g: 0, b: 0 }), true);
360
+ });
361
+
362
+ it('returns false for gray', () => {
363
+ assert.equal(isSaturated({ r: 128, g: 128, b: 128 }), false);
364
+ });
365
+
366
+ it('returns false for white', () => {
367
+ assert.equal(isSaturated({ r: 255, g: 255, b: 255 }), false);
368
+ });
369
+
370
+ it('returns false for black', () => {
371
+ assert.equal(isSaturated({ r: 0, g: 0, b: 0 }), false);
372
+ });
373
+ });
374
+
375
+ // ── safeName ────────────────────────────────────────────────────
376
+
377
+ describe('safeName', () => {
378
+ it('replaces special characters with hyphens', () => {
379
+ assert.equal(safeName('Hello World!'), 'hello-world');
380
+ });
381
+
382
+ it('collapses multiple hyphens', () => {
383
+ assert.equal(safeName('a--b---c'), 'a-b-c');
384
+ });
385
+
386
+ it('trims leading/trailing hyphens', () => {
387
+ assert.equal(safeName('--test--'), 'test');
388
+ });
389
+ });
390
+
391
+ // ── remToPx / pxToRem ──────────────────────────────────────────
392
+
393
+ describe('remToPx', () => {
394
+ it('converts with default base 16', () => {
395
+ assert.equal(remToPx(1), 16);
396
+ assert.equal(remToPx(2), 32);
397
+ });
398
+
399
+ it('converts with custom base', () => {
400
+ assert.equal(remToPx(1, 10), 10);
401
+ });
402
+ });
403
+
404
+ describe('pxToRem', () => {
405
+ it('converts with default base 16', () => {
406
+ assert.equal(pxToRem(16), 1);
407
+ assert.equal(pxToRem(32), 2);
408
+ });
409
+
410
+ it('converts with custom base', () => {
411
+ assert.equal(pxToRem(10, 10), 1);
412
+ });
413
+ });
@@ -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
+ }