designlang 6.0.0 → 7.1.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 (92) hide show
  1. package/.github/FUNDING.yml +1 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.yml +62 -0
  3. package/.github/ISSUE_TEMPLATE/config.yml +8 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.yml +28 -0
  5. package/.vercel/README.txt +11 -0
  6. package/.vercel/project.json +1 -0
  7. package/CHANGELOG.md +58 -0
  8. package/CONTRIBUTING.md +25 -0
  9. package/README.md +120 -8
  10. package/bin/design-extract.js +106 -3
  11. package/chrome-extension/README.md +41 -0
  12. package/chrome-extension/icons/favicon.svg +7 -0
  13. package/chrome-extension/icons/icon-128.png +0 -0
  14. package/chrome-extension/icons/icon-16.png +0 -0
  15. package/chrome-extension/icons/icon-32.png +0 -0
  16. package/chrome-extension/icons/icon-48.png +0 -0
  17. package/chrome-extension/manifest.json +26 -0
  18. package/chrome-extension/popup.html +167 -0
  19. package/chrome-extension/popup.js +59 -0
  20. package/docs/superpowers/plans/2026-04-18-designlang-v7.md +1121 -0
  21. package/docs/superpowers/specs/2026-04-18-designlang-v7-design.md +150 -0
  22. package/docs/superpowers/specs/2026-04-18-website-redesign-design.md +120 -0
  23. package/docs/superpowers/specs/2026-04-19-designlang-v7-1-design.md +111 -0
  24. package/package.json +5 -4
  25. package/src/config.js +26 -0
  26. package/src/crawler.js +136 -2
  27. package/src/extractors/a11y-remediation.js +47 -0
  28. package/src/extractors/component-clusters.js +39 -0
  29. package/src/extractors/css-health.js +151 -0
  30. package/src/extractors/scoring.js +20 -1
  31. package/src/extractors/semantic-regions.js +44 -0
  32. package/src/extractors/stack-fingerprint.js +88 -0
  33. package/src/formatters/_token-ref.js +44 -0
  34. package/src/formatters/agent-rules.js +116 -0
  35. package/src/formatters/android-compose.js +164 -0
  36. package/src/formatters/dtcg-tokens.js +175 -0
  37. package/src/formatters/flutter-dart.js +130 -0
  38. package/src/formatters/ios-swiftui.js +161 -0
  39. package/src/formatters/markdown.js +25 -0
  40. package/src/formatters/wordpress.js +183 -0
  41. package/src/index.js +30 -0
  42. package/src/mcp/resources.js +64 -0
  43. package/src/mcp/server.js +110 -0
  44. package/src/mcp/tools.js +149 -0
  45. package/src/utils-cookies.js +73 -0
  46. package/tests/cli.test.js +50 -0
  47. package/tests/cookies.test.js +98 -0
  48. package/tests/extractors.test.js +131 -0
  49. package/tests/formatters.test.js +232 -0
  50. package/tests/mcp.test.js +68 -0
  51. package/website/app/api/extract/route.js +216 -56
  52. package/website/app/components/A11ySlider.js +369 -0
  53. package/website/app/components/Comparison.js +286 -0
  54. package/website/app/components/CssHealth.js +243 -0
  55. package/website/app/components/HeroExtractor.js +455 -0
  56. package/website/app/components/Marginalia.js +3 -0
  57. package/website/app/components/McpSection.js +223 -0
  58. package/website/app/components/PlatformTabs.js +250 -0
  59. package/website/app/components/RegionsComponents.js +429 -0
  60. package/website/app/components/Rule.js +13 -0
  61. package/website/app/components/Specimens.js +237 -0
  62. package/website/app/components/StructuredData.js +144 -0
  63. package/website/app/components/TokenBrowser.js +344 -0
  64. package/website/app/components/token-browser-sample.js +65 -0
  65. package/website/app/globals.css +415 -633
  66. package/website/app/icon.svg +7 -0
  67. package/website/app/layout.js +113 -6
  68. package/website/app/opengraph-image.js +170 -0
  69. package/website/app/page.js +325 -148
  70. package/website/app/robots.js +15 -0
  71. package/website/app/seo-config.js +82 -0
  72. package/website/app/sitemap.js +18 -0
  73. package/website/lib/cache.js +73 -0
  74. package/website/lib/rate-limit.js +30 -0
  75. package/website/lib/rate-limit.test.js +55 -0
  76. package/website/lib/specimens.json +86 -0
  77. package/website/lib/token-helpers.js +70 -0
  78. package/website/lib/url-safety.js +103 -0
  79. package/website/lib/url-safety.test.js +116 -0
  80. package/website/lib/zip-files.js +15 -0
  81. package/website/package-lock.json +85 -0
  82. package/website/package.json +1 -0
  83. package/website/public/favicon.svg +7 -0
  84. package/website/public/logo-specimen.svg +76 -0
  85. package/website/public/mark.svg +12 -0
  86. package/website/public/site.webmanifest +13 -0
  87. package/website/app/favicon.ico +0 -0
  88. package/website/public/file.svg +0 -1
  89. package/website/public/globe.svg +0 -1
  90. package/website/public/next.svg +0 -1
  91. package/website/public/vercel.svg +0 -1
  92. package/website/public/window.svg +0 -1
@@ -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
+ }
@@ -0,0 +1,223 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import Marginalia from './Marginalia';
5
+
6
+ const CURSOR_RULE = `---
7
+ description: Design system extracted from https://stripe.com — use these tokens, do not invent new ones.
8
+ globs: **/*.{ts,tsx,jsx,js,css,scss,html,vue,svelte,swift,kt,dart,php}
9
+ alwaysApply: true
10
+ ---
11
+
12
+ # Design system reference
13
+
14
+ Source: https://stripe.com
15
+ Extracted by designlang v7.0.0 on 2026-04-18T12:00:00Z
16
+
17
+ ## Semantic tokens (use these)
18
+ - color.action.primary: #533afd
19
+ - color.surface.default: #ffffff
20
+ - color.text.body: #0a2540
21
+ - radius.control: 8px
22
+ - typography.body.fontFamily: sohne-var, Helvetica Neue, Arial, sans-serif
23
+
24
+ ## How to use
25
+ - Prefer \`semantic.*\` tokens over \`primitive.*\`.
26
+ - Never invent new tokens or hex values; reuse the ones above.
27
+ - When a value is missing, pick the closest existing semantic token and flag the gap.
28
+ - Reference tokens by their dotted path (e.g. \`semantic.color.action.primary\`).
29
+ `;
30
+
31
+ // The transcript. Each step is either typed (user input) or printed (server output).
32
+ const TRANSCRIPT = [
33
+ { kind: 'typed', text: '$ designlang mcp --output-dir ./design-extract-output' },
34
+ { kind: 'print', text: '{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"designlang","version":"7.0.0"}}}' },
35
+ { kind: 'typed', text: '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_tokens","arguments":{"query":"action.primary"}}}' },
36
+ { kind: 'print', text: '{"jsonrpc":"2.0","id":2,"result":{"matches":[{"path":"semantic.color.action.primary","$type":"color","$value":"#533afd"}]}}' },
37
+ ];
38
+
39
+ function Terminal({ reduced }) {
40
+ const [lines, setLines] = useState(() =>
41
+ reduced ? TRANSCRIPT.map((t) => ({ ...t, rendered: t.text })) : []
42
+ );
43
+ const containerRef = useRef(null);
44
+ const [inView, setInView] = useState(false);
45
+
46
+ // IntersectionObserver to pause when offscreen.
47
+ useEffect(() => {
48
+ if (reduced) return;
49
+ if (!containerRef.current) return;
50
+ const el = containerRef.current;
51
+ const io = new IntersectionObserver(
52
+ ([entry]) => setInView(entry.isIntersecting),
53
+ { threshold: 0.25 }
54
+ );
55
+ io.observe(el);
56
+ return () => io.disconnect();
57
+ }, [reduced]);
58
+
59
+ // Typewriter runner.
60
+ useEffect(() => {
61
+ if (reduced) return;
62
+ if (!inView) return;
63
+ const controller = new AbortController();
64
+ const signal = controller.signal;
65
+
66
+ async function run() {
67
+ while (!signal.aborted) {
68
+ setLines([]);
69
+ for (let i = 0; i < TRANSCRIPT.length; i++) {
70
+ if (signal.aborted) return;
71
+ const step = TRANSCRIPT[i];
72
+ if (step.kind === 'typed') {
73
+ // ~55 cps ≈ 18ms per char
74
+ for (let j = 0; j <= step.text.length; j++) {
75
+ if (signal.aborted) return;
76
+ await wait(18, signal);
77
+ setLines((prev) => {
78
+ const next = [...prev];
79
+ next[i] = { ...step, rendered: step.text.slice(0, j) };
80
+ return next;
81
+ });
82
+ }
83
+ await wait(280, signal);
84
+ } else {
85
+ await wait(220, signal);
86
+ if (signal.aborted) return;
87
+ setLines((prev) => {
88
+ const next = [...prev];
89
+ next[i] = { ...step, rendered: step.text };
90
+ return next;
91
+ });
92
+ }
93
+ }
94
+ await wait(2000, signal);
95
+ }
96
+ }
97
+
98
+ run().catch(() => {});
99
+ return () => controller.abort();
100
+ }, [inView, reduced]);
101
+
102
+ return (
103
+ <div
104
+ ref={containerRef}
105
+ aria-label="designlang MCP terminal transcript"
106
+ style={{
107
+ background: 'var(--ink)',
108
+ color: 'var(--paper)',
109
+ border: 'var(--hair)',
110
+ fontFamily: 'var(--font-mono)',
111
+ fontSize: 12,
112
+ lineHeight: 1.55,
113
+ padding: 'var(--r4) var(--r5)',
114
+ minHeight: 320,
115
+ overflow: 'auto',
116
+ }}
117
+ >
118
+ <div style={{ color: 'var(--ink-3)', marginBottom: 8 }}>
119
+ designlang-mcp · stdio
120
+ </div>
121
+ {TRANSCRIPT.map((step, i) => {
122
+ const line = lines[i];
123
+ const rendered = line?.rendered ?? '';
124
+ const done = rendered.length === step.text.length;
125
+ return (
126
+ <pre
127
+ key={i}
128
+ style={{
129
+ margin: 0,
130
+ whiteSpace: 'pre-wrap',
131
+ wordBreak: 'break-all',
132
+ color: step.kind === 'typed' ? 'var(--paper)' : 'var(--ink-3)',
133
+ }}
134
+ >
135
+ {step.kind === 'print' && rendered ? <span style={{ color: 'var(--accent)' }}>» </span> : null}
136
+ {rendered}
137
+ {!done && !reduced ? <span style={{ opacity: 0.7 }}>▍</span> : null}
138
+ </pre>
139
+ );
140
+ })}
141
+ </div>
142
+ );
143
+ }
144
+
145
+ function wait(ms, signal) {
146
+ return new Promise((resolve, reject) => {
147
+ const t = setTimeout(resolve, ms);
148
+ signal.addEventListener('abort', () => {
149
+ clearTimeout(t);
150
+ reject(new Error('aborted'));
151
+ }, { once: true });
152
+ });
153
+ }
154
+
155
+ export default function McpSection() {
156
+ const [reduced, setReduced] = useState(false);
157
+ useEffect(() => {
158
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
159
+ const update = () => setReduced(mq.matches);
160
+ update();
161
+ mq.addEventListener?.('change', update);
162
+ return () => mq.removeEventListener?.('change', update);
163
+ }, []);
164
+
165
+ return (
166
+ <div>
167
+ <div style={{ padding: 'var(--r5) 0 var(--r6)' }}>
168
+ <h2 className="display">Your editor reads this.</h2>
169
+ <p className="prose" style={{ marginTop: 'var(--r4)', fontSize: 18, maxWidth: '52ch' }}>
170
+ The MCP server exposes five resources and five tools over stdio, speaking
171
+ JSON-RPC to Claude Code, Cursor, and Windsurf. See <a href="#install">§09</a> for install.
172
+ </p>
173
+ </div>
174
+
175
+ <div
176
+ style={{
177
+ display: 'grid',
178
+ gridTemplateColumns: 'minmax(0, 7fr) minmax(0, 5fr)',
179
+ columnGap: 0,
180
+ alignItems: 'stretch',
181
+ }}
182
+ >
183
+ {/* Left: rule file */}
184
+ <div
185
+ style={{
186
+ background: 'var(--paper-2)',
187
+ border: 'var(--hair)',
188
+ borderRight: 0,
189
+ padding: 'var(--r4) var(--r5)',
190
+ fontFamily: 'var(--font-mono)',
191
+ fontSize: 12,
192
+ lineHeight: 1.55,
193
+ overflow: 'auto',
194
+ }}
195
+ >
196
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 'var(--r3)' }}>
197
+ <span style={{ color: 'var(--ink-3)' }}>.cursor/rules/designlang.mdc</span>
198
+ <span className="chip" style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }}>
199
+ alwaysApply: true
200
+ </span>
201
+ </div>
202
+ <pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--ink)' }}>
203
+ {CURSOR_RULE}
204
+ </pre>
205
+ </div>
206
+
207
+ {/* Right: terminal */}
208
+ <Terminal reduced={reduced} />
209
+ </div>
210
+
211
+ <div style={{ marginTop: 'var(--r6)' }}>
212
+ <Marginalia>
213
+ <div>install</div>
214
+ <div>
215
+ <code>{'{ "mcpServers": { "designlang": { "command": "npx", "args": ["designlang","mcp"] } } }'}</code>
216
+ </div>
217
+ <hr style={{ margin: '12px 0', border: 0, borderTop: '1px solid var(--ink-3)' }} />
218
+ <div>Zero-config for any MCP-aware agent.</div>
219
+ </Marginalia>
220
+ </div>
221
+ </div>
222
+ );
223
+ }