designlang 7.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 (64) hide show
  1. package/.vercel/README.txt +11 -0
  2. package/.vercel/project.json +1 -0
  3. package/CHANGELOG.md +15 -0
  4. package/CONTRIBUTING.md +25 -0
  5. package/README.md +9 -7
  6. package/bin/design-extract.js +18 -1
  7. package/chrome-extension/README.md +41 -0
  8. package/chrome-extension/icons/favicon.svg +7 -0
  9. package/chrome-extension/icons/icon-128.png +0 -0
  10. package/chrome-extension/icons/icon-16.png +0 -0
  11. package/chrome-extension/icons/icon-32.png +0 -0
  12. package/chrome-extension/icons/icon-48.png +0 -0
  13. package/chrome-extension/manifest.json +26 -0
  14. package/chrome-extension/popup.html +167 -0
  15. package/chrome-extension/popup.js +59 -0
  16. package/docs/superpowers/specs/2026-04-18-website-redesign-design.md +120 -0
  17. package/docs/superpowers/specs/2026-04-19-designlang-v7-1-design.md +111 -0
  18. package/package.json +1 -1
  19. package/src/config.js +3 -0
  20. package/src/crawler.js +20 -2
  21. package/src/utils-cookies.js +73 -0
  22. package/tests/cookies.test.js +98 -0
  23. package/website/app/api/extract/route.js +216 -56
  24. package/website/app/components/A11ySlider.js +369 -0
  25. package/website/app/components/Comparison.js +286 -0
  26. package/website/app/components/CssHealth.js +243 -0
  27. package/website/app/components/HeroExtractor.js +455 -0
  28. package/website/app/components/Marginalia.js +3 -0
  29. package/website/app/components/McpSection.js +223 -0
  30. package/website/app/components/PlatformTabs.js +250 -0
  31. package/website/app/components/RegionsComponents.js +429 -0
  32. package/website/app/components/Rule.js +13 -0
  33. package/website/app/components/Specimens.js +237 -0
  34. package/website/app/components/StructuredData.js +144 -0
  35. package/website/app/components/TokenBrowser.js +344 -0
  36. package/website/app/components/token-browser-sample.js +65 -0
  37. package/website/app/globals.css +415 -633
  38. package/website/app/icon.svg +7 -0
  39. package/website/app/layout.js +113 -6
  40. package/website/app/opengraph-image.js +170 -0
  41. package/website/app/page.js +325 -148
  42. package/website/app/robots.js +15 -0
  43. package/website/app/seo-config.js +82 -0
  44. package/website/app/sitemap.js +18 -0
  45. package/website/lib/cache.js +73 -0
  46. package/website/lib/rate-limit.js +30 -0
  47. package/website/lib/rate-limit.test.js +55 -0
  48. package/website/lib/specimens.json +86 -0
  49. package/website/lib/token-helpers.js +70 -0
  50. package/website/lib/url-safety.js +103 -0
  51. package/website/lib/url-safety.test.js +116 -0
  52. package/website/lib/zip-files.js +15 -0
  53. package/website/package-lock.json +85 -0
  54. package/website/package.json +1 -0
  55. package/website/public/favicon.svg +7 -0
  56. package/website/public/logo-specimen.svg +76 -0
  57. package/website/public/mark.svg +12 -0
  58. package/website/public/site.webmanifest +13 -0
  59. package/website/app/favicon.ico +0 -0
  60. package/website/public/file.svg +0 -1
  61. package/website/public/globe.svg +0 -1
  62. package/website/public/next.svg +0 -1
  63. package/website/public/vercel.svg +0 -1
  64. package/website/public/window.svg +0 -1
@@ -0,0 +1,429 @@
1
+ 'use client';
2
+
3
+ // §06 Regions + components.
4
+ // Two sub-blocks:
5
+ // a. Region classifier — schematic page + legend of 9 roles
6
+ // b. Component clusters — an "unfold" list of 4 button variants
7
+
8
+ import { useEffect, useRef, useState } from 'react';
9
+ import Rule from './Rule';
10
+ import Marginalia from './Marginalia';
11
+
12
+ // Schematic regions — shape matches semantic-regions.js output
13
+ // { role, selector, bbox: { x, y, w, h }, signals: [...] }
14
+ const REGIONS = [
15
+ { role: 'nav', x: 0, y: 0, w: 640, h: 48 },
16
+ { role: 'hero', x: 0, y: 48, w: 640, h: 140 },
17
+ { role: 'features', x: 0, y: 188, w: 640, h: 84 },
18
+ { role: 'pricing', x: 0, y: 272, w: 640, h: 70 },
19
+ { role: 'testimonials', x: 0, y: 342, w: 640, h: 42 },
20
+ { role: 'footer', x: 0, y: 384, w: 640, h: 36 },
21
+ ];
22
+
23
+ const ROLES = [
24
+ { role: 'nav', desc: 'top-anchored navigation', signal: 'landmark + top-anchored + link density > 0.5' },
25
+ { role: 'hero', desc: 'primary above-the-fold statement', signal: 'large type + early viewport + CTA' },
26
+ { role: 'features', desc: 'grid of benefit/feature cells', signal: '3+ siblings, similar structural hash' },
27
+ { role: 'pricing', desc: 'price tiers and plans', signal: 'currency symbol + repeated card shape' },
28
+ { role: 'testimonials', desc: 'social proof / quotes', signal: 'blockquote or quote glyph + attribution' },
29
+ { role: 'cta', desc: 'standalone conversion band', signal: 'single button + heading, full-width container' },
30
+ { role: 'footer', desc: 'bottom landmark', signal: 'landmark + bottom-anchored + link density high' },
31
+ { role: 'sidebar', desc: 'lateral aside content', signal: 'landmark=complementary or aside element' },
32
+ { role: 'content', desc: 'prose / article body', signal: 'main landmark, paragraph density, reading width' },
33
+ ];
34
+
35
+ // Cluster — shape matches component-clusters.js output
36
+ const BUTTON_CLUSTER = {
37
+ kind: 'button',
38
+ instanceCount: 24,
39
+ variants: [
40
+ {
41
+ name: 'primary',
42
+ css: {
43
+ background: 'var(--accent)',
44
+ color: 'var(--paper)',
45
+ border: '1px solid var(--accent)',
46
+ padding: '10px 18px',
47
+ },
48
+ instanceCount: 14,
49
+ },
50
+ {
51
+ name: 'secondary',
52
+ css: {
53
+ background: 'transparent',
54
+ color: 'var(--ink)',
55
+ border: '1px solid var(--ink)',
56
+ padding: '10px 18px',
57
+ },
58
+ instanceCount: 6,
59
+ },
60
+ {
61
+ name: 'ghost',
62
+ css: {
63
+ background: 'transparent',
64
+ color: 'var(--ink)',
65
+ border: '1px solid transparent',
66
+ padding: '10px 18px',
67
+ textDecoration: 'underline',
68
+ textUnderlineOffset: 3,
69
+ },
70
+ instanceCount: 3,
71
+ },
72
+ {
73
+ name: 'disabled',
74
+ css: {
75
+ background: 'var(--paper-2)',
76
+ color: 'var(--ink-3)',
77
+ border: '1px solid var(--ink-3)',
78
+ padding: '10px 18px',
79
+ },
80
+ instanceCount: 1,
81
+ },
82
+ ],
83
+ };
84
+
85
+ export default function RegionsComponents() {
86
+ return (
87
+ <>
88
+ <Rule number="06" label="Regions and components" />
89
+ <div className="with-margin" style={{ marginTop: 'var(--r5)' }}>
90
+ <div>
91
+ <div className="eyebrow" style={{ marginBottom: 'var(--r3)' }}>§06 Regions + components</div>
92
+ <h2 className="display" style={{ marginBottom: 'var(--r4)' }}>
93
+ Structure, not just style.
94
+ </h2>
95
+ <p className="prose" style={{ fontSize: 18, maxWidth: '62ch', color: 'var(--ink-2)' }}>
96
+ designlang labels the page before measuring it. Nav, hero, pricing, footer — nine
97
+ roles; landmarks first, heuristics second. Components are clustered by DOM signature
98
+ and style vector, not by guessing at class names.
99
+ </p>
100
+
101
+ <RegionOverlay />
102
+
103
+ <div
104
+ style={{
105
+ borderTop: '1px solid var(--ink)',
106
+ marginTop: 'var(--r8)',
107
+ paddingTop: 'var(--r6)',
108
+ }}
109
+ />
110
+
111
+ <ComponentClusters />
112
+ </div>
113
+
114
+ <Marginalia>
115
+ <div>MCP companion</div>
116
+ <p style={{ marginTop: 6 }}>
117
+ Regions and components live alongside the tokens, in the MCP companion JSON and the
118
+ DTCG <code>$extensions</code> field.
119
+ </p>
120
+ <hr style={{ margin: '12px 0', border: 0, borderTop: '1px solid var(--ink-3)' }} />
121
+ <p className="foot">
122
+ Agents can query <code>get_region</code> and <code>get_component</code> over MCP to
123
+ regenerate a section in matching style.
124
+ </p>
125
+ </Marginalia>
126
+ </div>
127
+ </>
128
+ );
129
+ }
130
+
131
+ // ── Sub-block A — Region classifier ────────────────────────────
132
+ function RegionOverlay() {
133
+ const [hovered, setHovered] = useState(null);
134
+
135
+ return (
136
+ <div style={{ marginTop: 'var(--r7)' }}>
137
+ <div
138
+ className="mono"
139
+ style={{
140
+ fontSize: 11,
141
+ letterSpacing: '0.14em',
142
+ textTransform: 'uppercase',
143
+ color: 'var(--ink-2)',
144
+ marginBottom: 'var(--r3)',
145
+ }}
146
+ >
147
+ §06.a Region classifier
148
+ </div>
149
+
150
+ <div className="grid-12" style={{ alignItems: 'start' }}>
151
+ {/* Schematic page — span 7 */}
152
+ <div style={{ gridColumn: 'span 7' }}>
153
+ <div
154
+ style={{
155
+ position: 'relative',
156
+ width: '100%',
157
+ aspectRatio: '640 / 420',
158
+ background: 'var(--paper-2)',
159
+ border: '1px solid var(--ink)',
160
+ }}
161
+ >
162
+ <svg
163
+ viewBox="0 0 640 420"
164
+ preserveAspectRatio="none"
165
+ style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
166
+ role="img"
167
+ aria-label="Schematic page with six classified regions"
168
+ >
169
+ {REGIONS.map((r) => {
170
+ const active = hovered === r.role;
171
+ return (
172
+ <g
173
+ key={r.role}
174
+ onMouseEnter={() => setHovered(r.role)}
175
+ onMouseLeave={() => setHovered(null)}
176
+ style={{ cursor: 'pointer' }}
177
+ >
178
+ <rect
179
+ x={r.x + 4}
180
+ y={r.y + 4}
181
+ width={r.w - 8}
182
+ height={r.h - 8}
183
+ fill={active ? 'var(--paper)' : 'transparent'}
184
+ stroke="var(--ink)"
185
+ strokeWidth="1"
186
+ />
187
+ <rect
188
+ x={r.x + 10}
189
+ y={r.y + 10}
190
+ width={r.role.length * 7 + 10}
191
+ height={16}
192
+ fill="var(--paper)"
193
+ stroke="var(--ink)"
194
+ strokeWidth="0.5"
195
+ />
196
+ <text
197
+ x={r.x + 15}
198
+ y={r.y + 21}
199
+ fontFamily="var(--font-mono)"
200
+ fontSize="10"
201
+ fill={active ? 'var(--accent)' : 'var(--ink)'}
202
+ letterSpacing="0.06em"
203
+ >
204
+ {r.role}
205
+ </text>
206
+ </g>
207
+ );
208
+ })}
209
+ </svg>
210
+ </div>
211
+ <div
212
+ className="mono"
213
+ style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 'var(--r3)' }}
214
+ >
215
+ hover a region to match its role in the legend →
216
+ </div>
217
+ </div>
218
+
219
+ {/* Legend — span 5 */}
220
+ <div style={{ gridColumn: 'span 5' }}>
221
+ <div
222
+ className="mono"
223
+ style={{
224
+ fontSize: 10,
225
+ letterSpacing: '0.14em',
226
+ textTransform: 'uppercase',
227
+ color: 'var(--ink-3)',
228
+ marginBottom: 'var(--r3)',
229
+ display: 'grid',
230
+ gridTemplateColumns: '100px 1fr',
231
+ gap: 12,
232
+ }}
233
+ >
234
+ <span>role</span>
235
+ <span>signal</span>
236
+ </div>
237
+ <ul style={{ listStyle: 'none', padding: 0, margin: 0, borderTop: '1px solid var(--ink)' }}>
238
+ {ROLES.map((r) => {
239
+ const active = hovered === r.role;
240
+ return (
241
+ <li
242
+ key={r.role}
243
+ onMouseEnter={() => setHovered(r.role)}
244
+ onMouseLeave={() => setHovered(null)}
245
+ style={{
246
+ display: 'grid',
247
+ gridTemplateColumns: '100px 1fr',
248
+ gap: 12,
249
+ padding: '8px 0',
250
+ borderBottom: '1px solid var(--ink-3)',
251
+ alignItems: 'baseline',
252
+ }}
253
+ >
254
+ <span
255
+ className="mono"
256
+ style={{
257
+ fontSize: 12,
258
+ color: active ? 'var(--ink)' : 'var(--ink-2)',
259
+ textDecoration: active ? 'underline' : 'none',
260
+ textDecorationColor: 'var(--accent)',
261
+ textUnderlineOffset: 3,
262
+ textDecorationThickness: 2,
263
+ }}
264
+ >
265
+ {r.role}
266
+ </span>
267
+ <span style={{ fontSize: 12, color: 'var(--ink-2)', lineHeight: 1.45 }}>
268
+ <span style={{ color: 'var(--ink)' }}>{r.desc}.</span>{' '}
269
+ <span className="mono" style={{ fontSize: 11, color: 'var(--ink-3)' }}>
270
+ {r.signal}
271
+ </span>
272
+ </span>
273
+ </li>
274
+ );
275
+ })}
276
+ </ul>
277
+ </div>
278
+ </div>
279
+ </div>
280
+ );
281
+ }
282
+
283
+ // ── Sub-block B — Component clusters ───────────────────────────
284
+ function ComponentClusters() {
285
+ const ref = useRef(null);
286
+ const [visible, setVisible] = useState(false);
287
+ const [reduced, setReduced] = useState(false);
288
+
289
+ useEffect(() => {
290
+ if (typeof window === 'undefined') return;
291
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
292
+ setReduced(mq.matches);
293
+ }, []);
294
+
295
+ useEffect(() => {
296
+ if (!ref.current) return;
297
+ const el = ref.current;
298
+ const io = new IntersectionObserver(
299
+ (entries) => {
300
+ for (const entry of entries) {
301
+ if (entry.isIntersecting) {
302
+ setVisible(true);
303
+ io.disconnect();
304
+ }
305
+ }
306
+ },
307
+ { threshold: 0.25 }
308
+ );
309
+ io.observe(el);
310
+ return () => io.disconnect();
311
+ }, []);
312
+
313
+ return (
314
+ <div>
315
+ <div
316
+ className="mono"
317
+ style={{
318
+ fontSize: 11,
319
+ letterSpacing: '0.14em',
320
+ textTransform: 'uppercase',
321
+ color: 'var(--ink-2)',
322
+ marginBottom: 'var(--r3)',
323
+ }}
324
+ >
325
+ §06.b Component clusters
326
+ </div>
327
+ <div
328
+ className="mono"
329
+ style={{
330
+ fontSize: 12,
331
+ color: 'var(--ink)',
332
+ marginBottom: 'var(--r4)',
333
+ paddingBottom: 'var(--r3)',
334
+ borderBottom: '1px solid var(--ink)',
335
+ }}
336
+ >
337
+ button — {BUTTON_CLUSTER.instanceCount} instances, {BUTTON_CLUSTER.variants.length}{' '}
338
+ variants ({BUTTON_CLUSTER.variants.map((v) => v.name).join(', ')})
339
+ </div>
340
+
341
+ <div
342
+ ref={ref}
343
+ style={{
344
+ display: 'grid',
345
+ gridTemplateColumns: 'repeat(4, 1fr)',
346
+ gap: 'var(--r4)',
347
+ }}
348
+ >
349
+ {BUTTON_CLUSTER.variants.map((v, i) => {
350
+ const shown = reduced || visible;
351
+ return (
352
+ <div
353
+ key={v.name}
354
+ style={{
355
+ border: '1px solid var(--ink)',
356
+ padding: 'var(--r4)',
357
+ display: 'flex',
358
+ flexDirection: 'column',
359
+ gap: 'var(--r3)',
360
+ background: 'var(--paper)',
361
+ opacity: shown ? 1 : 0,
362
+ transform: shown ? 'translateX(0)' : 'translateX(-12px)',
363
+ transition: reduced
364
+ ? 'none'
365
+ : `opacity 200ms ease ${i * 80}ms, transform 200ms ease ${i * 80}ms`,
366
+ }}
367
+ >
368
+ <div
369
+ className="mono"
370
+ style={{
371
+ fontSize: 10,
372
+ letterSpacing: '0.14em',
373
+ textTransform: 'uppercase',
374
+ color: 'var(--ink-3)',
375
+ }}
376
+ >
377
+ {v.name} · {v.instanceCount}
378
+ </div>
379
+ <div
380
+ style={{
381
+ display: 'flex',
382
+ alignItems: 'center',
383
+ justifyContent: 'center',
384
+ minHeight: 92,
385
+ background: 'var(--paper-2)',
386
+ border: '1px solid var(--ink-3)',
387
+ }}
388
+ >
389
+ <span
390
+ className="mono"
391
+ style={{
392
+ ...v.css,
393
+ fontSize: 13,
394
+ letterSpacing: '0.04em',
395
+ textTransform: 'uppercase',
396
+ display: 'inline-block',
397
+ }}
398
+ >
399
+ Continue
400
+ </span>
401
+ </div>
402
+ <pre
403
+ className="mono"
404
+ style={{
405
+ fontSize: 10,
406
+ color: 'var(--ink-2)',
407
+ lineHeight: 1.5,
408
+ whiteSpace: 'pre-wrap',
409
+ margin: 0,
410
+ }}
411
+ >
412
+ {formatCss(v.css)}
413
+ </pre>
414
+ </div>
415
+ );
416
+ })}
417
+ </div>
418
+ </div>
419
+ );
420
+ }
421
+
422
+ function formatCss(css) {
423
+ return Object.entries(css)
424
+ .map(([k, v]) => `${kebab(k)}: ${v};`)
425
+ .join('\n');
426
+ }
427
+ function kebab(s) {
428
+ return s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
429
+ }
@@ -0,0 +1,13 @@
1
+ export default function Rule({ number, label }) {
2
+ return (
3
+ <div className="rule" role="separator" aria-label={label}>
4
+ <div className="rule-line" />
5
+ {(number || label) && (
6
+ <div className="rule-label">
7
+ {number && <span className="rule-number">§{number}</span>}
8
+ {label}
9
+ </div>
10
+ )}
11
+ </div>
12
+ );
13
+ }
@@ -0,0 +1,237 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import specimens from '../../lib/specimens.json';
5
+ import Marginalia from './Marginalia';
6
+
7
+ // Quick relative-luminance check used to guard against accents that would fail
8
+ // 3:1 on paper. If they do, we fall back to ink.
9
+ function contrastsOnPaper(hex) {
10
+ const paper = [0xf3, 0xf1, 0xea];
11
+ const r = parseInt(hex.slice(1, 3), 16);
12
+ const g = parseInt(hex.slice(3, 5), 16);
13
+ const b = parseInt(hex.slice(5, 7), 16);
14
+ const lum = (c) => {
15
+ const s = c / 255;
16
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
17
+ };
18
+ const l1 = 0.2126 * lum(paper[0]) + 0.7152 * lum(paper[1]) + 0.0722 * lum(paper[2]);
19
+ const l2 = 0.2126 * lum(r) + 0.7152 * lum(g) + 0.0722 * lum(b);
20
+ const ratio = (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
21
+ return ratio >= 3;
22
+ }
23
+
24
+ export default function Specimens() {
25
+ const stripRef = useRef(null);
26
+
27
+ useEffect(() => {
28
+ const el = stripRef.current;
29
+ if (!el) return;
30
+
31
+ const cards = Array.from(el.querySelectorAll('[data-specimen]'));
32
+ const root = document.documentElement;
33
+ const DEFAULT = '#FF4800';
34
+
35
+ const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
36
+ root.style.setProperty('--accent-transition', reduced ? '0s' : '400ms');
37
+ // Attach transition for --accent. Since CSS can't transition a custom
38
+ // property by default we apply it via a style tag scoped to documentElement.
39
+ const styleEl = document.createElement('style');
40
+ styleEl.textContent = `html { transition: --accent var(--accent-transition) ease; } @property --accent { syntax: '<color>'; inherits: true; initial-value: #FF4800; }`;
41
+ document.head.appendChild(styleEl);
42
+
43
+ const io = new IntersectionObserver(
44
+ (entries) => {
45
+ const visible = entries.filter((e) => e.isIntersecting);
46
+ if (visible.length === 0) {
47
+ root.style.setProperty('--accent', DEFAULT);
48
+ return;
49
+ }
50
+ // Pick the one with the greatest visibility ratio.
51
+ const top = visible.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
52
+ const accent = top.target.getAttribute('data-accent') || DEFAULT;
53
+ const safe = contrastsOnPaper(accent) ? accent : '#0A0908';
54
+ root.style.setProperty('--accent', safe);
55
+ },
56
+ { root: el, threshold: [0.6] }
57
+ );
58
+
59
+ cards.forEach((c) => io.observe(c));
60
+ return () => {
61
+ io.disconnect();
62
+ root.style.setProperty('--accent', DEFAULT);
63
+ styleEl.remove();
64
+ };
65
+ }, []);
66
+
67
+ return (
68
+ <div>
69
+ <div className="with-margin" style={{ marginTop: 'var(--r5)', marginBottom: 'var(--r7)' }}>
70
+ <div>
71
+ <div className="section-label" style={{ marginBottom: 'var(--r5)' }}>
72
+ <span>§07 — Specimens</span>
73
+ </div>
74
+ <h2 className="display" style={{ marginBottom: 'var(--r5)' }}>
75
+ Six design systems,<br />
76
+ <em style={{ fontStyle: 'italic', color: 'var(--accent)' }}>one format.</em>
77
+ </h2>
78
+ <p className="prose" style={{ fontSize: 18, lineHeight: 1.5, maxWidth: '62ch' }}>
79
+ Each specimen was crawled once and written to DTCG. The page accent below is
80
+ pulled from the specimen&apos;s own extracted primary — the same{' '}
81
+ <code className="mono" style={{ fontSize: 14 }}>semantic.color.action.primary</code>{' '}
82
+ your agent would get over MCP.
83
+ </p>
84
+ </div>
85
+ <Marginalia>
86
+ <div>accessibility</div>
87
+ <p className="foot" style={{ marginTop: 6 }}>
88
+ The live <code>--accent</code> is tested for ≥3:1 contrast with paper on scroll.
89
+ Specimens that fall below fall back to ink.
90
+ </p>
91
+ <hr style={{ margin: '12px 0', border: 0, borderTop: '1px solid var(--ink-3)' }} />
92
+ <p className="foot">
93
+ Specimens are point-in-time snapshots, re-rendered from on-disk DTCG files.
94
+ To update, re-run <code>designlang &lt;url&gt; --platforms web</code> and
95
+ replace the specimen entry.
96
+ </p>
97
+ </Marginalia>
98
+ </div>
99
+
100
+ <div
101
+ ref={stripRef}
102
+ className="specimen-strip"
103
+ style={{
104
+ display: 'flex',
105
+ gap: 'var(--r5)',
106
+ overflowX: 'auto',
107
+ overflowY: 'hidden',
108
+ scrollSnapType: 'x mandatory',
109
+ paddingBottom: 'var(--r5)',
110
+ paddingInline: 'var(--page-pad-x)',
111
+ marginInline: 'calc(-1 * var(--page-pad-x))',
112
+ scrollbarWidth: 'thin',
113
+ }}
114
+ >
115
+ {specimens.map((s) => (
116
+ <SpecimenCard key={s.host} s={s} />
117
+ ))}
118
+ </div>
119
+
120
+ <style jsx>{`
121
+ .specimen-strip > :global([data-specimen]) {
122
+ flex: 0 0 640px;
123
+ height: 400px;
124
+ scroll-snap-align: start;
125
+ border: 1px solid var(--ink);
126
+ background: var(--paper-2);
127
+ padding: var(--r5);
128
+ display: grid;
129
+ grid-template-rows: auto auto 1fr auto auto;
130
+ gap: var(--r4);
131
+ }
132
+ @media (max-width: 860px) {
133
+ .specimen-strip {
134
+ flex-direction: column;
135
+ overflow-x: hidden;
136
+ scroll-snap-type: none;
137
+ }
138
+ .specimen-strip > :global([data-specimen]) {
139
+ flex: 0 0 auto;
140
+ width: 100%;
141
+ height: auto;
142
+ min-height: 420px;
143
+ }
144
+ }
145
+ `}</style>
146
+ </div>
147
+ );
148
+ }
149
+
150
+ function SpecimenCard({ s }) {
151
+ return (
152
+ <article data-specimen data-accent={s.accent} aria-label={`${s.display} specimen`}>
153
+ <header style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
154
+ <span className="chip mono">{s.host}</span>
155
+ <span className="chip mono">{s.year}</span>
156
+ <span className="chip mono">{s.framework}</span>
157
+ </header>
158
+
159
+ <div style={{ display: 'flex', gap: 0 }}>
160
+ {s.palette.map((hex) => (
161
+ <div key={hex} style={{ flex: '0 0 auto', marginRight: 8 }}>
162
+ <div
163
+ aria-hidden="true"
164
+ style={{
165
+ width: 40,
166
+ height: 40,
167
+ background: hex,
168
+ border: '1px solid var(--ink)',
169
+ }}
170
+ />
171
+ <div
172
+ className="mono"
173
+ style={{ fontSize: 10, marginTop: 4, color: 'var(--ink-2)', letterSpacing: '0.02em' }}
174
+ >
175
+ {hex.toUpperCase()}
176
+ </div>
177
+ </div>
178
+ ))}
179
+ </div>
180
+
181
+ <div
182
+ className="display"
183
+ style={{
184
+ fontSize: 64,
185
+ lineHeight: 1,
186
+ letterSpacing: '-0.02em',
187
+ color: 'var(--ink)',
188
+ alignSelf: 'center',
189
+ whiteSpace: 'nowrap',
190
+ overflow: 'hidden',
191
+ textOverflow: 'ellipsis',
192
+ }}
193
+ >
194
+ <span style={{ color: s.accent }}>{s.display}.</span>{' '}
195
+ <span style={{ color: 'var(--ink-2)' }}>Design as a system.</span>
196
+ </div>
197
+
198
+ <div style={{ display: 'flex', gap: 'var(--r6)' }}>
199
+ <Metric label="a11y" value={s.a11y} />
200
+ <Metric label="score" value={s.designScore} />
201
+ <Metric label="radius" value={s.radius} />
202
+ </div>
203
+
204
+ <footer
205
+ style={{
206
+ fontFamily: 'var(--font-display)',
207
+ fontStyle: 'italic',
208
+ color: 'var(--ink-2)',
209
+ fontSize: 14,
210
+ borderTop: '1px solid var(--ink-3)',
211
+ paddingTop: 10,
212
+ }}
213
+ >
214
+ {s.note}
215
+ </footer>
216
+ </article>
217
+ );
218
+ }
219
+
220
+ function Metric({ label, value }) {
221
+ return (
222
+ <div>
223
+ <div
224
+ className="display"
225
+ style={{ fontSize: 40, lineHeight: 1, letterSpacing: '-0.02em' }}
226
+ >
227
+ {value}
228
+ </div>
229
+ <div
230
+ className="mono"
231
+ style={{ fontSize: 10, color: 'var(--ink-3)', textTransform: 'uppercase', letterSpacing: '0.12em', marginTop: 4 }}
232
+ >
233
+ {label}
234
+ </div>
235
+ </div>
236
+ );
237
+ }