designlang 7.0.0 → 7.2.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 (77) hide show
  1. package/.github/og-preview.png +0 -0
  2. package/.github/workflows/manavarya-bot.yml +17 -0
  3. package/.vercel/README.txt +11 -0
  4. package/.vercel/project.json +1 -0
  5. package/CHANGELOG.md +29 -0
  6. package/CONTRIBUTING.md +25 -0
  7. package/README.md +38 -11
  8. package/bin/design-extract.js +41 -2
  9. package/chrome-extension/README.md +41 -0
  10. package/chrome-extension/icons/favicon.svg +7 -0
  11. package/chrome-extension/icons/icon-128.png +0 -0
  12. package/chrome-extension/icons/icon-16.png +0 -0
  13. package/chrome-extension/icons/icon-32.png +0 -0
  14. package/chrome-extension/icons/icon-48.png +0 -0
  15. package/chrome-extension/manifest.json +26 -0
  16. package/chrome-extension/popup.html +167 -0
  17. package/chrome-extension/popup.js +59 -0
  18. package/docs/superpowers/specs/2026-04-18-website-redesign-design.md +120 -0
  19. package/docs/superpowers/specs/2026-04-19-designlang-v7-1-design.md +111 -0
  20. package/package.json +1 -1
  21. package/src/config.js +5 -1
  22. package/src/crawler.js +361 -2
  23. package/src/extractors/interaction-states.js +57 -0
  24. package/src/extractors/modern-css.js +100 -0
  25. package/src/extractors/token-sources.js +65 -0
  26. package/src/extractors/wide-gamut.js +47 -0
  27. package/src/formatters/routes-reconciliation.js +160 -0
  28. package/src/index.js +29 -0
  29. package/src/utils/color-gamut.js +82 -0
  30. package/src/utils-cookies.js +73 -0
  31. package/tests/cookies.test.js +98 -0
  32. package/tests/interaction-states.test.js +62 -0
  33. package/tests/modern-css.test.js +104 -0
  34. package/tests/routes-reconciliation.test.js +120 -0
  35. package/tests/wide-gamut.test.js +90 -0
  36. package/website/app/api/extract/route.js +216 -56
  37. package/website/app/components/A11ySlider.js +369 -0
  38. package/website/app/components/Comparison.js +286 -0
  39. package/website/app/components/CssHealth.js +243 -0
  40. package/website/app/components/HeroExtractor.js +455 -0
  41. package/website/app/components/Marginalia.js +3 -0
  42. package/website/app/components/McpSection.js +223 -0
  43. package/website/app/components/PlatformTabs.js +250 -0
  44. package/website/app/components/RegionsComponents.js +429 -0
  45. package/website/app/components/Rule.js +13 -0
  46. package/website/app/components/Specimens.js +237 -0
  47. package/website/app/components/StructuredData.js +144 -0
  48. package/website/app/components/TokenBrowser.js +344 -0
  49. package/website/app/components/token-browser-sample.js +65 -0
  50. package/website/app/globals.css +415 -633
  51. package/website/app/icon.svg +7 -0
  52. package/website/app/layout.js +113 -6
  53. package/website/app/opengraph-image.js +170 -0
  54. package/website/app/page.js +372 -148
  55. package/website/app/robots.js +15 -0
  56. package/website/app/seo-config.js +82 -0
  57. package/website/app/sitemap.js +18 -0
  58. package/website/lib/cache.js +73 -0
  59. package/website/lib/rate-limit.js +30 -0
  60. package/website/lib/rate-limit.test.js +55 -0
  61. package/website/lib/specimens.json +86 -0
  62. package/website/lib/token-helpers.js +70 -0
  63. package/website/lib/url-safety.js +103 -0
  64. package/website/lib/url-safety.test.js +116 -0
  65. package/website/lib/zip-files.js +15 -0
  66. package/website/package-lock.json +85 -0
  67. package/website/package.json +1 -0
  68. package/website/public/favicon.svg +7 -0
  69. package/website/public/logo-specimen.svg +76 -0
  70. package/website/public/mark.svg +12 -0
  71. package/website/public/site.webmanifest +13 -0
  72. package/website/app/favicon.ico +0 -0
  73. package/website/public/file.svg +0 -1
  74. package/website/public/globe.svg +0 -1
  75. package/website/public/next.svg +0 -1
  76. package/website/public/vercel.svg +0 -1
  77. package/website/public/window.svg +0 -1
@@ -0,0 +1,243 @@
1
+ // §04 CSS health — mostly static. Static SVG plot, no ResizeObserver needed
2
+ // (responsive via 100% width + aspect-ratio). Numbers are the PR B smoke
3
+ // sample taken from stripe.com: 14 sheets, 89% unused, 764 !important rules,
4
+ // 9,041 duplicate declarations.
5
+
6
+ // Synthetic sample shaped from the PR B smoke distribution: a mostly low
7
+ // specificity body with a rising-ramp tail — the classic "!important wall"
8
+ // silhouette. X = rule order (0..100), Y = collapsed specificity (a*100+b*10+c).
9
+ const POINTS = [
10
+ [2, 11], [4, 10], [6, 20], [8, 12], [10, 21], [12, 11], [14, 22],
11
+ [16, 20], [18, 12], [20, 22], [22, 30], [24, 20], [26, 21], [28, 31],
12
+ [30, 22], [32, 30], [34, 31], [36, 22], [38, 32], [40, 30], [42, 40],
13
+ [44, 32], [46, 41], [48, 42], [50, 51], [52, 42], [54, 50], [56, 61],
14
+ [58, 52], [60, 71], [62, 81], [64, 92], [66, 112], [68, 121], [70, 141],
15
+ [72, 161], [74, 172], [76, 191], [78, 202], [80, 221], [82, 232],
16
+ [84, 252], [86, 272], [88, 291], [90, 311], [92, 331], [94, 352],
17
+ [96, 372], [98, 391], [100, 412],
18
+ ];
19
+
20
+ const VENDOR_CHIPS = [
21
+ ['-webkit-', '183'],
22
+ ['-moz-', '41'],
23
+ ['-ms-', '7'],
24
+ ['-o-', '2'],
25
+ ['duplicates', '9,041'],
26
+ ];
27
+
28
+ const STATS = [
29
+ ['89%', 'unused css'],
30
+ ['764', '!important rules'],
31
+ ['9,041', 'duplicate declarations'],
32
+ ['14', 'stylesheets analyzed'],
33
+ ];
34
+
35
+ import Rule from './Rule';
36
+ import Marginalia from './Marginalia';
37
+
38
+ function SpecificityPlot() {
39
+ // viewBox 100 x 100 (abstract units), axis gutters inside.
40
+ const maxY = 500;
41
+ const padL = 10;
42
+ const padB = 12;
43
+ const padT = 4;
44
+ const padR = 4;
45
+
46
+ return (
47
+ <svg
48
+ viewBox="0 0 200 125"
49
+ preserveAspectRatio="none"
50
+ role="img"
51
+ aria-label="Specificity scatter plot — rule order vs specificity score"
52
+ style={{
53
+ width: '100%',
54
+ aspectRatio: '16 / 10',
55
+ border: '1px solid var(--ink)',
56
+ background: 'var(--paper)',
57
+ display: 'block',
58
+ }}
59
+ >
60
+ {/* axis lines */}
61
+ <line x1={padL} y1={125 - padB} x2={200 - padR} y2={125 - padB} stroke="var(--ink)" strokeWidth="0.5" />
62
+ <line x1={padL} y1={padT} x2={padL} y2={125 - padB} stroke="var(--ink)" strokeWidth="0.5" />
63
+
64
+ {/* axis labels (mono ~10px visually — SVG units scaled) */}
65
+ <text x={padL} y={124} fontFamily="var(--font-mono)" fontSize="3.2" fill="var(--ink-2)" letterSpacing="0.1">
66
+ rule order →
67
+ </text>
68
+ <text
69
+ x={padL + 1}
70
+ y={padT + 3}
71
+ fontFamily="var(--font-mono)"
72
+ fontSize="3.2"
73
+ fill="var(--ink-2)"
74
+ letterSpacing="0.1"
75
+ >
76
+ ↑ specificity
77
+ </text>
78
+ <text x={200 - padR - 14} y={124} fontFamily="var(--font-mono)" fontSize="3" fill="var(--ink-3)">
79
+ n=50
80
+ </text>
81
+
82
+ {/* points */}
83
+ {POINTS.map(([x, y], i) => {
84
+ const px = padL + (x / 100) * (200 - padL - padR);
85
+ const py = 125 - padB - (Math.min(y, maxY) / maxY) * (125 - padB - padT);
86
+ const outlier = y > 200;
87
+ return (
88
+ <rect
89
+ key={i}
90
+ x={px - 1}
91
+ y={py - 1}
92
+ width="2"
93
+ height="2"
94
+ fill={outlier ? 'var(--accent)' : 'var(--ink)'}
95
+ />
96
+ );
97
+ })}
98
+ </svg>
99
+ );
100
+ }
101
+
102
+ export default function CssHealth() {
103
+ return (
104
+ <>
105
+ <Rule number="04" label="CSS health audit" />
106
+ <div className="with-margin" style={{ marginTop: 'var(--r5)' }}>
107
+ <div>
108
+ <div className="eyebrow" style={{ marginBottom: 'var(--r3)' }}>§04 CSS health</div>
109
+ <h2 className="display" style={{ marginBottom: 'var(--r4)' }}>
110
+ The stylesheet is the problem.
111
+ </h2>
112
+ <p className="prose" style={{ fontSize: 18, maxWidth: '62ch', color: 'var(--ink-2)' }}>
113
+ Most sites ship 40–90% unused CSS, long walls of <code>!important</code> escalations,
114
+ and a specificity graph that rises forever. v7.0 surfaces all of it — not as a
115
+ vanity score, but as exact declaration-level evidence.
116
+ </p>
117
+
118
+ <div
119
+ className="grid-12"
120
+ style={{
121
+ marginTop: 'var(--r7)',
122
+ gap: 'var(--col-gap)',
123
+ alignItems: 'start',
124
+ }}
125
+ >
126
+ {/* Big numerals — span 5 */}
127
+ <div
128
+ style={{
129
+ gridColumn: 'span 5',
130
+ display: 'grid',
131
+ gridTemplateColumns: '1fr 1fr',
132
+ gap: 'var(--r5) var(--r4)',
133
+ }}
134
+ >
135
+ {STATS.map(([n, label]) => (
136
+ <div key={label} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
137
+ <div
138
+ className="display"
139
+ style={{
140
+ fontSize: 'clamp(64px, 7vw, 120px)',
141
+ lineHeight: 0.95,
142
+ fontVariantNumeric: 'tabular-nums',
143
+ fontFeatureSettings: "'tnum' 1",
144
+ letterSpacing: '-0.04em',
145
+ }}
146
+ >
147
+ {n}
148
+ </div>
149
+ <div
150
+ className="mono"
151
+ style={{
152
+ fontSize: 11,
153
+ letterSpacing: '0.14em',
154
+ textTransform: 'uppercase',
155
+ color: 'var(--ink-2)',
156
+ }}
157
+ >
158
+ {label}
159
+ </div>
160
+ </div>
161
+ ))}
162
+ </div>
163
+
164
+ {/* Plot — span 7 */}
165
+ <div style={{ gridColumn: 'span 7' }}>
166
+ <div
167
+ className="mono"
168
+ style={{
169
+ fontSize: 11,
170
+ letterSpacing: '0.14em',
171
+ textTransform: 'uppercase',
172
+ color: 'var(--ink-2)',
173
+ marginBottom: 'var(--r3)',
174
+ display: 'flex',
175
+ justifyContent: 'space-between',
176
+ }}
177
+ >
178
+ <span>specificity distribution</span>
179
+ <span style={{ color: 'var(--accent)' }}>outliers: specificity &gt; 200</span>
180
+ </div>
181
+ <SpecificityPlot />
182
+ <div
183
+ className="mono"
184
+ style={{
185
+ fontSize: 11,
186
+ color: 'var(--ink-3)',
187
+ marginTop: 'var(--r3)',
188
+ }}
189
+ >
190
+ collapsed score: a×100 + b×10 + c. rising tail = accumulated !important wall.
191
+ </div>
192
+ </div>
193
+ </div>
194
+
195
+ {/* Caption strip — vendor prefix chips */}
196
+ <div
197
+ style={{
198
+ marginTop: 'var(--r7)',
199
+ display: 'flex',
200
+ flexWrap: 'wrap',
201
+ gap: 'var(--r3)',
202
+ borderTop: 'var(--hair)',
203
+ paddingTop: 'var(--r4)',
204
+ }}
205
+ >
206
+ {VENDOR_CHIPS.map(([k, v]) => (
207
+ <span
208
+ key={k}
209
+ className="mono"
210
+ style={{
211
+ display: 'inline-flex',
212
+ alignItems: 'baseline',
213
+ gap: 8,
214
+ padding: '3px 8px',
215
+ border: '1px solid var(--ink)',
216
+ fontSize: 11,
217
+ letterSpacing: '0.04em',
218
+ }}
219
+ >
220
+ <span style={{ color: 'var(--ink-2)' }}>{k}</span>
221
+ <span style={{ fontVariantNumeric: 'tabular-nums' }}>{v}</span>
222
+ </span>
223
+ ))}
224
+ </div>
225
+ </div>
226
+
227
+ <Marginalia>
228
+ <div>additive scoring</div>
229
+ <p style={{ marginTop: 6 }}>
230
+ Tracked additively: existing score fields kept for back-compat; CSS health joins as a new
231
+ dimension.
232
+ </p>
233
+ <hr style={{ margin: '12px 0', border: 0, borderTop: '1px solid var(--ink-3)' }} />
234
+ <div>works on any site</div>
235
+ <p className="foot" style={{ marginTop: 6 }}>
236
+ Flags rising specificity, zombie declarations, and abandoned <code>!important</code> walls —
237
+ with exact selector provenance.
238
+ </p>
239
+ </Marginalia>
240
+ </div>
241
+ </>
242
+ );
243
+ }
@@ -0,0 +1,455 @@
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
+ }
@@ -0,0 +1,3 @@
1
+ export default function Marginalia({ children }) {
2
+ return <aside className="marginalia">{children}</aside>;
3
+ }