designlang 7.1.0 → 8.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 (82) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +46 -4
  3. package/bin/design-extract.js +28 -2
  4. package/package.json +1 -1
  5. package/src/config.js +4 -1
  6. package/src/crawler.js +376 -6
  7. package/src/extractors/accessibility.js +44 -1
  8. package/src/extractors/colors.js +50 -12
  9. package/src/extractors/interaction-states.js +57 -0
  10. package/src/extractors/modern-css.js +100 -0
  11. package/src/extractors/scoring.js +49 -30
  12. package/src/extractors/token-sources.js +65 -0
  13. package/src/extractors/wide-gamut.js +47 -0
  14. package/src/formatters/routes-reconciliation.js +160 -0
  15. package/src/index.js +29 -0
  16. package/src/utils/color-gamut.js +82 -0
  17. package/.github/FUNDING.yml +0 -1
  18. package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -62
  19. package/.github/ISSUE_TEMPLATE/config.yml +0 -8
  20. package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -28
  21. package/chrome-extension/README.md +0 -41
  22. package/chrome-extension/icons/favicon.svg +0 -7
  23. package/chrome-extension/icons/icon-128.png +0 -0
  24. package/chrome-extension/icons/icon-16.png +0 -0
  25. package/chrome-extension/icons/icon-32.png +0 -0
  26. package/chrome-extension/icons/icon-48.png +0 -0
  27. package/chrome-extension/manifest.json +0 -26
  28. package/chrome-extension/popup.html +0 -167
  29. package/chrome-extension/popup.js +0 -59
  30. package/docs/superpowers/plans/2026-04-18-designlang-v7.md +0 -1121
  31. package/docs/superpowers/specs/2026-04-18-designlang-v7-design.md +0 -150
  32. package/docs/superpowers/specs/2026-04-18-website-redesign-design.md +0 -120
  33. package/docs/superpowers/specs/2026-04-19-designlang-v7-1-design.md +0 -111
  34. package/tests/cli.test.js +0 -84
  35. package/tests/cookies.test.js +0 -98
  36. package/tests/extractors.test.js +0 -792
  37. package/tests/formatters.test.js +0 -709
  38. package/tests/mcp.test.js +0 -68
  39. package/tests/utils.test.js +0 -413
  40. package/website/.claude/launch.json +0 -11
  41. package/website/AGENTS.md +0 -5
  42. package/website/CLAUDE.md +0 -1
  43. package/website/README.md +0 -36
  44. package/website/app/api/extract/route.js +0 -245
  45. package/website/app/components/A11ySlider.js +0 -369
  46. package/website/app/components/Comparison.js +0 -286
  47. package/website/app/components/CssHealth.js +0 -243
  48. package/website/app/components/Extractor.js +0 -184
  49. package/website/app/components/HeroExtractor.js +0 -455
  50. package/website/app/components/Marginalia.js +0 -3
  51. package/website/app/components/McpSection.js +0 -223
  52. package/website/app/components/PlatformTabs.js +0 -250
  53. package/website/app/components/RegionsComponents.js +0 -429
  54. package/website/app/components/Rule.js +0 -13
  55. package/website/app/components/Specimens.js +0 -237
  56. package/website/app/components/StructuredData.js +0 -144
  57. package/website/app/components/TokenBrowser.js +0 -344
  58. package/website/app/components/token-browser-sample.js +0 -65
  59. package/website/app/globals.css +0 -505
  60. package/website/app/icon.svg +0 -7
  61. package/website/app/layout.js +0 -126
  62. package/website/app/opengraph-image.js +0 -170
  63. package/website/app/page.js +0 -352
  64. package/website/app/robots.js +0 -15
  65. package/website/app/seo-config.js +0 -82
  66. package/website/app/sitemap.js +0 -18
  67. package/website/jsconfig.json +0 -7
  68. package/website/lib/cache.js +0 -73
  69. package/website/lib/rate-limit.js +0 -30
  70. package/website/lib/rate-limit.test.js +0 -55
  71. package/website/lib/specimens.json +0 -86
  72. package/website/lib/token-helpers.js +0 -70
  73. package/website/lib/url-safety.js +0 -103
  74. package/website/lib/url-safety.test.js +0 -116
  75. package/website/lib/zip-files.js +0 -15
  76. package/website/next.config.mjs +0 -15
  77. package/website/package-lock.json +0 -1353
  78. package/website/package.json +0 -19
  79. package/website/public/favicon.svg +0 -7
  80. package/website/public/logo-specimen.svg +0 -76
  81. package/website/public/mark.svg +0 -12
  82. package/website/public/site.webmanifest +0 -13
@@ -1,184 +0,0 @@
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
- }
@@ -1,455 +0,0 @@
1
- 'use client';
2
-
3
- import { useCallback, useEffect, useRef, useState } from 'react';
4
- import Marginalia from './Marginalia';
5
-
6
- const STAGE_LABEL = {
7
- crawl: 'walking DOM',
8
- colors: 'resolving palette',
9
- typography: 'reading type',
10
- spacing: 'measuring rhythm',
11
- shadows: 'catching shadows',
12
- borders: 'tracing radii',
13
- components: 'clustering components',
14
- regions: 'naming regions',
15
- a11y: 'auditing contrast',
16
- score: 'scoring system',
17
- };
18
-
19
- const MAX_SWATCHES = 24;
20
- const MAX_DIMENSIONS = 12;
21
-
22
- export default function HeroExtractor() {
23
- const [status, setStatus] = useState('idle'); // idle | streaming | done | error
24
- const [stage, setStage] = useState(null);
25
- const [cached, setCached] = useState(false);
26
- const [swatches, setSwatches] = useState([]);
27
- const [fontSample, setFontSample] = useState(null);
28
- const [dimensions, setDimensions] = useState([]);
29
- const [summary, setSummary] = useState(null);
30
- const [files, setFiles] = useState(null);
31
- const [errorMsg, setErrorMsg] = useState(null);
32
- const [rateLimitMsg, setRateLimitMsg] = useState(null);
33
- const [downloadBusy, setDownloadBusy] = useState(false);
34
- const [copied, setCopied] = useState(false);
35
- const inputRef = useRef(null);
36
-
37
- // Accept ?url= query param (Chrome extension handoff, deep links). Only
38
- // applied once on mount; the extension prefills the input and the user still
39
- // controls submission.
40
- useEffect(() => {
41
- if (typeof window === 'undefined') return;
42
- const params = new URLSearchParams(window.location.search);
43
- const incoming = params.get('url');
44
- if (!incoming || !inputRef.current) return;
45
- try {
46
- const parsed = new URL(incoming);
47
- if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
48
- inputRef.current.value = parsed.toString();
49
- }
50
- } catch {
51
- // Ignore malformed URLs silently — user still sees the default placeholder.
52
- }
53
- }, []);
54
-
55
- const reset = useCallback(() => {
56
- setStage(null);
57
- setCached(false);
58
- setSwatches([]);
59
- setFontSample(null);
60
- setDimensions([]);
61
- setSummary(null);
62
- setFiles(null);
63
- setErrorMsg(null);
64
- setRateLimitMsg(null);
65
- setCopied(false);
66
- }, []);
67
-
68
- const handleEvent = useCallback((event) => {
69
- switch (event.type) {
70
- case 'cache':
71
- setCached(true);
72
- break;
73
- case 'stage':
74
- setStage(event.name);
75
- break;
76
- case 'token': {
77
- if (event.$type === 'color' && typeof event.value === 'string' && event.value.startsWith('#')) {
78
- setSwatches((prev) => {
79
- if (prev.length >= MAX_SWATCHES) return prev;
80
- if (prev.some((s) => s.path === event.path)) return prev;
81
- return [...prev, { path: event.path, hex: event.value }];
82
- });
83
- } else if (event.$type === 'fontFamily' && typeof event.value === 'string') {
84
- setFontSample((prev) => prev || event.value);
85
- } else if (event.$type === 'dimension' && typeof event.value === 'string') {
86
- const px = parseFloat(event.value);
87
- if (!Number.isNaN(px)) {
88
- setDimensions((prev) => {
89
- if (prev.length >= MAX_DIMENSIONS) return prev;
90
- return [...prev, { path: event.path, px, raw: event.value }];
91
- });
92
- }
93
- }
94
- break;
95
- }
96
- case 'summary':
97
- setSummary(event.summary);
98
- break;
99
- case 'files':
100
- setFiles(event.files);
101
- setStatus('done');
102
- break;
103
- case 'error':
104
- setErrorMsg(event.error || 'Extraction failed');
105
- setStatus('error');
106
- break;
107
- }
108
- }, []);
109
-
110
- const handleSubmit = useCallback(async (e) => {
111
- e.preventDefault();
112
- if (status === 'streaming') return;
113
- reset();
114
- setStatus('streaming');
115
-
116
- const url = inputRef.current?.value?.trim() || '';
117
- let res;
118
- try {
119
- res = await fetch('/api/extract', {
120
- method: 'POST',
121
- headers: { 'content-type': 'application/json' },
122
- body: JSON.stringify({ url }),
123
- });
124
- } catch (err) {
125
- setErrorMsg('Network error — check your connection.');
126
- setStatus('error');
127
- return;
128
- }
129
-
130
- if (!res.ok) {
131
- let body = null;
132
- try { body = await res.json(); } catch {}
133
- if (res.status === 429) {
134
- setRateLimitMsg(body?.error || 'Rate limit reached. Try again in 24h.');
135
- } else if (res.status === 400) {
136
- setErrorMsg(body?.error || 'Invalid URL.');
137
- } else {
138
- setErrorMsg(body?.error || 'Extraction failed. Try another URL.');
139
- }
140
- setStatus('error');
141
- return;
142
- }
143
-
144
- if (!res.body) {
145
- setErrorMsg('Browser does not support streaming responses.');
146
- setStatus('error');
147
- return;
148
- }
149
-
150
- const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
151
- let buffer = '';
152
- try {
153
- while (true) {
154
- const { value, done } = await reader.read();
155
- if (done) break;
156
- buffer += value;
157
- let nl;
158
- while ((nl = buffer.indexOf('\n')) !== -1) {
159
- const line = buffer.slice(0, nl).trim();
160
- buffer = buffer.slice(nl + 1);
161
- if (!line) continue;
162
- try {
163
- handleEvent(JSON.parse(line));
164
- } catch {
165
- // Ignore partial / malformed lines — defensive only.
166
- }
167
- }
168
- }
169
- if (buffer.trim()) {
170
- try { handleEvent(JSON.parse(buffer.trim())); } catch {}
171
- }
172
- } catch (err) {
173
- setErrorMsg('Stream interrupted. Try another URL.');
174
- setStatus('error');
175
- }
176
- }, [status, handleEvent, reset]);
177
-
178
- const handleDownload = useCallback(async () => {
179
- if (!files || downloadBusy) return;
180
- setDownloadBusy(true);
181
- try {
182
- const { zipFilesToUrl } = await import('../../lib/zip-files.js');
183
- const { url, filename } = await zipFilesToUrl(files, {
184
- name: `designlang-${new Date().toISOString().slice(0, 10)}`,
185
- });
186
- const a = document.createElement('a');
187
- a.href = url;
188
- a.download = filename;
189
- a.click();
190
- setTimeout(() => URL.revokeObjectURL(url), 1000);
191
- } finally {
192
- setDownloadBusy(false);
193
- }
194
- }, [files, downloadBusy]);
195
-
196
- const handleCopyMarkdown = useCallback(async () => {
197
- if (!files) return;
198
- const mdKey = Object.keys(files).find((k) => k.endsWith('-design-language.md'));
199
- if (!mdKey) return;
200
- try {
201
- await navigator.clipboard.writeText(files[mdKey]);
202
- setCopied(true);
203
- setTimeout(() => setCopied(false), 1800);
204
- } catch {
205
- // no-op
206
- }
207
- }, [files]);
208
-
209
- const stageLabel = stage ? STAGE_LABEL[stage] || stage : null;
210
- const streaming = status === 'streaming';
211
- const disabled = streaming;
212
-
213
- return (
214
- <>
215
- <div className="with-margin" style={{ marginTop: 'var(--r8)' }}>
216
- <form
217
- onSubmit={handleSubmit}
218
- style={{
219
- display: 'flex',
220
- alignItems: 'stretch',
221
- gap: 0,
222
- maxWidth: 720,
223
- border: 'var(--hair)',
224
- }}
225
- >
226
- <label htmlFor="url" style={{ display: 'none' }}>URL</label>
227
- <input
228
- id="url"
229
- ref={inputRef}
230
- name="url"
231
- type="url"
232
- placeholder="https://stripe.com"
233
- defaultValue="https://stripe.com"
234
- disabled={disabled}
235
- className="mono"
236
- style={{
237
- flex: 1,
238
- padding: '18px 20px',
239
- fontSize: 16,
240
- color: 'var(--ink)',
241
- background: 'transparent',
242
- borderRight: 'var(--hair)',
243
- }}
244
- />
245
- <button
246
- type="submit"
247
- className="cta"
248
- disabled={disabled}
249
- style={{ boxShadow: 'none' }}
250
- >
251
- {streaming ? 'Extracting…' : 'Extract'}
252
- <span style={{ fontFamily: 'var(--font-mono)', color: 'var(--ink-3)' }}>↵</span>
253
- </button>
254
- </form>
255
-
256
- <Marginalia>
257
- <div>status</div>
258
- <div style={{ minHeight: '1.5em' }}>
259
- {status === 'idle' && <code style={{ color: 'var(--ink-3)' }}>awaiting URL</code>}
260
- {streaming && stageLabel && (
261
- <code>§ {stageLabel}{cached ? ' (cached)' : ''}</code>
262
- )}
263
- {streaming && !stageLabel && <code>§ opening stream</code>}
264
- {status === 'done' && <code style={{ color: 'var(--accent)' }}>complete{cached ? ' (cached)' : ''}</code>}
265
- {status === 'error' && <code style={{ color: 'var(--accent)' }}>halted</code>}
266
- </div>
267
- {cached && (
268
- <>
269
- <hr style={{ margin: '12px 0', border: 0, borderTop: '1px solid var(--ink-3)' }} />
270
- <span className="chip" style={{ fontSize: 10 }}>cached · &lt; 24h</span>
271
- </>
272
- )}
273
- </Marginalia>
274
- </div>
275
-
276
- {errorMsg && (
277
- <p className="mono" role="alert" style={{ marginTop: 'var(--r4)', fontSize: 12, color: 'var(--accent)' }}>
278
- {errorMsg}{' '}
279
- {status === 'error' && (
280
- <button
281
- type="button"
282
- onClick={reset}
283
- style={{ textDecoration: 'underline', color: 'inherit', marginLeft: 8 }}
284
- >
285
- Try another URL
286
- </button>
287
- )}
288
- </p>
289
- )}
290
-
291
- {rateLimitMsg && (
292
- <p className="mono" role="alert" style={{ marginTop: 'var(--r4)', fontSize: 12, color: 'var(--accent)' }}>
293
- {rateLimitMsg}
294
- </p>
295
- )}
296
-
297
- <p className="mono" style={{ marginTop: 14, fontSize: 11, color: 'var(--ink-3)' }}>
298
- Free, rate-limited to 3 extractions per day. Private IPs rejected. No accounts.
299
- </p>
300
-
301
- {/* ── Live paint ─────────────────────────────────────── */}
302
- {(swatches.length > 0 || fontSample || dimensions.length > 0 || summary) && (
303
- <div style={{ marginTop: 'var(--r7)', display: 'grid', gap: 'var(--r5)' }}>
304
- {swatches.length > 0 && (
305
- <div>
306
- <div className="section-label" style={{ marginBottom: 'var(--r3)' }}>
307
- <span>palette</span>
308
- </div>
309
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--r3)' }}>
310
- {swatches.map((s, i) => (
311
- <div
312
- key={s.path}
313
- style={{
314
- opacity: 0,
315
- animation: `designlang-fade-in 200ms ease-out forwards`,
316
- animationDelay: `${i * 40}ms`,
317
- }}
318
- >
319
- <div
320
- style={{
321
- width: 40,
322
- height: 40,
323
- background: s.hex,
324
- border: 'var(--hair)',
325
- }}
326
- />
327
- <div
328
- className="mono"
329
- style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 4 }}
330
- >
331
- {s.hex}
332
- </div>
333
- </div>
334
- ))}
335
- </div>
336
- </div>
337
- )}
338
-
339
- {fontSample && (
340
- <div>
341
- <div className="section-label" style={{ marginBottom: 'var(--r3)' }}>
342
- <span>type</span>
343
- </div>
344
- <div
345
- style={{
346
- fontFamily: `${fontSample}, system-ui, sans-serif`,
347
- fontSize: 32,
348
- lineHeight: 1.2,
349
- letterSpacing: '-0.01em',
350
- }}
351
- >
352
- Aa Bb Cc 123
353
- </div>
354
- <div className="mono" style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 4 }}>
355
- {fontSample}
356
- </div>
357
- </div>
358
- )}
359
-
360
- {dimensions.length > 0 && (
361
- <div>
362
- <div className="section-label" style={{ marginBottom: 'var(--r3)' }}>
363
- <span>scale</span>
364
- </div>
365
- <div style={{ display: 'flex', alignItems: 'flex-end', gap: 8 }}>
366
- {dimensions.map((d, i) => (
367
- <div
368
- key={d.path}
369
- title={`${d.path} — ${d.raw}`}
370
- style={{
371
- width: 14,
372
- height: Math.min(Math.max(d.px, 4), 120),
373
- background: 'var(--ink)',
374
- opacity: 0,
375
- animation: `designlang-fade-in 200ms ease-out forwards`,
376
- animationDelay: `${i * 30}ms`,
377
- }}
378
- />
379
- ))}
380
- </div>
381
- </div>
382
- )}
383
-
384
- {summary && (
385
- <div
386
- style={{
387
- display: 'grid',
388
- gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
389
- gap: 'var(--r5)',
390
- borderTop: 'var(--hair)',
391
- paddingTop: 'var(--r5)',
392
- }}
393
- >
394
- <Numeral value={summary.colors} label="colors" />
395
- <Numeral value={summary.spacingCount} label="spacing" />
396
- <Numeral value={summary.shadowCount} label="shadows" />
397
- <Numeral value={summary.componentCount} label="components" />
398
- <Numeral
399
- value={summary.score?.overall ?? '—'}
400
- label={`score ${summary.score?.grade ? `(${summary.score.grade})` : ''}`}
401
- />
402
- </div>
403
- )}
404
- </div>
405
- )}
406
-
407
- {files && (
408
- <div style={{ marginTop: 'var(--r6)', display: 'flex', gap: 'var(--r3)', flexWrap: 'wrap' }}>
409
- <button
410
- type="button"
411
- onClick={handleDownload}
412
- className="cta"
413
- disabled={downloadBusy}
414
- >
415
- {downloadBusy ? 'Zipping…' : `Download all (.zip) — ${Object.keys(files).length} files`}
416
- </button>
417
- <button
418
- type="button"
419
- onClick={handleCopyMarkdown}
420
- className="cta"
421
- style={{ background: 'var(--paper)', color: 'var(--ink)', boxShadow: 'none' }}
422
- >
423
- {copied ? 'Copied' : 'Copy markdown'}
424
- </button>
425
- </div>
426
- )}
427
-
428
- <style jsx>{`
429
- @keyframes designlang-fade-in {
430
- from { opacity: 0; transform: translateY(4px); }
431
- to { opacity: 1; transform: translateY(0); }
432
- }
433
- @media (prefers-reduced-motion: reduce) {
434
- * { animation: none !important; }
435
- }
436
- `}</style>
437
- </>
438
- );
439
- }
440
-
441
- function Numeral({ value, label }) {
442
- return (
443
- <div>
444
- <div
445
- className="display"
446
- style={{ fontSize: 'clamp(32px, 5vw, 56px)', lineHeight: 1 }}
447
- >
448
- {value}
449
- </div>
450
- <div className="mono" style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 6, textTransform: 'uppercase', letterSpacing: '0.1em' }}>
451
- {label}
452
- </div>
453
- </div>
454
- );
455
- }
@@ -1,3 +0,0 @@
1
- export default function Marginalia({ children }) {
2
- return <aside className="marginalia">{children}</aside>;
3
- }