prism-design 2.13.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 (90) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/LICENSE +21 -0
  3. package/README.md +203 -0
  4. package/bin/clone-architect.mjs +476 -0
  5. package/bin/prism.mjs +467 -0
  6. package/catalog/index.json +1155 -0
  7. package/extractions/airbnb.com/DESIGN.md +1068 -0
  8. package/extractions/airbnb.com/tokens.json +507 -0
  9. package/extractions/attio.com/DESIGN.md +1295 -0
  10. package/extractions/attio.com/tokens.json +438 -0
  11. package/extractions/auroxdashboard.com/DESIGN.md +724 -0
  12. package/extractions/auroxdashboard.com/tokens.json +195 -0
  13. package/extractions/careerexplorer.com/DESIGN.md +1178 -0
  14. package/extractions/careerexplorer.com/tokens.json +141 -0
  15. package/extractions/chance.co/DESIGN.md +1209 -0
  16. package/extractions/chance.co/tokens.json +160 -0
  17. package/extractions/choisis-ton-avenir.com/DESIGN.md +1265 -0
  18. package/extractions/choisis-ton-avenir.com/tokens.json +227 -0
  19. package/extractions/example.com/DESIGN.md +436 -0
  20. package/extractions/example.com/tokens.json +91 -0
  21. package/extractions/getdesign.md/DESIGN.md +1009 -0
  22. package/extractions/getdesign.md/tokens.json +219 -0
  23. package/extractions/github.com/DESIGN.md +1130 -0
  24. package/extractions/github.com/tokens.json +2092 -0
  25. package/extractions/hello-charly.com/DESIGN.md +1146 -0
  26. package/extractions/hello-charly.com/tokens.json +322 -0
  27. package/extractions/hyperliquid.xyz/DESIGN.md +779 -0
  28. package/extractions/hyperliquid.xyz/tokens.json +598 -0
  29. package/extractions/instagram.com/DESIGN.md +996 -0
  30. package/extractions/instagram.com/tokens.json +1240 -0
  31. package/extractions/jobirl.com/DESIGN.md +1160 -0
  32. package/extractions/jobirl.com/tokens.json +139 -0
  33. package/extractions/life360.com/DESIGN.md +1133 -0
  34. package/extractions/life360.com/tokens.json +491 -0
  35. package/extractions/lifesum.com/DESIGN.md +965 -0
  36. package/extractions/lifesum.com/tokens.json +170 -0
  37. package/extractions/linear.app/DESIGN.md +1301 -0
  38. package/extractions/linear.app/tokens.json +732 -0
  39. package/extractions/mavoie.org/DESIGN.md +1148 -0
  40. package/extractions/mavoie.org/tokens.json +128 -0
  41. package/extractions/miro.com/DESIGN.md +1237 -0
  42. package/extractions/miro.com/tokens.json +401 -0
  43. package/extractions/notion.so/DESIGN.md +1319 -0
  44. package/extractions/notion.so/tokens.json +906 -0
  45. package/extractions/onetonline.org/DESIGN.md +909 -0
  46. package/extractions/onetonline.org/tokens.json +280 -0
  47. package/extractions/posthog.com/DESIGN.md +1024 -0
  48. package/extractions/posthog.com/tokens.json +197 -0
  49. package/extractions/revolut.com/DESIGN.md +1080 -0
  50. package/extractions/revolut.com/tokens.json +401 -0
  51. package/extractions/stripe.com/DESIGN.md +1272 -0
  52. package/extractions/stripe.com/tokens.json +794 -0
  53. package/extractions/switchcollective.com/DESIGN.md +1040 -0
  54. package/extractions/switchcollective.com/tokens.json +98 -0
  55. package/extractions/truity.com/DESIGN.md +970 -0
  56. package/extractions/truity.com/tokens.json +166 -0
  57. package/extractions/uniquekicks.be/DESIGN.md +1171 -0
  58. package/extractions/uniquekicks.be/tokens.json +237 -0
  59. package/package.json +122 -0
  60. package/scripts/analyze.ts +281 -0
  61. package/scripts/bank-register.ts +379 -0
  62. package/scripts/bank.ts +374 -0
  63. package/scripts/browser-stealth.ts +189 -0
  64. package/scripts/clone.ts +198 -0
  65. package/scripts/compare-vs-gd-final.ts +273 -0
  66. package/scripts/compare-vs-gd.ts +269 -0
  67. package/scripts/compare.ts +405 -0
  68. package/scripts/deploy-site.ts +181 -0
  69. package/scripts/diff-snapshots.ts +340 -0
  70. package/scripts/enrich-catalog.ts +212 -0
  71. package/scripts/extract.ts +2038 -0
  72. package/scripts/extractors/advanced.ts +524 -0
  73. package/scripts/extractors/widgets.ts +711 -0
  74. package/scripts/generate-design-md.ts +5775 -0
  75. package/scripts/generate-final-pdf.ts +274 -0
  76. package/scripts/generate-og-image.ts +87 -0
  77. package/scripts/generate-showcase.ts +1588 -0
  78. package/scripts/generate-site.ts +847 -0
  79. package/scripts/mass-extract.sh +91 -0
  80. package/scripts/post-process-all.sh +55 -0
  81. package/scripts/regen-catalog.ts +203 -0
  82. package/scripts/shared/cache.ts +149 -0
  83. package/scripts/shared/css-helpers.ts +263 -0
  84. package/scripts/shared/logger.ts +57 -0
  85. package/scripts/shared/named-colors.ts +355 -0
  86. package/scripts/shared/types.ts +220 -0
  87. package/scripts/sync-catalog.ts +105 -0
  88. package/scripts/tokenize.ts +988 -0
  89. package/templates/layout-template.md +52 -0
  90. package/templates/tokens-template.json +34 -0
@@ -0,0 +1,1588 @@
1
+ /**
2
+ * generate-showcase.ts โ€” Sprint 3
3
+ * Gรฉnรจre extractions/{domain}/showcase/index.html
4
+ *
5
+ * Nouveautรฉs vs v1:
6
+ * โ€ข Sticky sidebar nav (9 sections, IntersectionObserver)
7
+ * โ€ข Copy-to-clipboard sur chaque swatch
8
+ * โ€ข Semantic color roles (error/success/warning/info)
9
+ * โ€ข Typography: line-height + letter-spacing columns + @font-face loading
10
+ * โ€ข Header: desktop + mobile screenshots cรดte-ร -cรดte
11
+ * โ€ข Live Components: boutons/inputs/cards rendus en HTML rรฉel avec vrais tokens
12
+ * โ€ข Visual Match section: screenshot original vs section gรฉnรฉrรฉe
13
+ */
14
+
15
+ import { readFile, writeFile, mkdir, access } from 'fs/promises';
16
+ import { join, resolve } from 'path';
17
+ import { readFileSync, existsSync } from 'fs';
18
+ import { luminance } from './shared/css-helpers.js';
19
+
20
+ // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
21
+
22
+ function rgbToHex(color: string): string {
23
+ if (!color || color === 'transparent') return '#transparent';
24
+ if (color.startsWith('#')) return color.toLowerCase();
25
+ const m = color.match(/rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)/);
26
+ if (!m) return color;
27
+ const hex = [m[1], m[2], m[3]].map(v => parseInt(v).toString(16).padStart(2, '0')).join('');
28
+ return `#${hex}`;
29
+ }
30
+
31
+ // Note: re-uses shared luminance() (WCAG gamma-corrected) โ€” Phase 1.2 cleanup, dedupe inline impl
32
+ function luminanceFromColorString(color: string): number {
33
+ const hex = rgbToHex(color);
34
+ const m = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
35
+ if (!m) return 0.5;
36
+ const [r, g, b] = [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)];
37
+ return luminance(r, g, b);
38
+ }
39
+
40
+ function contrastColor(color: string): string {
41
+ return luminanceFromColorString(color) > 0.45 ? '#1a1a1a' : '#ffffff';
42
+ }
43
+
44
+ function escapeHtml(s: string): string {
45
+ if (!s) return '';
46
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
47
+ }
48
+
49
+ async function fileExists(p: string): Promise<boolean> {
50
+ try { await access(p); return true; } catch { return false; }
51
+ }
52
+
53
+ async function screenshotBase64(dir: string, preferMobile = false): Promise<string | null> {
54
+ const desktopCandidates = [
55
+ 'above-fold-desktop.png', 'scroll-0-desktop.png', 'full-page-desktop.png',
56
+ ];
57
+ const mobileCandidates = [
58
+ 'above-fold-mobile.png', 'scroll-0-mobile.png', 'full-page-mobile.png',
59
+ ];
60
+ const list = preferMobile ? mobileCandidates : desktopCandidates;
61
+ for (const name of list) {
62
+ const p = join(dir, 'screenshots', name);
63
+ if (await fileExists(p)) {
64
+ const buf = readFileSync(p);
65
+ return `data:image/png;base64,${buf.toString('base64')}`;
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ // Read fontFaces from raw-css.json for @font-face injection
72
+ async function loadFontFaces(baseDir: string): Promise<string> {
73
+ const rawPath = join(baseDir, 'raw-css.json');
74
+ if (!existsSync(rawPath)) return '';
75
+ try {
76
+ const raw = JSON.parse(readFileSync(rawPath, 'utf-8'));
77
+ const data = raw.desktop || raw;
78
+ const faces: Array<{ family?: string; src?: string; weight?: string; style?: string }> = data.fontFaces || [];
79
+ if (!faces.length) return '';
80
+ return faces.slice(0, 8).map(f => {
81
+ if (!f.family || !f.src) return '';
82
+ // Only load woff2/woff โ€” skip ttf/eot to stay lean
83
+ const safeSrc = (f.src || '').replace(/url\(['"]?([^'")\s]+\.(?:ttf|eot|svg)[^'")\s]*)['"]?\)/gi, '');
84
+ if (!safeSrc.includes('url(')) return '';
85
+ return `@font-face { font-family: '${f.family}'; src: ${safeSrc}; font-weight: ${f.weight || '400'}; font-style: ${f.style || 'normal'}; font-display: swap; }`;
86
+ }).filter(Boolean).join('\n');
87
+ } catch { return ''; }
88
+ }
89
+
90
+ // โ”€โ”€ CSS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
91
+
92
+ function buildCss(tokens: any): string {
93
+ const bg = tokens.colors?.background?.primary || '#ffffff';
94
+ const bgAlt = tokens.colors?.background?.secondary || '#f5f5f5';
95
+ let fg = tokens.colors?.text?.primary || '#111111';
96
+ let fgMuted = tokens.colors?.text?.muted || '#888888';
97
+ const accent = tokens.colors?.accent?.primary || '#0070f3';
98
+ const border = tokens.colors?.border || '#e5e5e5';
99
+ const radius = tokens.borderRadius?.md || '8px';
100
+ const font = tokens.typography?.fontFamily?.primary || 'system-ui';
101
+ const mono = tokens.typography?.fontFamily?.mono || 'monospace';
102
+
103
+ const accentHex = rgbToHex(accent);
104
+ const bgHex = rgbToHex(bg);
105
+ const bgAltHex = rgbToHex(bgAlt);
106
+ let fgHex = rgbToHex(fg);
107
+ let fgMutedHex = rgbToHex(fgMuted);
108
+ const borderHex = rgbToHex(border);
109
+ const isLight = luminanceFromColorString(bgHex) > 0.5;
110
+
111
+ // v5-FIX: Auto-correct invisible text โ€” if bg and fg are both very dark OR both very light,
112
+ // override fg with safe contrast color (white on dark, black on light)
113
+ const bgLum = luminanceFromColorString(bgHex);
114
+ const fgLum = luminanceFromColorString(fgHex);
115
+ if (Math.abs(bgLum - fgLum) < 0.3) {
116
+ // Insufficient contrast โ†’ force readable color
117
+ fgHex = isLight ? '#1a1a1a' : '#fafafa';
118
+ fgMutedHex = isLight ? '#666666' : '#a8a8a8';
119
+ } else if (luminanceFromColorString(fgMutedHex) < 0.15 && !isLight) {
120
+ // Muted text invisible on dark
121
+ fgMutedHex = '#a8a8a8';
122
+ } else if (luminanceFromColorString(fgMutedHex) > 0.85 && isLight) {
123
+ fgMutedHex = '#666666';
124
+ }
125
+
126
+ // v7: FORCE LIGHT MODE โ€” fond blanc partout, brand accent preserved
127
+ // The CA showcase itself is always white-background regardless of source site theme.
128
+ // The brand colors are shown in their cards/components but the chrome stays clean.
129
+ const v7Bg = '#ffffff';
130
+ const v7BgAlt = '#fafafa';
131
+ const v7Fg = '#0a0a0a';
132
+ const v7FgMuted = '#666666';
133
+ const v7Border = '#e6e6e6';
134
+ const v7BorderSoft = '#f0f0f0';
135
+
136
+ return `
137
+ /* v7: Premium typography via @font-face Inter + JetBrains Mono (self-hosted Google Fonts) */
138
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
139
+
140
+ :root {
141
+ --bg: ${v7Bg};
142
+ --bg-alt: ${v7BgAlt};
143
+ --fg: ${v7Fg};
144
+ --fg-muted: ${v7FgMuted};
145
+ --accent: ${accentHex};
146
+ --accent-soft: ${accentHex}15;
147
+ --border: ${v7Border};
148
+ --border-soft: ${v7BorderSoft};
149
+ --radius: 12px;
150
+ --radius-sm: 8px;
151
+ --font: 'Inter', system-ui, -apple-system, sans-serif;
152
+ --font-display: 'Inter', system-ui, -apple-system, sans-serif;
153
+ --mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
154
+ --page-max: 1240px;
155
+ --nav-w: 236px;
156
+ --shadow-soft: 0 1px 2px rgba(0,0,0,0.04), 0 1px 4px rgba(0,0,0,0.04);
157
+ --shadow-card: 0 1px 2px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04);
158
+ --shadow-elev: 0 8px 32px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.04);
159
+ }
160
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
161
+ html { font-size: 16px; scroll-behavior: smooth; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
162
+ body { background: var(--bg); color: var(--fg); font-family: var(--font); font-size: 15px; line-height: 1.55; letter-spacing: -0.005em; }
163
+ /* Premium heading typography โ€” Inter at heavy weights with tight letter-spacing */
164
+ h1, h2, h3, h4 { font-family: var(--font-display); font-weight: 700; letter-spacing: -0.025em; line-height: 1.15; color: var(--fg); }
165
+
166
+ /* โ”€ Layout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
167
+ .app-layout {
168
+ display: flex;
169
+ max-width: calc(var(--page-max) + var(--nav-w) + 48px);
170
+ margin: 0 auto;
171
+ padding: 0 24px;
172
+ gap: 40px;
173
+ align-items: flex-start;
174
+ }
175
+
176
+ /* โ”€ Sidebar nav v7 โ€” clean white modern โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
177
+ .nav-sidebar {
178
+ position: sticky;
179
+ top: 32px;
180
+ width: var(--nav-w);
181
+ flex-shrink: 0;
182
+ align-self: flex-start;
183
+ padding: 24px 0;
184
+ }
185
+ .nav-sidebar-inner {
186
+ background: var(--bg);
187
+ border: 1px solid var(--border);
188
+ border-radius: var(--radius);
189
+ padding: 18px 0;
190
+ box-shadow: var(--shadow-soft);
191
+ }
192
+ .nav-brand {
193
+ display: block;
194
+ padding: 0 16px 12px;
195
+ font-size: 11px;
196
+ font-weight: 700;
197
+ letter-spacing: .08em;
198
+ text-transform: uppercase;
199
+ opacity: .45;
200
+ border-bottom: 1px solid var(--border);
201
+ margin-bottom: 8px;
202
+ }
203
+ .nav-link {
204
+ display: flex;
205
+ align-items: center;
206
+ gap: 8px;
207
+ padding: 6px 16px;
208
+ font-size: 13px;
209
+ color: var(--fg);
210
+ text-decoration: none;
211
+ opacity: .55;
212
+ transition: opacity .15s, background .15s;
213
+ white-space: nowrap;
214
+ overflow: hidden;
215
+ text-overflow: ellipsis;
216
+ }
217
+ .nav-link:hover { opacity: 1; background: rgba(128,128,128,.07); }
218
+ .nav-link.active { opacity: 1; font-weight: 600; color: var(--accent); }
219
+ .nav-dot {
220
+ width: 6px; height: 6px; border-radius: 50%;
221
+ background: var(--border);
222
+ flex-shrink: 0;
223
+ transition: background .15s;
224
+ }
225
+ .nav-link.active .nav-dot { background: var(--accent); }
226
+
227
+ /* โ”€ Main content โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
228
+ .main-content { flex: 1; min-width: 0; padding-bottom: 80px; }
229
+
230
+ /* โ”€ Header v5 โ€” BRAND-CUSTOMIZED HERO โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
231
+ .site-header {
232
+ position: relative;
233
+ padding: 64px 48px 56px;
234
+ margin: -24px -24px 56px -24px;
235
+ border-radius: 16px;
236
+ background:
237
+ radial-gradient(circle at 0% 0%, ${accentHex}25 0%, transparent 50%),
238
+ radial-gradient(circle at 100% 100%, ${accentHex}15 0%, transparent 60%),
239
+ linear-gradient(180deg, var(--bg) 0%, var(--bg-alt) 100%);
240
+ border: 1px solid var(--border);
241
+ overflow: hidden;
242
+ animation: heroFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1);
243
+ }
244
+ .site-header::before {
245
+ content: '';
246
+ position: absolute;
247
+ top: 0; left: 0; right: 0; height: 3px;
248
+ background: linear-gradient(90deg, ${accentHex} 0%, ${accentHex}80 50%, transparent 100%);
249
+ }
250
+ @keyframes heroFadeIn {
251
+ from { opacity: 0; transform: translateY(8px); }
252
+ to { opacity: 1; transform: translateY(0); }
253
+ }
254
+ .header-top { display: flex; gap: 32px; align-items: flex-start; flex-wrap: wrap; }
255
+ .header-meta { flex: 1; min-width: 280px; }
256
+ .header-badge {
257
+ display: inline-block; padding: 5px 12px;
258
+ background: var(--bg); color: var(--fg-muted); border: 1px solid var(--border);
259
+ border-radius: 99px; font-size: 10px; font-weight: 700;
260
+ letter-spacing: .08em; text-transform: uppercase; margin-bottom: 20px;
261
+ box-shadow: 0 2px 8px ${accentHex}40;
262
+ }
263
+ .header-domain {
264
+ font-size: 44px; font-weight: 800; letter-spacing: -.03em;
265
+ margin-bottom: 10px; line-height: 1.05;
266
+ background: none;
267
+ color: var(--fg);
268
+ /* removed background-clip text */
269
+ }
270
+ .header-tagline {
271
+ font-family: var(--font-display);
272
+ font-size: 22px; font-weight: 500;
273
+ color: var(--fg);
274
+ opacity: .75;
275
+ margin: 16px 0 24px;
276
+ max-width: 680px;
277
+ line-height: 1.35;
278
+ letter-spacing: -0.015em;
279
+ }
280
+ .header-meta-row { display: flex; gap: 16px; font-size: 12px; opacity: .6; flex-wrap: wrap; align-items: center; }
281
+ .header-meta-row a:hover { color: ${accentHex}; }
282
+ .confidence-badge {
283
+ display: inline-block; padding: 2px 7px; border-radius: 4px;
284
+ font-weight: 700; font-family: var(--mono); font-size: 11px;
285
+ margin-left: 4px;
286
+ }
287
+ .confidence-A { background: rgba(16,185,129,.15); color: #10b981; border: 1px solid rgba(16,185,129,.3); }
288
+ .confidence-B { background: rgba(14,165,233,.15); color: #0ea5e9; border: 1px solid rgba(14,165,233,.3); }
289
+ .confidence-C { background: rgba(245,158,11,.15); color: #f59e0b; border: 1px solid rgba(245,158,11,.3); }
290
+ .confidence-D { background: rgba(239,68,68,.15); color: #ef4444; border: 1px solid rgba(239,68,68,.3); }
291
+
292
+ /* v6: Stagger fade-in pour sections */
293
+ .section {
294
+ opacity: 0; transform: translateY(12px);
295
+ animation: sectionReveal 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
296
+ }
297
+ .section:nth-of-type(1) { animation-delay: 0.05s; }
298
+ .section:nth-of-type(2) { animation-delay: 0.1s; }
299
+ .section:nth-of-type(3) { animation-delay: 0.15s; }
300
+ .section:nth-of-type(4) { animation-delay: 0.2s; }
301
+ .section:nth-of-type(5) { animation-delay: 0.25s; }
302
+ .section:nth-of-type(n+6) { animation-delay: 0.3s; }
303
+ @keyframes sectionReveal {
304
+ to { opacity: 1; transform: translateY(0); }
305
+ }
306
+
307
+ /* v6: Footer */
308
+ .showcase-footer {
309
+ margin-top: 96px; padding: 32px 0; border-top: 1px solid var(--border);
310
+ display: flex; gap: 20px; align-items: center; justify-content: space-between;
311
+ flex-wrap: wrap; font-size: 12px; opacity: .7;
312
+ }
313
+ .showcase-footer .footer-meta { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
314
+ .showcase-footer .footer-links { display: flex; gap: 16px; }
315
+ .showcase-footer a { color: var(--accent); text-decoration: none; font-weight: 600; }
316
+ .showcase-footer a:hover { text-decoration: underline; }
317
+
318
+ /* v6: WCAG contrast badge on swatch hover */
319
+ .swatch[data-wcag]::after {
320
+ content: attr(data-wcag);
321
+ position: absolute; top: 8px; right: 8px;
322
+ font-size: 9px; font-weight: 700; letter-spacing: .04em;
323
+ padding: 2px 6px; border-radius: 4px;
324
+ opacity: 0; transition: opacity .15s;
325
+ background: rgba(0,0,0,.7); color: white;
326
+ }
327
+ .swatch:hover[data-wcag]::after { opacity: 1; }
328
+ .header-screenshots {
329
+ display: flex; gap: 12px; margin-top: 24px;
330
+ align-items: flex-start; flex-wrap: wrap;
331
+ }
332
+ .header-screenshot-wrap { flex-shrink: 0; }
333
+ .screenshot-label {
334
+ font-size: 10px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase;
335
+ opacity: .4; margin-bottom: 6px;
336
+ }
337
+ .header-screenshot {
338
+ border-radius: var(--radius); overflow: hidden;
339
+ border: 1px solid var(--border); box-shadow: 0 4px 16px rgba(0,0,0,.08);
340
+ }
341
+ .header-screenshot.desktop img { width: 360px; display: block; }
342
+ .header-screenshot.mobile img { width: 120px; display: block; }
343
+
344
+ /* โ”€ Section chrome v5 โ€” Indexed numbering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
345
+ .main-content { counter-reset: section; }
346
+ .section { margin-bottom: 72px; scroll-margin-top: 32px; counter-increment: section; }
347
+ .section > h2 {
348
+ font-size: 28px; font-weight: 800;
349
+ margin-bottom: 28px; padding-bottom: 12px;
350
+ border-bottom: 2px solid var(--accent);
351
+ display: inline-block;
352
+ letter-spacing: -.02em;
353
+ }
354
+ .section > h2::before {
355
+ content: counter(section, decimal-leading-zero) " โ€” ";
356
+ color: var(--accent);
357
+ font-weight: 600;
358
+ opacity: .7;
359
+ }
360
+ .section-desc { font-size: 13px; opacity: .55; margin-bottom: 24px; margin-top: -16px; max-width: 720px; line-height: 1.55; }
361
+ .section h3 {
362
+ font-size: 11px; font-weight: 700; letter-spacing: .1em;
363
+ text-transform: uppercase; opacity: .5;
364
+ margin-bottom: 14px; margin-top: 32px;
365
+ }
366
+
367
+ /* โ”€ Palette v5 โ€” Enhanced cards 180x120 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
368
+ .color-group { margin-bottom: 32px; }
369
+ .color-group-label {
370
+ font-size: 11px; font-weight: 700; letter-spacing: .08em;
371
+ text-transform: uppercase; opacity: .5; margin-bottom: 12px;
372
+ }
373
+ .swatches {
374
+ display: grid;
375
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
376
+ gap: 14px;
377
+ }
378
+ .swatch {
379
+ min-height: 124px; border-radius: 10px;
380
+ padding: 14px 16px; display: flex; flex-direction: column;
381
+ justify-content: flex-end; border: 1px solid rgba(255,255,255,.08);
382
+ cursor: pointer; position: relative; transition: transform .2s cubic-bezier(.4,0,.2,1), box-shadow .2s;
383
+ user-select: none; overflow: hidden;
384
+ }
385
+ .swatch::before {
386
+ content: ''; position: absolute; inset: 0;
387
+ background: linear-gradient(180deg, rgba(255,255,255,.04) 0%, transparent 30%, rgba(0,0,0,.12) 100%);
388
+ pointer-events: none;
389
+ }
390
+ .swatch:hover { transform: translateY(-3px); box-shadow: 0 10px 24px rgba(0,0,0,.2); }
391
+ .swatch-name { font-size: 13px; font-weight: 700; line-height: 1.3; position: relative; }
392
+ .swatch-hex { font-size: 11px; opacity: .85; font-family: var(--mono); margin-top: 3px; position: relative; }
393
+ .swatch-use { font-size: 10px; opacity: .7; margin-top: 6px; position: relative; }
394
+ .swatch-role {
395
+ position: absolute; top: 8px; right: 8px;
396
+ font-size: 9px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
397
+ opacity: .6; padding: 2px 5px; border-radius: 4px; background: rgba(0,0,0,.12);
398
+ }
399
+ .swatch-copied {
400
+ position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
401
+ background: rgba(0,0,0,.6); color: #fff; font-size: 13px; font-weight: 700;
402
+ border-radius: var(--radius); opacity: 0; transition: opacity .15s;
403
+ pointer-events: none;
404
+ }
405
+ .swatch.flash .swatch-copied { opacity: 1; }
406
+
407
+ /* โ”€ Typography โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
408
+ .typo-table { width: 100%; border-collapse: collapse; }
409
+ .typo-table th {
410
+ text-align: left; font-size: 10px; font-weight: 700; letter-spacing: .07em;
411
+ text-transform: uppercase; opacity: .4; padding: 0 8px 10px 0;
412
+ border-bottom: 1px solid var(--border);
413
+ }
414
+ .typo-table td { padding: 12px 8px 12px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
415
+ .typo-table tr:last-child td { border-bottom: none; }
416
+ .typo-role-cell { font-size: 12px; font-weight: 600; white-space: nowrap; }
417
+ .typo-spec-cell { font-family: var(--mono); font-size: 11px; opacity: .5; white-space: nowrap; }
418
+ .typo-preview-cell { font-size: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 320px; }
419
+
420
+ /* โ”€ Spacing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
421
+ .token-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 40px; }
422
+ .token-row { display: flex; align-items: center; gap: 12px; }
423
+ .token-name { font-family: var(--mono); font-size: 12px; width: 52px; opacity: .55; flex-shrink: 0; }
424
+ .token-bar-wrap { flex: 1; max-width: 320px; }
425
+ .token-bar { height: 8px; background: var(--accent); border-radius: 4px; display: block; opacity: .6; }
426
+ .token-value{ font-family: var(--mono); font-size: 12px; opacity: .55; }
427
+ .radius-row { display: flex; flex-wrap: wrap; gap: 24px; align-items: flex-end; margin-top: 8px; }
428
+ .radius-item{ display: flex; flex-direction: column; align-items: center; gap: 8px; }
429
+ .radius-box { width: 48px; height: 48px; background: var(--accent); opacity: .65; }
430
+ .radius-label{ font-size: 11px; text-align: center; opacity: .55; font-family: var(--mono); }
431
+
432
+ /* โ”€ Live Components โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
433
+ .live-components-grid { display: flex; flex-direction: column; gap: 40px; }
434
+ .live-component-group { }
435
+ .live-component-group h3 { margin-top: 0; margin-bottom: 16px; }
436
+ .live-demo-row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; padding: 24px; background: var(--bg-alt); border-radius: var(--radius); border: 1px solid var(--border); }
437
+ .live-demo-label { font-size: 11px; opacity: .4; font-family: var(--mono); margin-top: 12px; display: block; }
438
+
439
+ /* โ”€ Component specs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
440
+ .component-group { margin-bottom: 32px; }
441
+ .variants-grid { display: flex; flex-wrap: wrap; gap: 14px; }
442
+ .component-variant {
443
+ background: var(--bg-alt); border: 1px solid var(--border);
444
+ border-radius: var(--radius); padding: 14px; width: 210px; font-size: 12px;
445
+ }
446
+ .component-variant strong { display: block; margin-bottom: 8px; font-size: 13px; }
447
+ .component-variant ul { list-style: none; }
448
+ .component-variant li {
449
+ padding: 3px 0; opacity: .65; border-top: 1px solid var(--border);
450
+ margin-top: 4px; font-family: var(--mono); font-size: 11px;
451
+ }
452
+
453
+ /* โ”€ Breakpoints โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
454
+ .bp-list { display: flex; flex-direction: column; gap: 10px; }
455
+ .bp-row { display: flex; align-items: center; gap: 12px; }
456
+ .bp-name { font-size: 12px; width: 120px; opacity: .55; flex-shrink: 0; }
457
+ .bp-bar-wrap { flex: 1; max-width: 400px; background: var(--border); border-radius: 4px; height: 8px; }
458
+ .bp-bar { height: 8px; background: var(--accent); border-radius: 4px; display: block; opacity: .75; min-width: 4px; }
459
+ .bp-value{ font-family: var(--mono); font-size: 12px; opacity: .55; width: 72px; }
460
+
461
+ /* โ”€ Elevation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
462
+ .elev-grid { display: flex; gap: 20px; flex-wrap: wrap; padding: 24px 0; }
463
+ .elev-card {
464
+ flex: 1 1 160px; min-height: 90px; border-radius: var(--radius);
465
+ background: var(--bg); border: 1px solid var(--border); padding: 16px;
466
+ display: flex; flex-direction: column; gap: 8px;
467
+ }
468
+ .elev-label { font-size: 12px; font-weight: 600; opacity: .7; }
469
+ .elev-code { font-size: 10px; opacity: .4; word-break: break-all; line-height: 1.4; font-family: var(--mono); }
470
+
471
+ /* โ”€ Motion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
472
+ .motion-table { width: 100%; border-collapse: collapse; }
473
+ .motion-table td { padding: 5px 8px; font-size: 12px; border-bottom: 1px solid var(--border); }
474
+ .motion-table tr:last-child td { border-bottom: none; }
475
+
476
+ /* โ”€ Do's & Don'ts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
477
+ .dos-donts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 8px; }
478
+ .do-col { background: rgba(0,128,0,.05); border: 1px solid rgba(0,128,0,.15); border-radius: 8px; padding: 16px 20px; }
479
+ .dont-col { background: rgba(200,0,0,.05); border: 1px solid rgba(200,0,0,.12); border-radius: 8px; padding: 16px 20px; }
480
+ .do-header { font-size: 12px; font-weight: 700; color: #1a7a1a; margin-bottom: 12px; letter-spacing: .04em; }
481
+ .dont-header { font-size: 12px; font-weight: 700; color: #9a1a1a; margin-bottom: 12px; letter-spacing: .04em; }
482
+ .do-list, .dont-list { list-style: none; display: flex; flex-direction: column; gap: 8px; }
483
+ .do-item, .dont-item { font-size: 13px; line-height: 1.5; display: flex; gap: 8px; align-items: flex-start; }
484
+ .do-icon { color: #1a7a1a; font-weight: 700; flex-shrink: 0; }
485
+ .dont-icon { color: #9a1a1a; font-weight: 700; flex-shrink: 0; }
486
+
487
+ /* โ”€ Visual Match โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
488
+ .visual-match-grid { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; }
489
+ .visual-match-item { flex: 1 1 360px; }
490
+ .visual-match-label { font-size: 11px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; opacity: .45; margin-bottom: 10px; }
491
+ .visual-match-frame {
492
+ border: 1px solid var(--border); border-radius: var(--radius);
493
+ overflow: hidden; box-shadow: 0 2px 12px rgba(0,0,0,.07);
494
+ }
495
+ .visual-match-frame img { width: 100%; display: block; }
496
+
497
+ /* โ”€ Agent Guide โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
498
+ .agent-code {
499
+ background: rgba(0,0,0,.04); border: 1px solid var(--border);
500
+ border-radius: 6px; padding: 14px 16px; font-family: var(--mono);
501
+ font-size: 11px; line-height: 1.65; overflow-x: auto;
502
+ margin: 8px 0 16px; white-space: pre-wrap;
503
+ }
504
+
505
+ /* โ”€ DESIGN.md โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
506
+ .designmd-section pre {
507
+ background: #0f0f0f; color: #e5e7eb; padding: 28px;
508
+ border-radius: var(--radius); font-family: var(--mono);
509
+ font-size: 12px; line-height: 1.7; overflow: auto;
510
+ max-height: 520px; white-space: pre-wrap;
511
+ }
512
+ .copy-btn {
513
+ display: inline-flex; align-items: center; gap: 6px;
514
+ margin-bottom: 12px; padding: 8px 18px;
515
+ background: var(--accent); color: ${contrastColor(accentHex)};
516
+ border: none; border-radius: 99px; font-size: 13px; font-weight: 600;
517
+ cursor: pointer; font-family: var(--font); transition: opacity .15s;
518
+ }
519
+ .copy-btn:hover { opacity: .85; }
520
+
521
+ /* โ”€ Footer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
522
+ .site-footer { padding-top: 32px; border-top: 1px solid var(--border); font-size: 12px; opacity: .4; text-align: center; }
523
+
524
+ /* โ”€ Responsive โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
525
+ @media (max-width: 900px) {
526
+ .app-layout { flex-direction: column; padding: 0 16px; }
527
+ .nav-sidebar { position: static; width: 100%; }
528
+ .nav-sidebar-inner { display: flex; flex-wrap: wrap; padding: 8px; gap: 4px; }
529
+ .nav-brand { display: none; }
530
+ .nav-link { padding: 5px 10px; border-radius: 99px; opacity: .7; }
531
+ .nav-link.active { background: var(--accent); color: ${contrastColor(accentHex)}; opacity: 1; }
532
+ }
533
+ @media (max-width: 600px) {
534
+ .header-screenshot.desktop img { width: 100%; }
535
+ .swatch { width: calc(50% - 5px); }
536
+ .dos-donts-grid { grid-template-columns: 1fr; }
537
+ .typo-preview-cell { max-width: 160px; }
538
+ }
539
+ `;
540
+ }
541
+
542
+ // โ”€โ”€ Section: Color Palette โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
543
+
544
+ function buildColorSection(tokens: any, brandOverrides: Map<string, string>, rawData?: any): string {
545
+ const c = tokens.colors || {};
546
+
547
+ interface SwatchItem { name: string; hex: string; raw: string; use: string; role?: string }
548
+ const cats: Array<{ label: string; id: string; items: SwatchItem[] }> = [];
549
+
550
+ function collectCat(label: string, id: string, obj: Record<string, string | null>, useMap: Record<string, string>, roleMap?: Record<string, string>) {
551
+ const items: SwatchItem[] = [];
552
+ for (const [key, val] of Object.entries(obj)) {
553
+ if (!val || val === 'transparent' || val.includes('transparent')) continue;
554
+ const hex = rgbToHex(val);
555
+ if (hex === '#transparent') continue;
556
+ const override = brandOverrides.get(hex);
557
+ const name = override || key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase());
558
+ items.push({ name, hex, raw: val, use: useMap[key] || '', role: roleMap?.[key] });
559
+ }
560
+ if (items.length) cats.push({ label, id, items });
561
+ }
562
+
563
+ collectCat('Background & Surface', 'bg', c.background || {}, { primary: 'Page bg', secondary: 'Elevated', tertiary: 'Card bg' });
564
+ collectCat('Text & Content', 'text', c.text || {}, { primary: 'Body text', secondary: 'Secondary', muted: 'Muted / Placeholder' });
565
+ collectCat('Accent & Interactive', 'accent', c.accent || {}, { primary: 'Primary CTA', secondary: 'Secondary CTA' });
566
+ if (c.border) {
567
+ collectCat('Border & Divider', 'border', { border: c.border }, { border: 'Dividers / Lines' });
568
+ }
569
+
570
+ // Semantic colors
571
+ if (c.semantic && Object.keys(c.semantic).length > 0) {
572
+ const sem = c.semantic as Record<string, string>;
573
+ const roleMap: Record<string, string> = {
574
+ error: 'โš  Error', success: 'โœ“ Success', warning: '! Warning', info: 'โ„น Info'
575
+ };
576
+ collectCat('Semantic States', 'semantic', sem,
577
+ { error: 'Errors, destructive', success: 'Success, positive', warning: 'Caution', info: 'Informative' },
578
+ roleMap
579
+ );
580
+ }
581
+
582
+ // v9-color: Full Extracted Palette โ€” surface ALL real colors from the live capture,
583
+ // not just the capped categorized roles above. Sources: rawData.allColors (rendered
584
+ // element colors) โˆช color values inside CSS custom properties (sites like theverge keep
585
+ // their palette in token vars, not on elements). rgbToHex handles hex + rgb(); junk and
586
+ // already-shown colors are skipped. Mirrors the DESIGN.md fix so the public site reflects it.
587
+ const shownHexes = new Set<string>();
588
+ for (const cat of cats) for (const it of cat.items) shownHexes.add(it.hex.toLowerCase());
589
+
590
+ const paletteCandidates: string[] = [];
591
+ if (rawData) {
592
+ if (Array.isArray(rawData.allColors)) paletteCandidates.push(...rawData.allColors);
593
+ for (const v of Object.values(rawData.cssCustomProperties || {})) {
594
+ const val = String(v).trim();
595
+ if (!val || val.startsWith('var(')) continue;
596
+ const m = val.match(/#[0-9a-fA-F]{6}\b|rgba?\([^)]+\)/g);
597
+ if (m) paletteCandidates.push(...m);
598
+ }
599
+ }
600
+ const extraSwatches: { hex: string; raw: string }[] = [];
601
+ const extraSeen = new Set<string>(shownHexes);
602
+ for (const cand of paletteCandidates) {
603
+ const s = String(cand).trim();
604
+ if (!/^#[0-9a-fA-F]{3,8}$/.test(s) && !/^rgba?\(/.test(s)) continue;
605
+ const hex = rgbToHex(s);
606
+ if (!/^#[0-9a-f]{6}$/.test(hex)) continue;
607
+ if (extraSeen.has(hex)) continue;
608
+ extraSeen.add(hex);
609
+ extraSwatches.push({ hex, raw: s });
610
+ }
611
+ if (extraSwatches.length > 0) {
612
+ cats.push({
613
+ label: `Full Extracted Palette (+${extraSwatches.length})`,
614
+ id: 'full-palette',
615
+ items: extraSwatches.slice(0, 40).map(e => ({ name: e.hex, hex: e.hex, raw: e.raw, use: '' })),
616
+ });
617
+ }
618
+
619
+ const swatchHtml = cats.map(cat => `
620
+ <div class="color-group">
621
+ <h3>${escapeHtml(cat.label)}</h3>
622
+ <div class="swatches">
623
+ ${cat.items.map(col => {
624
+ const fg = contrastColor(col.hex);
625
+ return `<div class="swatch" style="background:${escapeHtml(col.raw)};color:${fg}" onclick="copySwatch(this,'${escapeHtml(col.hex)}')" title="Click to copy ${escapeHtml(col.hex)}">
626
+ ${col.role ? `<span class="swatch-role" style="color:${fg}">${escapeHtml(col.role)}</span>` : ''}
627
+ <span class="swatch-name">${escapeHtml(col.name)}</span>
628
+ <span class="swatch-hex">${escapeHtml(col.hex)}</span>
629
+ ${col.use ? `<span class="swatch-use">${escapeHtml(col.use)}</span>` : ''}
630
+ <span class="swatch-copied">Copied!</span>
631
+ </div>`;
632
+ }).join('')}
633
+ </div>
634
+ </div>`).join('');
635
+
636
+ const totalShown = cats.reduce((n, cat) => n + cat.items.length, 0);
637
+ return `<section class="section" id="s-colors">
638
+ <h2>Color Palette</h2>
639
+ <p class="section-desc">${totalShown} colors extracted via getComputedStyle(). Click any swatch to copy its hex value.</p>
640
+ ${swatchHtml}
641
+ </section>`;
642
+ }
643
+
644
+ // โ”€โ”€ Section: Typography โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
645
+
646
+ function buildTypoSection(tokens: any, designMd: string): string {
647
+ const primaryFont = (tokens.typography?.fontFamily?.primary || 'system-ui').replace(/['"]/g, '');
648
+ const secondaryFont= (tokens.typography?.fontFamily?.secondary || primaryFont).replace(/['"]/g, '');
649
+
650
+ // Parse typography table from DESIGN.md ยง3
651
+ const tableMatch = designMd.match(/## 3\. Typography Rules[\s\S]*?\| Role \|([\s\S]*?)(?=\n##|\n---)/);
652
+
653
+ if (tableMatch) {
654
+ const lines = tableMatch[0].split('\n')
655
+ .filter(l => l.startsWith('|') && !l.includes('---') && !l.includes('| Role |'));
656
+
657
+ const rows = lines.map(line => {
658
+ const cells = line.split('|').map(c => c.trim()).filter(Boolean);
659
+ if (cells.length < 3) return '';
660
+ const [role, font, size, weight, lh, ls] = cells;
661
+ const safeFont = (font || primaryFont).replace(/['"]/g, '');
662
+ const safeSize = size || '14px';
663
+ const safeWeight = weight || '400';
664
+ const lhDisplay = lh && lh !== '-' ? lh : 'โ€”';
665
+ const lsDisplay = ls && ls !== '-' ? ls : 'โ€”';
666
+
667
+ return `<tr>
668
+ <td class="typo-role-cell">${escapeHtml(role || '')}</td>
669
+ <td class="typo-spec-cell">${escapeHtml(safeFont)}</td>
670
+ <td class="typo-spec-cell">${escapeHtml(safeSize)} / w${escapeHtml(safeWeight)}</td>
671
+ <td class="typo-spec-cell">${escapeHtml(lhDisplay)}</td>
672
+ <td class="typo-spec-cell">${escapeHtml(lsDisplay)}</td>
673
+ <td class="typo-preview-cell" style="font-size:${escapeHtml(safeSize)};font-weight:${escapeHtml(safeWeight)};font-family:'${escapeHtml(safeFont)}',sans-serif;line-height:${lhDisplay !== 'โ€”' ? escapeHtml(lhDisplay) : '1.3'};letter-spacing:${lsDisplay !== 'โ€”' ? escapeHtml(lsDisplay) : 'normal'}">
674
+ ${escapeHtml(role || 'Sample')}
675
+ </td>
676
+ </tr>`;
677
+ }).filter(Boolean).join('');
678
+
679
+ return `<section class="section" id="s-typography">
680
+ <h2>Typography</h2>
681
+ <p class="section-desc">All values extracted via getComputedStyle() โ€” no estimation.</p>
682
+ <div style="overflow-x:auto">
683
+ <table class="typo-table">
684
+ <thead>
685
+ <tr>
686
+ <th>Role</th><th>Font Family</th><th>Size / Weight</th>
687
+ <th>Line Height</th><th>Letter Spacing</th><th>Preview</th>
688
+ </tr>
689
+ </thead>
690
+ <tbody>${rows}</tbody>
691
+ </table>
692
+ </div>
693
+ </section>`;
694
+ }
695
+
696
+ // Fallback: font size scale
697
+ const sizes = tokens.typography?.fontSize || {};
698
+ const fontEntries = Object.entries(sizes)
699
+ .filter(([, v]) => v && (v as string).includes('px'))
700
+ .sort(([, a], [, b]) => parseFloat(b as string) - parseFloat(a as string));
701
+
702
+ const rows = fontEntries.map(([k, v]) =>
703
+ `<tr>
704
+ <td class="typo-role-cell">${escapeHtml(k)}</td>
705
+ <td class="typo-spec-cell">${escapeHtml(primaryFont)}</td>
706
+ <td class="typo-spec-cell">${escapeHtml(v as string)}</td>
707
+ <td class="typo-spec-cell">โ€”</td>
708
+ <td class="typo-spec-cell">โ€”</td>
709
+ <td class="typo-preview-cell" style="font-size:${escapeHtml(v as string)};font-family:'${escapeHtml(primaryFont)}',sans-serif">
710
+ ${escapeHtml(k)} specimen
711
+ </td>
712
+ </tr>`
713
+ ).join('');
714
+
715
+ return `<section class="section" id="s-typography">
716
+ <h2>Typography</h2>
717
+ <div style="overflow-x:auto">
718
+ <table class="typo-table">
719
+ <thead><tr><th>Scale</th><th>Font</th><th>Size</th><th>LH</th><th>LS</th><th>Preview</th></tr></thead>
720
+ <tbody>${rows}</tbody>
721
+ </table>
722
+ </div>
723
+ </section>`;
724
+ }
725
+
726
+ // โ”€โ”€ Section: Live Components โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
727
+
728
+ function buildLiveComponentsSection(tokens: any): string {
729
+ const c = tokens.colors || {};
730
+ const ty = tokens.typography || {};
731
+ const br = tokens.borderRadius || {};
732
+
733
+ const accent = c.accent?.primary || '#0070f3';
734
+ const accentFg = contrastColor(rgbToHex(accent));
735
+ const bg = c.background?.primary || '#ffffff';
736
+ const bgAlt = c.background?.secondary || '#f5f5f5';
737
+ const border = c.border || '#e5e5e5';
738
+ const textPrim = c.text?.primary || '#111111';
739
+ const textMuted = c.text?.muted || '#888888';
740
+ const radius = br.sm || br.md || '6px';
741
+ const radiusFull= br.full || '9999px';
742
+ const font = ty.fontFamily?.primary || 'system-ui';
743
+ const fw = ty.fontWeight;
744
+ const fwBold = fw?.bold || '700';
745
+ const fwMedium = fw?.medium || '500';
746
+ const fwNormal = fw?.normal || '400';
747
+
748
+ const sharedBtnStyle = `font-family:'${font}',system-ui;cursor:pointer;font-size:14px;transition:opacity .15s;`;
749
+
750
+ const btnPrimary = `style="${sharedBtnStyle}padding:10px 20px;background:${accent};color:${accentFg};border:none;border-radius:${radiusFull};font-weight:${fwBold};"`;
751
+ const btnSecondary= `style="${sharedBtnStyle}padding:10px 20px;background:transparent;color:${accent};border:2px solid ${accent};border-radius:${radiusFull};font-weight:${fwMedium};"`;
752
+ const btnGhost = `style="${sharedBtnStyle}padding:10px 20px;background:transparent;color:${textPrim};border:1px solid ${border};border-radius:${radius};font-weight:${fwNormal};"`;
753
+ const btnDisabled = `style="${sharedBtnStyle}padding:10px 20px;background:${bgAlt};color:${textMuted};border:1px solid ${border};border-radius:${radiusFull};font-weight:${fwNormal};opacity:.5;cursor:not-allowed;"`;
754
+
755
+ const inputStyle = `style="padding:10px 14px;background:${bg};color:${textPrim};border:1.5px solid ${border};border-radius:${radius};font-size:14px;font-family:'${font}',system-ui;outline:none;width:260px;"`;
756
+ const inputFocusStyle = `style="padding:10px 14px;background:${bg};color:${textPrim};border:2px solid ${accent};border-radius:${radius};font-size:14px;font-family:'${font}',system-ui;outline:none;width:260px;"`;
757
+
758
+ const shadow = tokens.shadows ? Object.values(tokens.shadows)[0] as string || 'none' : 'none';
759
+ const cardStyle = `style="background:${bgAlt};border:1px solid ${border};border-radius:${br.md || '10px'};padding:20px 24px;max-width:300px;box-shadow:${shadow};"`;
760
+
761
+ const badgeStyle = `style="display:inline-block;padding:3px 10px;background:${accent};color:${accentFg};border-radius:${radiusFull};font-size:11px;font-weight:${fwBold};font-family:'${font}',system-ui;"`;
762
+ const badgeNeutralSt = `style="display:inline-block;padding:3px 10px;background:${bgAlt};color:${textMuted};border:1px solid ${border};border-radius:${radiusFull};font-size:11px;font-weight:${fwMedium};font-family:'${font}',system-ui;"`;
763
+
764
+ return `<section class="section" id="s-components">
765
+ <h2>Live Components</h2>
766
+ <p class="section-desc">Rendered in real HTML using your extracted tokens โ€” no screenshots.</p>
767
+ <div class="live-components-grid">
768
+
769
+ <div class="live-component-group">
770
+ <h3>Buttons</h3>
771
+ <div class="live-demo-row">
772
+ <button ${btnPrimary}>Primary CTA</button>
773
+ <button ${btnSecondary}>Secondary</button>
774
+ <button ${btnGhost}>Ghost / Text</button>
775
+ <button ${btnDisabled} disabled>Disabled</button>
776
+ </div>
777
+ <span class="live-demo-label">accent: ${escapeHtml(accent)} ยท border-radius: ${escapeHtml(radiusFull)} ยท weight: ${escapeHtml(fwBold)}</span>
778
+ </div>
779
+
780
+ <div class="live-component-group">
781
+ <h3>Form Inputs</h3>
782
+ <div class="live-demo-row">
783
+ <input ${inputStyle} type="text" placeholder="Default input..." />
784
+ <input ${inputFocusStyle} type="text" value="Focused input" />
785
+ </div>
786
+ <span class="live-demo-label">border: ${escapeHtml(border)} ยท focus: ${escapeHtml(accent)} ยท radius: ${escapeHtml(radius)}</span>
787
+ </div>
788
+
789
+ <div class="live-component-group">
790
+ <h3>Card</h3>
791
+ <div class="live-demo-row" style="background:${escapeHtml(bg)}">
792
+ <div ${cardStyle}>
793
+ <div style="font-size:12px;font-weight:${fwBold};letter-spacing:.06em;text-transform:uppercase;opacity:.4;margin-bottom:10px;font-family:'${escapeHtml(font)}',system-ui">Card Component</div>
794
+ <div style="font-size:16px;font-weight:${fwBold};margin-bottom:8px;color:${escapeHtml(textPrim)};font-family:'${escapeHtml(font)}',system-ui">Card Title</div>
795
+ <div style="font-size:13px;color:${escapeHtml(textMuted)};line-height:1.55;font-family:'${escapeHtml(font)}',system-ui">Supporting description text with muted color for secondary information.</div>
796
+ <div style="margin-top:16px"><button style="${sharedBtnStyle}padding:8px 16px;background:${accent};color:${accentFg};border:none;border-radius:${radiusFull};font-weight:${fwBold};font-size:13px;">Action</button></div>
797
+ </div>
798
+ </div>
799
+ <span class="live-demo-label">bg: ${escapeHtml(bgAlt)} ยท radius: ${escapeHtml(br.md || '10px')} ยท shadow from tokens</span>
800
+ </div>
801
+
802
+ <div class="live-component-group">
803
+ <h3>Badges & Labels</h3>
804
+ <div class="live-demo-row">
805
+ <span ${badgeStyle}>New</span>
806
+ <span ${badgeStyle}>Feature</span>
807
+ <span ${badgeNeutralSt}>Beta</span>
808
+ <span ${badgeNeutralSt}>Draft</span>
809
+ </div>
810
+ </div>
811
+
812
+ </div>
813
+ </section>`;
814
+ }
815
+
816
+ // โ”€โ”€ Section: Spacing & Radius โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
817
+
818
+ function buildSpacingSection(tokens: any): string {
819
+ const spacing = tokens.spacing || {};
820
+ const radius = tokens.borderRadius || {};
821
+
822
+ const spacingBars = Object.entries(spacing)
823
+ .filter(([, v]) => v && (v as string).includes('px'))
824
+ .map(([k, v]) => {
825
+ const px = parseFloat(v as string);
826
+ return `<div class="token-row">
827
+ <span class="token-name">${escapeHtml(k)}</span>
828
+ <span class="token-bar-wrap"><span class="token-bar" style="width:${Math.min(px * 3, 280)}px"></span></span>
829
+ <span class="token-value">${escapeHtml(v as string)}</span>
830
+ </div>`;
831
+ }).join('');
832
+
833
+ const radiusItems = Object.entries(radius)
834
+ .filter(([, v]) => v)
835
+ .map(([k, v]) => {
836
+ const displayV = v as string;
837
+ const r = displayV === '9999px' ? '50%' : displayV;
838
+ return `<div class="radius-item">
839
+ <div class="radius-box" style="border-radius:${r}"></div>
840
+ <span class="radius-label">${escapeHtml(k)}<br><small>${escapeHtml(displayV)}</small></span>
841
+ </div>`;
842
+ }).join('');
843
+
844
+ return `<section class="section" id="s-spacing">
845
+ <h2>Spacing Scale</h2>
846
+ <div class="token-list">${spacingBars}</div>
847
+ <h2>Border Radius</h2>
848
+ <div class="radius-row">${radiusItems}</div>
849
+ </section>`;
850
+ }
851
+
852
+ // โ”€โ”€ Section: Component Specs (from DESIGN.md) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
853
+
854
+ function buildComponentSpecsSection(designMd: string): string {
855
+ const match = designMd.match(/## 4\. Component Stylings([\s\S]*?)(?=\n## 5\.|\n---)/);
856
+ if (!match) return '';
857
+ const raw = match[1].trim();
858
+ const groups = raw.split(/\n### /).filter(Boolean);
859
+ const html = groups.map(group => {
860
+ const [header, ...rest] = group.split('\n');
861
+ const specs = rest.join('\n');
862
+ const variants = specs.split(/\n\*\*/).filter(Boolean).map(v => {
863
+ const [nameLine, ...props] = v.split('\n');
864
+ const name = nameLine.replace(/\*\*/g, '').trim();
865
+ const propHtml = props.filter(p => p.trim().startsWith('-'))
866
+ .map(p => `<li>${escapeHtml(p.replace(/^- /, '').trim())}</li>`).join('');
867
+ return `<div class="component-variant">
868
+ <strong>${escapeHtml(name)}</strong>
869
+ <ul>${propHtml}</ul>
870
+ </div>`;
871
+ }).join('');
872
+ return `<div class="component-group">
873
+ <h3>${escapeHtml((header || '').replace(/\*\*/g, '').trim())}</h3>
874
+ <div class="variants-grid">${variants}</div>
875
+ </div>`;
876
+ }).join('');
877
+
878
+ return `<section class="section" id="s-specs">
879
+ <h2>Component Specs</h2>
880
+ <p class="section-desc">Extracted CSS values per component and variant state.</p>
881
+ ${html}
882
+ </section>`;
883
+ }
884
+
885
+ // โ”€โ”€ Section: Visual Match โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
886
+
887
+ function buildVisualMatchSection(desktopUrl: string | null, mobileUrl: string | null): string {
888
+ if (!desktopUrl && !mobileUrl) return '';
889
+
890
+ const items = [
891
+ desktopUrl ? { label: 'Desktop (1440px)', url: desktopUrl } : null,
892
+ mobileUrl ? { label: 'Mobile (390px)', url: mobileUrl } : null,
893
+ ].filter(Boolean) as Array<{ label: string; url: string }>;
894
+
895
+ const cards = items.map(item => `
896
+ <div class="visual-match-item">
897
+ <div class="visual-match-label">${escapeHtml(item.label)}</div>
898
+ <div class="visual-match-frame">
899
+ <img src="${item.url}" alt="${escapeHtml(item.label)} screenshot" loading="lazy">
900
+ </div>
901
+ </div>`).join('');
902
+
903
+ return `<section class="section" id="s-visual">
904
+ <h2>Visual Reference</h2>
905
+ <p class="section-desc">Playwright screenshots captured during extraction โ€” source of truth for all tokens.</p>
906
+ <div class="visual-match-grid">${cards}</div>
907
+ </section>`;
908
+ }
909
+
910
+ // โ”€โ”€ Section: Breakpoints โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
911
+
912
+ function buildBreakpointsSection(designMd: string): string {
913
+ const match = designMd.match(/### Breakpoints\n\| Name \| Width \|([\s\S]*?)(?=\n###|\n##)/);
914
+ if (!match) return '';
915
+ const lines = match[0].split('\n').filter(l => l.startsWith('|') && !l.includes('---') && !l.includes('Name'));
916
+ const maxWidth = 1440;
917
+ const bars = lines.map(line => {
918
+ const cells = line.split('|').map(c => c.trim()).filter(Boolean);
919
+ if (cells.length < 2) return '';
920
+ const [name, width] = cells;
921
+ const px = parseFloat(width || '0');
922
+ const pct = Math.round((px / maxWidth) * 100);
923
+ return `<div class="bp-row">
924
+ <span class="bp-name">${escapeHtml(name || '')}</span>
925
+ <span class="bp-bar-wrap"><span class="bp-bar" style="width:${pct}%"></span></span>
926
+ <span class="bp-value">${escapeHtml(width || '')}</span>
927
+ </div>`;
928
+ }).join('');
929
+
930
+ return `<section class="section" id="s-breakpoints">
931
+ <h2>Breakpoints</h2>
932
+ <div class="bp-list">${bars}</div>
933
+ </section>`;
934
+ }
935
+
936
+ // โ”€โ”€ Section: Elevation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
937
+
938
+ function buildElevationSection(tokens: any): string {
939
+ const shadows = tokens.shadows || {};
940
+ const shadowEntries = Object.entries(shadows).filter(([, v]) => v && v !== 'none') as [string, string][];
941
+ if (shadowEntries.length === 0) return '';
942
+
943
+ const cards = shadowEntries.map(([name, shadow]) => {
944
+ const label = name.replace(/shadow-?/i, 'Level ').replace(/^(\w)/, c => c.toUpperCase());
945
+ const truncated = shadow.length > 80 ? shadow.slice(0, 80) + 'โ€ฆ' : shadow;
946
+ return `<div class="elev-card" style="box-shadow:${shadow}">
947
+ <div class="elev-label">${escapeHtml(label)}</div>
948
+ <code class="elev-code">${escapeHtml(truncated)}</code>
949
+ </div>`;
950
+ }).join('');
951
+
952
+ return `<section class="section" id="s-elevation">
953
+ <h2>Elevation &amp; Depth</h2>
954
+ <p class="section-desc">Shadow levels rendered live โ€” every value extracted directly from the design system.</p>
955
+ <div class="elev-grid">${cards}</div>
956
+ </section>`;
957
+ }
958
+
959
+ // โ”€โ”€ Section: Motion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
960
+
961
+ // v4-V2-T1: Motion Timeline โ€” live preview animations from extracted keyframes
962
+ function buildMotionTimelineSection(rawData: any): string {
963
+ if (!rawData) return '';
964
+ const keyframes = rawData.keyframes || {};
965
+ const animNames = Object.keys(keyframes);
966
+ if (animNames.length === 0) return '';
967
+
968
+ // Build CSS @keyframes from extracted data
969
+ const cssRules: string[] = [];
970
+ const previewItems: string[] = [];
971
+
972
+ const shown = animNames.slice(0, 12); // cap at 12 for performance
973
+ for (const name of shown) {
974
+ const frames = keyframes[name];
975
+ if (!frames || typeof frames !== 'object') continue;
976
+ const keyframeBody = Object.entries(frames)
977
+ .map(([key, props]: any) => {
978
+ const propsStr = Object.entries(props || {})
979
+ .map(([p, v]: any) => `${p}: ${v};`)
980
+ .join(' ');
981
+ return `${key} { ${propsStr} }`;
982
+ })
983
+ .join('\n');
984
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_');
985
+ cssRules.push(`@keyframes ca-preview-${safeName} {\n${keyframeBody}\n}`);
986
+ previewItems.push(`
987
+ <div style="display:flex;flex-direction:column;align-items:center;gap:8px;padding:16px;background:rgba(0,0,0,.03);border-radius:8px;">
988
+ <div class="anim-box" data-anim="${safeName}" style="width:48px;height:48px;background:linear-gradient(135deg,#10b981,#0ea5e9);border-radius:6px;animation:ca-preview-${safeName} 2s ease-in-out infinite;"></div>
989
+ <code style="font-size:11px;font-family:var(--mono);">${escapeHtml(name)}</code>
990
+ </div>`);
991
+ }
992
+
993
+ return `<section class="section" id="s-motion-timeline">
994
+ <h2>Motion Timeline</h2>
995
+ <p class="section-desc">${animNames.length} keyframe animation${animNames.length > 1 ? 's' : ''} extracted from the live page. Preview ${shown.length} below โ€” each block is animating right now with the actual extracted keyframes.</p>
996
+ <style>${cssRules.join('\n')}</style>
997
+ <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px;margin:16px 0;">${previewItems.join('')}</div>
998
+ ${animNames.length > 12 ? `<p style="font-size:12px;color:var(--fg-muted);">+${animNames.length - 12} additional animations not previewed (cap at 12 for performance).</p>` : ''}
999
+ </section>`;
1000
+ }
1001
+
1002
+ // v4-V2-T2: Component State Machine โ€” default/hover/focus/active per element
1003
+ function buildComponentStatesSection(rawData: any): string {
1004
+ if (!rawData) return '';
1005
+ const states = rawData.componentStates || {};
1006
+ const components = Object.keys(states);
1007
+ if (components.length === 0) return '';
1008
+
1009
+ const rows: string[] = [];
1010
+ for (const comp of components.slice(0, 5)) {
1011
+ const compStates = states[comp];
1012
+ if (!compStates || typeof compStates !== 'object') continue;
1013
+ const stateKeys = Object.keys(compStates).filter(s => compStates[s]);
1014
+ if (stateKeys.length === 0) continue;
1015
+
1016
+ const stateCells = stateKeys.map(state => {
1017
+ const s = compStates[state] || {};
1018
+ const bg = s.backgroundColor || 'transparent';
1019
+ const fg = s.color || 'inherit';
1020
+ const border = s.border || 'none';
1021
+ const shadow = (s.boxShadow || 'none').slice(0, 60);
1022
+ return `<td style="vertical-align:top;padding:8px;border:1px solid var(--border);">
1023
+ <div style="font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px;color:var(--fg-muted);">${state}</div>
1024
+ <div style="display:inline-block;padding:6px 12px;background:${bg};color:${fg};border:${border};border-radius:${s.borderRadius || '4px'};font-size:12px;box-shadow:${shadow};">${comp}</div>
1025
+ <div style="font-size:10px;font-family:var(--mono);margin-top:6px;color:var(--fg-muted);">bg ${bg}<br>fg ${fg}</div>
1026
+ </td>`;
1027
+ }).join('');
1028
+
1029
+ rows.push(`<tr><td style="padding:8px;font-weight:600;text-transform:capitalize;border:1px solid var(--border);">${comp}</td>${stateCells}</tr>`);
1030
+ }
1031
+
1032
+ if (rows.length === 0) return '';
1033
+
1034
+ return `<section class="section" id="s-states">
1035
+ <h2>Component States</h2>
1036
+ <p class="section-desc">Default โ†’ Hover โ†’ Focus โ†’ Active visual diff per component. Captured via Playwright state simulation.</p>
1037
+ <div style="overflow-x:auto;">
1038
+ <table style="border-collapse:collapse;width:100%;font-size:12px;">
1039
+ <thead>
1040
+ <tr>
1041
+ <th style="padding:8px;text-align:left;border:1px solid var(--border);">Component</th>
1042
+ <th style="padding:8px;text-align:left;border:1px solid var(--border);">States</th>
1043
+ </tr>
1044
+ </thead>
1045
+ <tbody>${rows}</tbody>
1046
+ </table>
1047
+ </div>
1048
+ </section>`;
1049
+ }
1050
+
1051
+ // v4-V2-T3: Z-Index Stacking Context Heatmap
1052
+ function buildZIndexHeatmapSection(rawData: any): string {
1053
+ if (!rawData) return '';
1054
+ const zMap = rawData.zIndexMap || [];
1055
+ if (!Array.isArray(zMap) || zMap.length === 0) return '';
1056
+
1057
+ // Sort by z desc
1058
+ const sorted = [...zMap].sort((a: any, b: any) => (b.z || 0) - (a.z || 0));
1059
+ const maxZ = Math.max(...sorted.map((e: any) => e.z || 0));
1060
+
1061
+ const rows = sorted.slice(0, 20).map((entry: any) => {
1062
+ const z = entry.z || 0;
1063
+ const pct = maxZ > 0 ? Math.round((z / maxZ) * 100) : 0;
1064
+ // Color tier
1065
+ const tier = z >= 100 ? '#ef4444' : z >= 10 ? '#f59e0b' : '#10b981';
1066
+ return `
1067
+ <div style="display:flex;align-items:center;gap:12px;padding:6px 0;border-bottom:1px solid var(--border);">
1068
+ <div style="width:60px;text-align:right;font-family:var(--mono);font-size:12px;color:${tier};font-weight:600;">z:${z}</div>
1069
+ <div style="flex:1;background:rgba(0,0,0,.05);border-radius:3px;height:18px;position:relative;overflow:hidden;">
1070
+ <div style="position:absolute;left:0;top:0;height:100%;width:${pct}%;background:${tier};opacity:.75;"></div>
1071
+ </div>
1072
+ <div style="flex:2;font-family:var(--mono);font-size:11px;color:var(--fg-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml((entry.selector || '?').slice(0, 80))}</div>
1073
+ </div>`;
1074
+ }).join('');
1075
+
1076
+ return `<section class="section" id="s-zindex">
1077
+ <h2>Z-Index Stacking Context</h2>
1078
+ <p class="section-desc">${zMap.length} z-index value${zMap.length > 1 ? 's' : ''} detected. Top 20 ordered by stacking priority. <span style="color:#ef4444">โ—</span> 100+ (modals) ยท <span style="color:#f59e0b">โ—</span> 10-99 (overlays) ยท <span style="color:#10b981">โ—</span> 1-9 (UI chrome).</p>
1079
+ <div style="margin:16px 0;">${rows}</div>
1080
+ </section>`;
1081
+ }
1082
+
1083
+ // v4-V2-T4: Export buttons section โ€” Copy as Tailwind/Figma/CSS vars
1084
+ function buildExportSection(tokens: any, domain: string): string {
1085
+ // Build Tailwind config snippet
1086
+ const tw: any = { theme: { extend: { colors: {}, fontFamily: {}, spacing: {}, borderRadius: {} } } };
1087
+ const flattenColors = (obj: any, prefix = ''): void => {
1088
+ for (const [k, v] of Object.entries(obj || {})) {
1089
+ if (typeof v === 'string') tw.theme.extend.colors[prefix + k] = v;
1090
+ else if (typeof v === 'object') flattenColors(v, prefix + k + '-');
1091
+ }
1092
+ };
1093
+ flattenColors(tokens.colors || {});
1094
+ Object.assign(tw.theme.extend.fontFamily, {
1095
+ sans: [tokens.typography?.fontFamily?.primary || 'system-ui', 'sans-serif'],
1096
+ mono: [tokens.typography?.fontFamily?.mono || 'monospace'],
1097
+ });
1098
+ Object.assign(tw.theme.extend.spacing, tokens.spacing || {});
1099
+ Object.assign(tw.theme.extend.borderRadius, tokens.borderRadius || {});
1100
+
1101
+ const twJson = JSON.stringify(tw, null, 2);
1102
+ const twCode = `// tailwind.config.js โ€” generated from ${domain} by Prism\nmodule.exports = ${twJson};`;
1103
+
1104
+ // Build Figma tokens JSON (Figma Tokens plugin format)
1105
+ const figmaTokens: any = { colors: {}, typography: {}, spacing: {}, radius: {} };
1106
+ flattenColors(tokens.colors || {});
1107
+ for (const [k, v] of Object.entries(tokens.colors?.background || {})) {
1108
+ figmaTokens.colors['bg-' + k] = { value: v, type: 'color' };
1109
+ }
1110
+ for (const [k, v] of Object.entries(tokens.colors?.text || {})) {
1111
+ figmaTokens.colors['text-' + k] = { value: v, type: 'color' };
1112
+ }
1113
+ for (const [k, v] of Object.entries(tokens.spacing || {})) {
1114
+ figmaTokens.spacing[k] = { value: v, type: 'spacing' };
1115
+ }
1116
+ for (const [k, v] of Object.entries(tokens.borderRadius || {})) {
1117
+ figmaTokens.radius[k] = { value: v, type: 'borderRadius' };
1118
+ }
1119
+ const figmaJson = JSON.stringify(figmaTokens, null, 2);
1120
+
1121
+ // Build CSS :root vars
1122
+ const cssLines: string[] = [':root {'];
1123
+ cssLines.push(` /* ${domain} โ€” extracted by Prism */`);
1124
+ const flatCss = (obj: any, prefix = '--ca'): void => {
1125
+ for (const [k, v] of Object.entries(obj || {})) {
1126
+ if (typeof v === 'string') cssLines.push(` ${prefix}-${k}: ${v};`);
1127
+ else if (typeof v === 'object') flatCss(v, prefix + '-' + k);
1128
+ }
1129
+ };
1130
+ flatCss(tokens.colors || {}, '--ca-color');
1131
+ flatCss(tokens.spacing || {}, '--ca-spacing');
1132
+ flatCss(tokens.borderRadius || {}, '--ca-radius');
1133
+ cssLines.push('}');
1134
+ const cssCode = cssLines.join('\n');
1135
+
1136
+ // Encode each safely for inline JS (base64 to avoid escape headaches)
1137
+ const b64 = (s: string) => Buffer.from(s, 'utf-8').toString('base64');
1138
+
1139
+ return `<section class="section" id="s-export">
1140
+ <h2>Export tokens</h2>
1141
+ <p class="section-desc">Copy these design tokens to any framework. Generated from <code>${escapeHtml(domain)}</code> live extraction.</p>
1142
+ <div style="display:flex;gap:12px;flex-wrap:wrap;margin:16px 0;">
1143
+ <button class="export-btn" data-payload="${b64(twCode)}" data-filename="tailwind.config.js" style="padding:10px 18px;border-radius:8px;background:#10b981;color:#fff;border:none;font-weight:600;font-size:13px;cursor:pointer;display:flex;align-items:center;gap:8px;">
1144
+ <span>โฌ‡๏ธ</span> Tailwind config
1145
+ </button>
1146
+ <button class="export-btn" data-payload="${b64(figmaJson)}" data-filename="figma-tokens.json" style="padding:10px 18px;border-radius:8px;background:#a259ff;color:#fff;border:none;font-weight:600;font-size:13px;cursor:pointer;display:flex;align-items:center;gap:8px;">
1147
+ <span>โฌ‡๏ธ</span> Figma tokens
1148
+ </button>
1149
+ <button class="export-btn" data-payload="${b64(cssCode)}" data-filename="tokens.css" style="padding:10px 18px;border-radius:8px;background:#0ea5e9;color:#fff;border:none;font-weight:600;font-size:13px;cursor:pointer;display:flex;align-items:center;gap:8px;">
1150
+ <span>โฌ‡๏ธ</span> CSS variables
1151
+ </button>
1152
+ </div>
1153
+ <p style="font-size:12px;color:var(--fg-muted);margin-top:8px;">
1154
+ Clicks copy to clipboard AND download the file. All values from <code>getComputedStyle()</code> on the live page โ€” no estimates.
1155
+ </p>
1156
+ <script>
1157
+ document.querySelectorAll('.export-btn').forEach(btn => {
1158
+ btn.addEventListener('click', () => {
1159
+ const payload = atob(btn.dataset.payload);
1160
+ const filename = btn.dataset.filename;
1161
+ // Copy to clipboard
1162
+ navigator.clipboard.writeText(payload).then(() => {
1163
+ const orig = btn.innerHTML;
1164
+ btn.innerHTML = '<span>โœ“</span> Copied!';
1165
+ setTimeout(() => { btn.innerHTML = orig; }, 1500);
1166
+ });
1167
+ // Also download as file
1168
+ const blob = new Blob([payload], { type: 'text/plain' });
1169
+ const url = URL.createObjectURL(blob);
1170
+ const a = document.createElement('a');
1171
+ a.href = url; a.download = filename;
1172
+ document.body.appendChild(a); a.click(); document.body.removeChild(a);
1173
+ URL.revokeObjectURL(url);
1174
+ });
1175
+ });
1176
+ </script>
1177
+ </section>`;
1178
+ }
1179
+
1180
+ function buildMotionSection(tokens: any): string {
1181
+ const cssVars = tokens.cssCustomProperties || {};
1182
+ const motionVars = Object.entries(cssVars)
1183
+ .filter(([k]) => /--motion-|--transition-|--duration-|--ease-|--spring/i.test(k))
1184
+ .filter(([, v]) => typeof v === 'string' && !(v as string).trim().startsWith('var('))
1185
+ .slice(0, 20) as [string, string][];
1186
+
1187
+ const transitions = tokens.transitions || {};
1188
+ const transEntries = Object.entries(transitions) as [string, string][];
1189
+
1190
+ if (motionVars.length === 0 && transEntries.length === 0) return '';
1191
+
1192
+ const dataVars = motionVars.length > 0 ? motionVars : transEntries;
1193
+ const rows = dataVars.map(([k, v]) => {
1194
+ const displayVal = (v as string).length > 70 ? (v as string).slice(0, 70) + 'โ€ฆ' : v as string;
1195
+ return `<tr><td style="opacity:.55;font-family:var(--mono);white-space:nowrap">${escapeHtml(k)}</td><td><code style="font-size:11px">${escapeHtml(displayVal)}</code></td></tr>`;
1196
+ }).join('');
1197
+
1198
+ return `<section class="section" id="s-motion">
1199
+ <h2>Motion &amp; Transitions</h2>
1200
+ <p class="section-desc">${dataVars.length} motion tokens โ€” easing curves and durations.</p>
1201
+ <table class="motion-table">${rows}</table>
1202
+ </section>`;
1203
+ }
1204
+
1205
+ // โ”€โ”€ Section: Do's & Don'ts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1206
+
1207
+ function buildDosDontSection(designMd: string): string {
1208
+ const sectionMatch = designMd.match(/## 7\. Do's and Don'ts\n([\s\S]*?)(?=\n## \d+\.|$)/);
1209
+ if (!sectionMatch) return '';
1210
+ const section = sectionMatch[1];
1211
+ const dosMatch = section.match(/### Do\n([\s\S]*?)(?=### Don|$)/);
1212
+ const dontsMatch = section.match(/### Don't\n([\s\S]*?)(?=### |$)/);
1213
+ if (!dosMatch && !dontsMatch) return '';
1214
+
1215
+ function parseListItems(text: string): string[] {
1216
+ return text.split('\n').filter(l => l.trim().startsWith('- ')).map(l => l.replace(/^- /, '').trim()).filter(l => l.length > 0);
1217
+ }
1218
+ const dos = dosMatch ? parseListItems(dosMatch[1]) : [];
1219
+ const donts = dontsMatch ? parseListItems(dontsMatch[1]) : [];
1220
+ if (dos.length === 0 && donts.length === 0) return '';
1221
+
1222
+ const doItems = dos.map(d => `<li class="do-item"> <span class="do-icon">โœ“</span>${escapeHtml(d)}</li>`).join('');
1223
+ const dontItems = donts.map(d => `<li class="dont-item"><span class="dont-icon">โœ•</span>${escapeHtml(d)}</li>`).join('');
1224
+
1225
+ return `<section class="section" id="s-dosdonts">
1226
+ <h2>Do's &amp; Don'ts</h2>
1227
+ <div class="dos-donts-grid">
1228
+ ${dos.length > 0 ? `<div class="do-col"><div class="do-header">โœ“ Do</div><ul class="do-list">${doItems}</ul></div>` : ''}
1229
+ ${donts.length > 0 ? `<div class="dont-col"><div class="dont-header">โœ• Don't</div><ul class="dont-list">${dontItems}</ul></div>` : ''}
1230
+ </div>
1231
+ </section>`;
1232
+ }
1233
+
1234
+ // โ”€โ”€ Section: Agent Guide โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1235
+
1236
+ function buildAgentGuideSection(designMd: string): string {
1237
+ const sectionMatch = designMd.match(/## 9\. Agent Prompt Guide\n([\s\S]*?)(?=\n## \d+\.|$)/);
1238
+ if (!sectionMatch) return '';
1239
+ const content = sectionMatch[1].trim();
1240
+ if (!content) return '';
1241
+
1242
+ const lines = content.split('\n');
1243
+ let html = '';
1244
+ let inCode = false;
1245
+ let codeBlock = '';
1246
+
1247
+ for (const line of lines) {
1248
+ if (line.startsWith('```')) {
1249
+ if (inCode) {
1250
+ html += `<pre class="agent-code">${escapeHtml(codeBlock.trim())}</pre>`;
1251
+ codeBlock = '';
1252
+ inCode = false;
1253
+ } else { inCode = true; }
1254
+ } else if (inCode) {
1255
+ codeBlock += line + '\n';
1256
+ } else if (line.startsWith('### ')) {
1257
+ html += `<h3 style="font-size:13px;font-weight:600;opacity:.65;margin:16px 0 8px">${escapeHtml(line.slice(4))}</h3>`;
1258
+ } else if (line.startsWith('- ')) {
1259
+ html += `<p style="font-size:13px;margin:4px 0;padding-left:16px">${escapeHtml(line.slice(2))}</p>`;
1260
+ } else if (line.trim()) {
1261
+ html += `<p style="font-size:13px;margin:6px 0;opacity:.8">${escapeHtml(line)}</p>`;
1262
+ }
1263
+ }
1264
+
1265
+ return `<section class="section" id="s-agent">
1266
+ <h2>Agent Prompt Guide</h2>
1267
+ <p class="section-desc">Ready-to-use prompts for Claude Code, Cursor, or Bolt โ€” paste and build.</p>
1268
+ ${html}
1269
+ </section>`;
1270
+ }
1271
+
1272
+ // โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1273
+
1274
+ async function generateShowcase(domain: string): Promise<void> {
1275
+ const baseDir = resolve(process.cwd(), 'extractions', domain);
1276
+ const tokensPath = join(baseDir, 'tokens.json');
1277
+ const designMdPath = join(baseDir, 'DESIGN.md');
1278
+ const outputDir = join(baseDir, 'showcase');
1279
+
1280
+ console.log(`\n๐ŸŽจ Generating showcase for ${domain}...`);
1281
+
1282
+ const tokens = JSON.parse(await readFile(tokensPath, 'utf-8'));
1283
+ const designMd = await readFile(designMdPath, 'utf-8');
1284
+
1285
+ // v4-V2: load raw-css for advanced sections (Motion Timeline, States, Z-Index)
1286
+ const rawCssPath = join(baseDir, 'raw-css.json');
1287
+ let rawDataDesktop: any = null;
1288
+ try {
1289
+ const rawAll = JSON.parse(await readFile(rawCssPath, 'utf-8'));
1290
+ rawDataDesktop = rawAll.desktop || rawAll;
1291
+ } catch { /* raw-css optional */ }
1292
+
1293
+ // v6: extract brand tagline from the hero text on the source page
1294
+ // (h1 textContent if it's shorter than 120 chars, otherwise meta description)
1295
+ let brandTagline = '';
1296
+ if (rawDataDesktop) {
1297
+ const heroEl = rawDataDesktop.elements?.heading;
1298
+ const text = heroEl?.textContent || heroEl?.innerText || '';
1299
+ if (text && text.length > 4 && text.length < 120) {
1300
+ brandTagline = text.trim().replace(/\s+/g, ' ');
1301
+ } else {
1302
+ // fallback: meta description if available
1303
+ const meta = rawDataDesktop.metaDescription || '';
1304
+ if (meta && meta.length > 4 && meta.length < 160) {
1305
+ brandTagline = meta.trim();
1306
+ }
1307
+ }
1308
+ }
1309
+
1310
+ // v6: confidence score from DESIGN.md completeness
1311
+ const completenessMatch = designMd.match(/Completeness: (\d+)\/100/);
1312
+ const confidenceScore = completenessMatch ? parseInt(completenessMatch[1]) : 50;
1313
+ const confidenceGrade =
1314
+ confidenceScore >= 90 ? 'A' :
1315
+ confidenceScore >= 75 ? 'B' :
1316
+ confidenceScore >= 60 ? 'C' : 'D';
1317
+
1318
+ const dateMatch = designMd.match(/Date: (.+)/);
1319
+ const sourceMatch = designMd.match(/Source: (\S+)/);
1320
+ const extractedDate = dateMatch?.[1] || new Date().toLocaleDateString();
1321
+ const sourceUrl = sourceMatch?.[1] || `https://${domain}`;
1322
+
1323
+ // Brand palette overrides (--palette-WORD โ†’ brand name)
1324
+ const brandOverrides = new Map<string, string>();
1325
+ const cssVars = tokens.cssCustomProperties || {};
1326
+ for (const [key, rawValue] of Object.entries(cssVars)) {
1327
+ const match = key.match(/^--(palette|color)-([a-zA-Z]+)$/);
1328
+ if (!match) continue;
1329
+ const brandWord = match[2];
1330
+ if (/^(primary|secondary|tertiary|bg|text|fg|background|border|divider|surface|accent|link|muted|error|warning|success|info|white|black|dark|light|base)$/i.test(brandWord)) continue;
1331
+ const val = (rawValue as string).trim();
1332
+ if (!/^#[0-9a-fA-F]{3,8}$/.test(val) && !/^rgba?\(/i.test(val)) continue;
1333
+ const hex = rgbToHex(val);
1334
+ if (!brandOverrides.has(hex)) {
1335
+ brandOverrides.set(hex, brandWord.charAt(0).toUpperCase() + brandWord.slice(1).toLowerCase());
1336
+ }
1337
+ }
1338
+
1339
+ // Screenshots
1340
+ const desktopUrl = await screenshotBase64(baseDir, false);
1341
+ const mobileUrl = await screenshotBase64(baseDir, true);
1342
+
1343
+ // Font faces from raw-css
1344
+ const fontFaceCss = await loadFontFaces(baseDir);
1345
+
1346
+ // Build all sections
1347
+ const cssStr = buildCss(tokens);
1348
+ const colorSection = buildColorSection(tokens, brandOverrides, rawDataDesktop);
1349
+ const typoSection = buildTypoSection(tokens, designMd);
1350
+ const spacingSection = buildSpacingSection(tokens);
1351
+ const liveComponents = buildLiveComponentsSection(tokens);
1352
+ const componentSpecs = buildComponentSpecsSection(designMd);
1353
+ const visualSection = buildVisualMatchSection(desktopUrl, mobileUrl);
1354
+ const breakpointsSection = buildBreakpointsSection(designMd);
1355
+ const elevationSection = buildElevationSection(tokens);
1356
+ const motionSection = buildMotionSection(tokens);
1357
+ // v4-V2: advanced sections from raw-css.json data
1358
+ const motionTimelineSection = buildMotionTimelineSection(rawDataDesktop);
1359
+ const componentStatesSection = buildComponentStatesSection(rawDataDesktop);
1360
+ const zIndexSection = buildZIndexHeatmapSection(rawDataDesktop);
1361
+ const exportSection = buildExportSection(tokens, domain);
1362
+ const dosDontSection = buildDosDontSection(designMd);
1363
+ const agentSection = buildAgentGuideSection(designMd);
1364
+
1365
+ // Nav items โ€” only include sections that have content
1366
+ const navItems = [
1367
+ { id: 's-colors', label: 'Colors', icon: 'โ—' },
1368
+ { id: 's-typography', label: 'Typography', icon: 'T' },
1369
+ { id: 's-components', label: 'Live Components', icon: 'โฌœ' },
1370
+ { id: 's-spacing', label: 'Spacing & Radius', icon: 'โ†”' },
1371
+ ...(componentSpecs ? [{ id: 's-specs', label: 'Component Specs', icon: 'โš™' }] : []),
1372
+ ...(visualSection ? [{ id: 's-visual', label: 'Screenshots', icon: '๐Ÿ–ผ' }] : []),
1373
+ ...(breakpointsSection? [{ id: 's-breakpoints',label: 'Breakpoints', icon: 'โŸบ' }] : []),
1374
+ ...(elevationSection ? [{ id: 's-elevation', label: 'Elevation', icon: 'โ—ซ' }] : []),
1375
+ ...(motionSection ? [{ id: 's-motion', label: 'Motion', icon: 'โ†’' }] : []),
1376
+ ...(motionTimelineSection ? [{ id: 's-motion-timeline', label: 'Animation Timeline', icon: 'โ—' }] : []),
1377
+ ...(componentStatesSection ? [{ id: 's-states', label: 'Component States', icon: 'โ—‘' }] : []),
1378
+ ...(zIndexSection ? [{ id: 's-zindex', label: 'Z-Index Heatmap', icon: 'โซ' }] : []),
1379
+ ...(exportSection ? [{ id: 's-export', label: 'Export tokens', icon: 'โฌ‡' }] : []),
1380
+ ...(dosDontSection ? [{ id: 's-dosdonts', label: "Do's & Don'ts", icon: 'โœ“' }] : []),
1381
+ ...(agentSection ? [{ id: 's-agent', label: 'Agent Guide', icon: '๐Ÿค–' }] : []),
1382
+ { id: 's-designmd', label: 'DESIGN.md', icon: '</>' },
1383
+ ];
1384
+
1385
+ const navHtml = navItems.map(item =>
1386
+ `<a class="nav-link" href="#${item.id}" data-section="${item.id}">
1387
+ <span class="nav-dot"></span>
1388
+ ${escapeHtml(item.label)}
1389
+ </a>`
1390
+ ).join('');
1391
+
1392
+ const html = `<!DOCTYPE html>
1393
+ <html lang="en">
1394
+ <head>
1395
+ <meta charset="UTF-8">
1396
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1397
+ <title>${escapeHtml(domain)} โ€” Design System ยท Prism</title>
1398
+ <style>
1399
+ ${fontFaceCss}
1400
+ ${cssStr}
1401
+ </style>
1402
+ </head>
1403
+ <body>
1404
+
1405
+ <div class="app-layout">
1406
+
1407
+ <!-- Sticky Sidebar Nav -->
1408
+ <nav class="nav-sidebar" aria-label="Design system sections">
1409
+ <div class="nav-sidebar-inner">
1410
+ <span class="nav-brand">Prism</span>
1411
+ ${navHtml}
1412
+ </div>
1413
+ </nav>
1414
+
1415
+ <!-- Main Content -->
1416
+ <main class="main-content">
1417
+
1418
+ <!-- Header -->
1419
+ <header class="site-header">
1420
+ <div class="header-top">
1421
+ <div class="header-meta">
1422
+ <span class="header-badge">Design System</span>
1423
+ <h1 class="header-domain">${escapeHtml(domain)}</h1>
1424
+ ${brandTagline ? `<p class="header-tagline">"${escapeHtml(brandTagline)}"</p>` : ''}
1425
+ <div class="header-meta-row">
1426
+ <span>Extracted ${escapeHtml(extractedDate)}</span>
1427
+ <span>ยท</span>
1428
+ <a href="${escapeHtml(sourceUrl)}" target="_blank" rel="noopener" style="color:inherit">${escapeHtml(sourceUrl)}</a>
1429
+ <span>ยท</span>
1430
+ <span>Confidence: <span class="confidence-badge confidence-${confidenceGrade}">${confidenceGrade} ยท ${confidenceScore}/100</span></span>
1431
+ </div>
1432
+ </div>
1433
+ ${(desktopUrl || mobileUrl) ? `
1434
+ <a href="#s-visual" class="proof-cta" style="display:inline-flex;align-items:center;gap:8px;padding:10px 14px;border-radius:8px;background:linear-gradient(135deg,#10b981 0%,#0ea5e9 100%);color:#fff;font-size:13px;font-weight:600;text-decoration:none;white-space:nowrap;box-shadow:0 4px 12px rgba(16,185,129,.25);transition:transform .15s ease;" onmouseover="this.style.transform='translateY(-1px)'" onmouseout="this.style.transform='translateY(0)'" title="See source screenshots side-by-side with extracted tokens">
1435
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
1436
+ View Source Proof
1437
+ </a>` : ''}
1438
+ </div>
1439
+ <div class="proof-tagline" style="margin-top:12px;padding:10px 14px;background:rgba(16,185,129,.08);border-left:3px solid #10b981;border-radius:4px;font-size:13px;color:var(--text-muted,#666);">
1440
+ <strong style="color:var(--text-primary,#000);">Every claim is screenshot-verified.</strong>
1441
+ getdesign.md tells. Prism <em>shows</em> โ€” every color, font, and component below was extracted via <code style="background:rgba(0,0,0,.05);padding:2px 4px;border-radius:3px;font-size:11px;">getComputedStyle()</code> from the live screenshots ${desktopUrl ? '<a href="#s-visual" style="color:#10b981;font-weight:600;text-decoration:underline;">visible here</a>' : 'below'}.
1442
+ </div>
1443
+ ${(desktopUrl || mobileUrl) ? `
1444
+ <div class="header-screenshots">
1445
+ ${desktopUrl ? `<div class="header-screenshot-wrap">
1446
+ <div class="screenshot-label">Desktop</div>
1447
+ <div class="header-screenshot desktop">
1448
+ <img src="${desktopUrl}" alt="${escapeHtml(domain)} desktop screenshot" loading="lazy">
1449
+ </div>
1450
+ </div>` : ''}
1451
+ ${mobileUrl ? `<div class="header-screenshot-wrap">
1452
+ <div class="screenshot-label">Mobile</div>
1453
+ <div class="header-screenshot mobile">
1454
+ <img src="${mobileUrl}" alt="${escapeHtml(domain)} mobile screenshot" loading="lazy">
1455
+ </div>
1456
+ </div>` : ''}
1457
+ </div>` : ''}
1458
+ </header>
1459
+
1460
+ ${colorSection}
1461
+ ${typoSection}
1462
+ ${liveComponents}
1463
+ ${spacingSection}
1464
+ ${componentSpecs}
1465
+ ${visualSection}
1466
+ ${breakpointsSection}
1467
+ ${elevationSection}
1468
+ ${motionSection}
1469
+ ${motionTimelineSection}
1470
+ ${componentStatesSection}
1471
+ ${zIndexSection}
1472
+ ${exportSection}
1473
+ ${dosDontSection}
1474
+ ${agentSection}
1475
+
1476
+ <!-- DESIGN.md -->
1477
+ <section class="section designmd-section" id="s-designmd">
1478
+ <h2>DESIGN.md</h2>
1479
+ <p class="section-desc">Narrative design system document โ€” paste directly into Claude Code, Cursor, or Bolt.</p>
1480
+ <button class="copy-btn" onclick="copyMd()"><span>๐Ÿ“‹</span> Copy DESIGN.md</button>
1481
+ <pre id="designmd-content">${escapeHtml(designMd)}</pre>
1482
+ </section>
1483
+
1484
+ <footer class="showcase-footer">
1485
+ <div class="footer-meta">
1486
+ <span>๐Ÿ—๏ธ Generated by <strong>Prism</strong></span>
1487
+ <span>ยท</span>
1488
+ <span>Confidence <span class="confidence-badge confidence-${confidenceGrade}">${confidenceGrade} ยท ${confidenceScore}/100</span></span>
1489
+ <span>ยท</span>
1490
+ <span>${extractedDate}</span>
1491
+ </div>
1492
+ <div class="footer-links">
1493
+ <a href="https://github.com/paulsainton/clone-architect" target="_blank" rel="noopener">GitHub โ†—</a>
1494
+ <a href="https://www.npmjs.com/package/clone-architect" target="_blank" rel="noopener">npm โ†—</a>
1495
+ <a href="${escapeHtml(sourceUrl)}" target="_blank" rel="noopener">Source โ†—</a>
1496
+ <a href="https://prism.ps-tools.dev/${escapeHtml(domain)}?raw=1">Raw DESIGN.md</a>
1497
+ </div>
1498
+ </footer>
1499
+ <p style="text-align:center;font-size:11px;opacity:.4;margin-top:16px;font-family:var(--mono);">
1500
+ Re-extract: <code style="background:rgba(0,0,0,.04);padding:2px 6px;border-radius:3px;">clone-architect update ${escapeHtml(domain)}</code>
1501
+ </p>
1502
+
1503
+ </main>
1504
+ </div>
1505
+
1506
+ <script>
1507
+ // โ”€โ”€ Copy swatch hex โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1508
+ function copySwatch(el, hex) {
1509
+ navigator.clipboard.writeText(hex).then(() => {
1510
+ el.classList.add('flash');
1511
+ setTimeout(() => el.classList.remove('flash'), 1200);
1512
+ }).catch(() => {
1513
+ // Fallback for http contexts
1514
+ const ta = document.createElement('textarea');
1515
+ ta.value = hex; ta.style.position = 'fixed'; ta.style.opacity = '0';
1516
+ document.body.appendChild(ta); ta.select();
1517
+ document.execCommand('copy');
1518
+ document.body.removeChild(ta);
1519
+ el.classList.add('flash');
1520
+ setTimeout(() => el.classList.remove('flash'), 1200);
1521
+ });
1522
+ }
1523
+
1524
+ // โ”€โ”€ Copy DESIGN.md โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1525
+ function copyMd() {
1526
+ const text = document.getElementById('designmd-content').textContent;
1527
+ navigator.clipboard.writeText(text).then(() => {
1528
+ const btn = document.querySelector('.copy-btn');
1529
+ btn.innerHTML = '<span>โœ“</span> Copied!';
1530
+ setTimeout(() => { btn.innerHTML = '<span>๐Ÿ“‹</span> Copy DESIGN.md'; }, 2500);
1531
+ });
1532
+ }
1533
+
1534
+ // โ”€โ”€ Sticky nav IntersectionObserver โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1535
+ (function () {
1536
+ const links = document.querySelectorAll('.nav-link[data-section]');
1537
+ if (!links.length) return;
1538
+
1539
+ const map = {};
1540
+ links.forEach(a => { map[a.dataset.section] = a; });
1541
+
1542
+ const sectionIds = Array.from(links).map(a => a.dataset.section);
1543
+ let current = sectionIds[0];
1544
+
1545
+ const observer = new IntersectionObserver(entries => {
1546
+ entries.forEach(e => {
1547
+ if (e.isIntersecting && e.intersectionRatio >= 0.1) {
1548
+ current = e.target.id;
1549
+ }
1550
+ });
1551
+ links.forEach(a => a.classList.remove('active'));
1552
+ if (map[current]) map[current].classList.add('active');
1553
+ }, { threshold: [0.1, 0.5], rootMargin: '-10% 0px -60% 0px' });
1554
+
1555
+ sectionIds.forEach(id => {
1556
+ const el = document.getElementById(id);
1557
+ if (el) observer.observe(el);
1558
+ });
1559
+
1560
+ // Activate first on load
1561
+ if (map[sectionIds[0]]) map[sectionIds[0]].classList.add('active');
1562
+ })();
1563
+ </script>
1564
+
1565
+ </body>
1566
+ </html>`;
1567
+
1568
+ await mkdir(outputDir, { recursive: true });
1569
+ const outPath = join(outputDir, 'index.html');
1570
+ await writeFile(outPath, html, 'utf-8');
1571
+
1572
+ const sizeKb = Math.round(Buffer.byteLength(html, 'utf-8') / 1024);
1573
+ console.log(`โœ… Showcase saved to ${outPath}`);
1574
+ console.log(` ${sizeKb} KB โ€” inline CSS + screenshot base64`);
1575
+ }
1576
+
1577
+ // โ”€โ”€ CLI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1578
+
1579
+ const domain = process.argv[2];
1580
+ if (!domain) {
1581
+ console.error('Usage: npx tsx scripts/generate-showcase.ts <domain>');
1582
+ process.exit(1);
1583
+ }
1584
+
1585
+ generateShowcase(domain).catch(err => {
1586
+ console.error('Error:', err.message);
1587
+ process.exit(1);
1588
+ });