designlang 7.2.0 → 9.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.
- package/CHANGELOG.md +69 -0
- package/README.md +154 -13
- package/bin/design-extract.js +94 -1
- package/package.json +9 -3
- package/src/config.js +2 -0
- package/src/crawler.js +55 -6
- package/src/drift.js +137 -0
- package/src/extractors/accessibility.js +44 -1
- package/src/extractors/colors.js +50 -12
- package/src/extractors/component-anatomy.js +123 -0
- package/src/extractors/motion.js +184 -0
- package/src/extractors/scoring.js +49 -30
- package/src/extractors/voice.js +96 -0
- package/src/formatters/markdown.js +88 -0
- package/src/formatters/motion-tokens.js +22 -0
- package/src/index.js +14 -0
- package/src/lint.js +198 -0
- package/src/visual-diff.js +116 -0
- package/.github/FUNDING.yml +0 -1
- package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -62
- package/.github/ISSUE_TEMPLATE/config.yml +0 -8
- package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -28
- package/.github/og-preview.png +0 -0
- package/.github/workflows/manavarya-bot.yml +0 -17
- package/chrome-extension/README.md +0 -41
- package/chrome-extension/icons/favicon.svg +0 -7
- package/chrome-extension/icons/icon-128.png +0 -0
- package/chrome-extension/icons/icon-16.png +0 -0
- package/chrome-extension/icons/icon-32.png +0 -0
- package/chrome-extension/icons/icon-48.png +0 -0
- package/chrome-extension/manifest.json +0 -26
- package/chrome-extension/popup.html +0 -167
- package/chrome-extension/popup.js +0 -59
- package/docs/superpowers/plans/2026-04-18-designlang-v7.md +0 -1121
- package/docs/superpowers/specs/2026-04-18-designlang-v7-design.md +0 -150
- package/docs/superpowers/specs/2026-04-18-website-redesign-design.md +0 -120
- package/docs/superpowers/specs/2026-04-19-designlang-v7-1-design.md +0 -111
- package/tests/cli.test.js +0 -84
- package/tests/cookies.test.js +0 -98
- package/tests/extractors.test.js +0 -792
- package/tests/formatters.test.js +0 -709
- package/tests/interaction-states.test.js +0 -62
- package/tests/mcp.test.js +0 -68
- package/tests/modern-css.test.js +0 -104
- package/tests/routes-reconciliation.test.js +0 -120
- package/tests/utils.test.js +0 -413
- package/tests/wide-gamut.test.js +0 -90
- package/website/.claude/launch.json +0 -11
- package/website/AGENTS.md +0 -5
- package/website/CLAUDE.md +0 -1
- package/website/README.md +0 -36
- package/website/app/api/extract/route.js +0 -245
- package/website/app/components/A11ySlider.js +0 -369
- package/website/app/components/Comparison.js +0 -286
- package/website/app/components/CssHealth.js +0 -243
- package/website/app/components/Extractor.js +0 -184
- package/website/app/components/HeroExtractor.js +0 -455
- package/website/app/components/Marginalia.js +0 -3
- package/website/app/components/McpSection.js +0 -223
- package/website/app/components/PlatformTabs.js +0 -250
- package/website/app/components/RegionsComponents.js +0 -429
- package/website/app/components/Rule.js +0 -13
- package/website/app/components/Specimens.js +0 -237
- package/website/app/components/StructuredData.js +0 -144
- package/website/app/components/TokenBrowser.js +0 -344
- package/website/app/components/token-browser-sample.js +0 -65
- package/website/app/globals.css +0 -505
- package/website/app/icon.svg +0 -7
- package/website/app/layout.js +0 -126
- package/website/app/opengraph-image.js +0 -170
- package/website/app/page.js +0 -399
- package/website/app/robots.js +0 -15
- package/website/app/seo-config.js +0 -82
- package/website/app/sitemap.js +0 -18
- package/website/jsconfig.json +0 -7
- package/website/lib/cache.js +0 -73
- package/website/lib/rate-limit.js +0 -30
- package/website/lib/rate-limit.test.js +0 -55
- package/website/lib/specimens.json +0 -86
- package/website/lib/token-helpers.js +0 -70
- package/website/lib/url-safety.js +0 -103
- package/website/lib/url-safety.test.js +0 -116
- package/website/lib/zip-files.js +0 -15
- package/website/next.config.mjs +0 -15
- package/website/package-lock.json +0 -1353
- package/website/package.json +0 -19
- package/website/public/favicon.svg +0 -7
- package/website/public/logo-specimen.svg +0 -76
- package/website/public/mark.svg +0 -12
- 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 · < 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
|
-
}
|