roast-my-design-system 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,870 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Diagnose — step 2 of the Proffer pipeline. Renders a harvest.json into a
4
+ * single self-contained HTML report: the visceral, shareable "here's your
5
+ * mess" page with real file paths. No dependencies, no server — one file.
6
+ *
7
+ * node src/diagnose/index.mjs harvest.json [--out diagnosis.html] [--theme dark|light]
8
+ * (--theme sets the initial mode; the page itself has a light/dark toggle)
9
+ */
10
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
11
+ import { resolve, basename, join, dirname } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { distinctTypefaces } from '../lib/typefaces.mjs';
14
+
15
+ // The benchmark (Ideal-2026 norms + scanned-repo stats) ships next to the
16
+ // code so the page works offline; degrade gracefully when absent.
17
+ const BENCH_PATH = join(dirname(fileURLToPath(import.meta.url)), '../benchmark/benchmark.json');
18
+ const bench = existsSync(BENCH_PATH) ? JSON.parse(readFileSync(BENCH_PATH, 'utf8')) : null;
19
+
20
+ function arg(name, fallback) {
21
+ const i = process.argv.indexOf(`--${name}`);
22
+ return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
23
+ }
24
+ const inPath = process.argv[2] && !process.argv[2].startsWith('--') ? resolve(process.argv[2]) : null;
25
+ if (!inPath) { console.error('Usage: node src/diagnose/index.mjs <harvest.json> [--out diagnosis.html]'); process.exit(1); }
26
+ const outPath = resolve(arg('out', 'diagnosis.html'));
27
+
28
+ const h = JSON.parse(readFileSync(inPath, 'utf8'));
29
+
30
+ // Shown in the report footer; keep in step with plugin.json when releasing.
31
+ const VERSION = '3.2.0';
32
+
33
+ // Two shipped skins, same layout: 'dark' (navy glass, mint accent) and
34
+ // 'light' (lilac wash, white glass, violet accent). --theme picks one.
35
+ const THEMES = {
36
+ dark: {
37
+ pageBgHex: [0x02, 0x06, 0x17],
38
+ css: `--bg:#020617; --card:rgba(15,23,42,.6); --card-solid:#0f172a; --deep:#1e293b;
39
+ --line:rgba(51,65,85,.7); --line-soft:rgba(51,65,85,.4);
40
+ --text:#f1f5f9; --dim:#94a3b8; --dim2:#64748b;
41
+ --accent:#2dd4bf; --ok:#2dd4bf; --coral:#f97066; --amber:#fbbf24;
42
+ --accent-glow:rgba(45,212,191,.6); --blob:rgba(45,212,191,.1); --blob2:rgba(45,212,191,.05);
43
+ --hover-ring:rgba(45,212,191,.3); --bad-ring:rgba(249,112,102,.4);
44
+ --bar-grad:linear-gradient(90deg,#2dd4bf,#14b8a6);
45
+ --hero-grad:linear-gradient(140deg,#0f172a 0%,#0f172a 100%);
46
+ --glass-inset:rgba(255,255,255,.05); --glass-shadow:0 20px 40px -20px rgba(0,0,0,.4), 0 2px 8px -2px rgba(0,0,0,.2);
47
+ --cell-ring:rgba(255,255,255,.05); --hover-row:rgba(30,41,59,.3); --hover-cell:rgba(30,41,59,.2);
48
+ --chip-bg:rgba(2,6,23,.5); --nods-bg:rgba(251,191,36,.08); --nods-line:rgba(251,191,36,.35);
49
+ --ok-soft:rgba(45,212,191,.1); --coral-soft:rgba(249,112,102,.15); --amber-soft:rgba(251,191,36,.15);
50
+ --page-grad:linear-gradient(180deg, rgba(241,245,249,.045) 0%, transparent 420px),
51
+ radial-gradient(1200px 600px at 10% -10%, rgba(45,212,191,.08) 0%, transparent 60%),
52
+ radial-gradient(900px 500px at 100% 10%, rgba(56,189,248,.06) 0%, transparent 55%),
53
+ radial-gradient(700px 400px at 50% 100%, rgba(45,212,191,.05) 0%, transparent 60%);
54
+ --sans:'Manrope',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
55
+ --disp:'Sora','Manrope',ui-sans-serif,system-ui,sans-serif;
56
+ --mono:'JetBrains Mono',ui-monospace,'SF Mono',Menlo,monospace;`,
57
+ },
58
+ light: {
59
+ pageBgHex: [0xee, 0xec, 0xfb],
60
+ css: `--bg:#eeecfb; --card:rgba(255,255,255,.6); --card-solid:#ffffff; --deep:#e2ddf5;
61
+ --line:rgba(255,255,255,.8); --line-soft:rgba(15,15,26,.08);
62
+ --text:#0f0f1a; --dim:#6b6b85; --dim2:#9494ad;
63
+ --accent:#6d5bff; --ok:#1fa564; --coral:#e5533d; --amber:#d97706;
64
+ --accent-glow:rgba(109,91,255,.5); --blob:rgba(109,91,255,.12); --blob2:rgba(109,91,255,.06);
65
+ --hover-ring:rgba(109,91,255,.35); --bad-ring:rgba(229,83,61,.4);
66
+ --bar-grad:linear-gradient(90deg,#6d5bff,#4b2fd9);
67
+ --hero-grad:linear-gradient(140deg,#6d5bff 0%,#4b2fd9 60%,#2a1a80 100%);
68
+ --glass-inset:rgba(255,255,255,.9); --glass-shadow:0 20px 40px -20px rgba(75,47,217,.15), 0 2px 8px -2px rgba(15,15,26,.06);
69
+ --cell-ring:rgba(15,15,26,.06); --hover-row:rgba(255,255,255,.55); --hover-cell:rgba(255,255,255,.45);
70
+ --chip-bg:rgba(255,255,255,.7); --nods-bg:rgba(251,191,36,.14); --nods-line:rgba(217,119,6,.4);
71
+ --ok-soft:rgba(74,222,128,.18); --coral-soft:rgba(249,112,102,.16); --amber-soft:rgba(251,191,36,.2);
72
+ --page-grad:linear-gradient(180deg, rgba(255,255,255,.55) 0%, transparent 420px),
73
+ radial-gradient(1200px 600px at 10% -10%, #d8d1f7 0%, transparent 60%),
74
+ radial-gradient(900px 500px at 100% 10%, #e8e3fb 0%, transparent 55%),
75
+ radial-gradient(700px 400px at 50% 100%, #ddd5f5 0%, transparent 60%);
76
+ --sans:'Inter',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
77
+ --disp:'Inter',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;
78
+ --mono:'JetBrains Mono',ui-monospace,'SF Mono',Menlo,monospace;`,
79
+ },
80
+ };
81
+ const themeName = arg('theme', 'dark');
82
+ const T = THEMES[themeName];
83
+ if (!T) { console.error(`Unknown theme "${themeName}" (dark | light)`); process.exit(1); }
84
+
85
+ // ---------- helpers ----------
86
+ const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
87
+ const n = (x) => x.toLocaleString('en-US');
88
+
89
+ // EFFECTIVE luminance as rendered: 8-digit hex carries alpha, and a
90
+ // 50%-transparent black shows as mid-grey composited over the page
91
+ // background — sorting by raw RGB put those "all over the place" in the
92
+ // strip. Both themes ship in one file, so the ramp is emitted once per
93
+ // compositing base and toggled with the theme.
94
+ const bgLumOf = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
95
+ function hexLuminance(hex, bgLum) {
96
+ if (!/^#[0-9a-f]{6}/.test(hex)) return null;
97
+ const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
98
+ const a = hex.length === 9 ? parseInt(hex.slice(7, 9), 16) / 255 : 1;
99
+ const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
100
+ return a * lum + (1 - a) * bgLum;
101
+ }
102
+ const isGreyHex = (v) => {
103
+ if (!/^#[0-9a-f]{6}/.test(v)) return false;
104
+ const r = parseInt(v.slice(1, 3), 16), g = parseInt(v.slice(3, 5), 16), b = parseInt(v.slice(5, 7), 16);
105
+ return Math.max(r, g, b) - Math.min(r, g, b) <= 10;
106
+ };
107
+
108
+ // ---------- derived numbers ----------
109
+ const colors = h.tokens.colors ?? [];
110
+ const greysSorted = (bgLum) => colors.filter((c) => isGreyHex(c.value))
111
+ .sort((a, b) => hexLuminance(a.value, bgLum) - hexLuminance(b.value, bgLum));
112
+ const greys = greysSorted(bgLumOf(THEMES.dark.pageBgHex));
113
+ const greysLight = greysSorted(bgLumOf(THEMES.light.pageBgHex));
114
+ const nonGreys = colors.filter((c) => !isGreyHex(c.value));
115
+ const spacing = h.tokens.spacing ?? [];
116
+ const twSpacing = h.tokens.tailwind?.spacing ?? [];
117
+ // Off-scale spacing only: CSS-declared values plus arbitrary brackets. Using
118
+ // many steps of the sanctioned Tailwind scale is health, not sprawl.
119
+ const spacingTotal = spacing.length + twSpacing.filter((v) => v.value.startsWith('[')).length;
120
+ const exactDupes = h.duplicates.exactDuplicates ?? [];
121
+ // A wrapped pair (one file imports the name from the other) is composition,
122
+ // not competition — listed with a badge, but not counted as a duplicate.
123
+ const hardDupes = exactDupes.filter((d) => !d.wrapped);
124
+ const iconCollisions = h.duplicates.iconCollisions ?? [];
125
+ const families = h.duplicates.families ?? [];
126
+ const inline = h.tokens.inlineStyles ?? { count: 0, files: [] };
127
+ const comps = h.components ?? [];
128
+ const reusable = comps.filter((c) => !c.isPage);
129
+ const agentFiles = (h.context ?? []).filter((c) => c.kind === 'agent-rules');
130
+ const repoName = h.profile?.name ?? basename(h.repo);
131
+ const ds = h.profile?.designSystem ?? { kind: 'none' };
132
+
133
+ const fontFamilies = h.tokens.fontFamilies ?? [];
134
+ const typefaces = distinctTypefaces(fontFamilies);
135
+
136
+ // Token-led repos (shadcn/Tailwind semantic setups) hold most of their colours
137
+ // as deliberate CSS-variable tokens; judging them on the total punishes the
138
+ // exact architecture the ideal recommends. Health rides on the strays instead.
139
+ const colorTokens = colors.filter((c) => c.isToken).length;
140
+ const colorStrays = colors.length - colorTokens;
141
+ const tokenLed = colorTokens >= colorStrays && colorTokens > 0;
142
+ const greyStrays = greys.filter((c) => !c.isToken).length;
143
+
144
+ // Arbitrary bracket values (p-[13px], text-[10px]) — scale erosion, counted.
145
+ const arbitrary = h.tokens.tailwind?.arbitrary ?? [];
146
+ const arbitraryCount = arbitrary.reduce((sum, a) => sum + a.count, 0);
147
+
148
+ // Benchmark helpers: for a metric value, where does this repo sit among the
149
+ // scanned fleet? ("more colours than 90% of scanned repos")
150
+ function percentile(metric, value) {
151
+ const vals = bench?.stats?.[metric]?.values;
152
+ if (!vals?.length) return null;
153
+ const below = vals.filter((v) => v < value).length;
154
+ return Math.round((below / vals.length) * 100);
155
+ }
156
+ // The flattering twin: what share of the scanned fleet is messier than you.
157
+ function cleanerPct(metric, value) {
158
+ const vals = bench?.stats?.[metric]?.values;
159
+ if (!vals?.length) return null;
160
+ const above = vals.filter((v) => v > value).length;
161
+ return Math.round((above / vals.length) * 100);
162
+ }
163
+ const ideal = (metric) => bench?.ideal2026?.[metric]?.value ?? null;
164
+ const median = (metric) => bench?.stats?.[metric]?.median ?? null;
165
+ // A zero median reads as broken data ("Avg: 0 typefaces"), when it really
166
+ // means "the median repo declares none". Fall back to the fleet mean there;
167
+ // it stays an honest "Avg" and only zeroes out if literally every repo does.
168
+ function displayAvg(metric) {
169
+ const mv = median(metric);
170
+ if (mv === null) return null;
171
+ if (mv !== 0) return mv;
172
+ const vals = bench?.stats?.[metric]?.values ?? [];
173
+ const mean = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
174
+ return mean >= 0.5 ? Math.round(mean) : null;
175
+ }
176
+ const refMedian = (metric) => bench?.referenceSystems?.stats?.[metric]?.median ?? null;
177
+
178
+ // Clickable file paths — vscode:// opens the file straight in the editor.
179
+ const fileLink = (f) => `<a class="path" href="vscode://file/${encodeURI(`${h.repo}/${f}`)}">${esc(f)}</a>`;
180
+ const fontSizeTotal = (h.tokens.fontSizes ?? []).length + (h.tokens.tailwind?.textSizes ?? []).length;
181
+ const radiiTotal = (h.tokens.radii ?? []).length + (h.tokens.tailwind?.radii ?? []).length;
182
+ const shadows = h.tokens.shadows ?? [];
183
+ const offenders = h.tokens.offenders ?? [];
184
+
185
+ // Verdict severity: a finding earns a summary slot only when it is
186
+ // meaningfully over the bar (a repo one grey over the ideal is not a
187
+ // headline), and the three slots go to the worst offences, not the first
188
+ // three metrics in file order.
189
+ const candidates = [];
190
+ const effColors = tokenLed ? colorStrays : colors.length;
191
+ const effGreys = tokenLed ? greyStrays : greys.length;
192
+ if (typefaces.length > 3) candidates.push({ ratio: typefaces.length / 3, text: `${typefaces.length} typefaces, brands use 2 or 3` });
193
+ else if (typefaces.length && fontFamilies.length > 6) candidates.push({ ratio: fontFamilies.length / 6, text: `${typefaces.length} typeface${typefaces.length > 1 ? 's' : ''} declared ${fontFamilies.length} different ways` });
194
+ if (effColors > 24 * 1.25) candidates.push({ ratio: effColors / 24, text: tokenLed
195
+ ? `${n(colorStrays)} hardcoded colours outside the token set`
196
+ : `${n(colors.length)} distinct colours, a design system needs ~24` });
197
+ if (effGreys > 13 * 1.25) candidates.push({ ratio: effGreys / 13, text: tokenLed
198
+ ? `${greyStrays} hardcoded greys outside the token set`
199
+ : `${greys.length} shades of grey doing the job of 13` });
200
+ if (spacingTotal > 15) candidates.push({ ratio: spacingTotal / 12, text: `${spacingTotal} off-scale spacing values where a dozen would do` });
201
+ if (iconCollisions.length >= 5) candidates.push({ ratio: 1 + iconCollisions.length / 8, text: `two icon sets collide on ${iconCollisions.length} names` });
202
+ if (hardDupes.length > 0) candidates.push({ ratio: 1 + hardDupes.length / 5, text: `${hardDupes.length} component${hardDupes.length > 1 ? 's' : ''} implemented more than once` });
203
+ if (inline.count > 20) candidates.push({ ratio: inline.count / 20, text: `${n(inline.count)} inline style blocks bypassing every system` });
204
+ if (arbitraryCount >= 20) candidates.push({ ratio: arbitraryCount / 30, text: `${n(arbitraryCount)} arbitrary values like ${arbitrary[0].value} punched through the Tailwind scale` });
205
+ if (agentFiles.length === 0) candidates.push({ ratio: 1.3, text: `no agent rules, so your AI is guessing` });
206
+ const findings = candidates.sort((a, b) => b.ratio - a.ratio).map((c) => c.text);
207
+
208
+ let verdict = findings.length === 0
209
+ ? 'This repo is in good shape. The gap is documentation: your agent still can\'t see the system.'
210
+ : findings.slice(0, 3).join('. ') + '.';
211
+ if (bench && findings.length) {
212
+ const core = [['colors', tokenLed ? colorStrays : colors.length], ['greys', tokenLed ? greyStrays : greys.length],
213
+ ['spacing', spacingTotal], ['typefaces', typefaces.length], ['exactDuplicates', hardDupes.length], ['inlineStyles', inline.count]];
214
+ const worse = core.filter(([m, v]) => median(m) !== null && v > median(m)).length;
215
+ if (worse >= 3) verdict += ` Messier than the median of ${bench.repoCount} scanned repos on ${worse} of ${core.length} core metrics. And the median repo is already a mess.`;
216
+ }
217
+
218
+ // ---------- health scoring ----------
219
+ // Each tile is a mini scorecard: your number vs the Ideal Design System
220
+ // target, the scanned-fleet average (the low bar) and the reputable-systems
221
+ // median. Health drives the animated icon and colours:
222
+ // good = at or under the ideal → green checkmark (draws in)
223
+ // warn = over the ideal but better than the 27-repo median: "over the
224
+ // target, better than the average repo" → amber circle (pulses)
225
+ // bad = worse than the median (and the median is already a mess), or
226
+ // basically absent (<5% of ideal) → coral cross (shakes)
227
+ // The absence rule is skipped for colours and greys: a low count there is
228
+ // discipline, not a missing design system (a site with 0 pure greys is fine).
229
+ // Where the median is missing or sits at/below ideal, warn caps at 1.5x ideal.
230
+ // Zero-ideal metrics (duplicates, inline styles): 0 is good; a small tolerance
231
+ // is warn; beyond that bad.
232
+ const ZERO_IDEAL = new Set(['exactDuplicates', 'inlineStyles']);
233
+ const WARN_TOLERANCE = { exactDuplicates: 2, inlineStyles: 10 };
234
+ const NO_ABSENCE_RULE = new Set(['colors', 'greys']);
235
+ function healthOf(metric, value) {
236
+ const iv = ideal(metric);
237
+ if (iv === null) return 'info';
238
+ if (ZERO_IDEAL.has(metric)) {
239
+ if (value === 0) return 'good';
240
+ return value <= WARN_TOLERANCE[metric] ? 'warn' : 'bad';
241
+ }
242
+ if (!NO_ABSENCE_RULE.has(metric) && value < Math.max(1, iv * 0.05)) return 'bad';
243
+ if (value <= iv) return 'good';
244
+ const mv = median(metric);
245
+ const warnCap = mv && mv > iv ? mv : iv * 1.5;
246
+ if (value <= warnCap) return 'warn';
247
+ return 'bad';
248
+ }
249
+ const ICONS = {
250
+ good: '<svg class="ic ic-good" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>',
251
+ warn: '<svg class="ic ic-warn" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12" y2="16.01"/></svg>',
252
+ bad: '<svg class="ic ic-bad" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
253
+ info: '',
254
+ };
255
+
256
+ // Comparison rows inside a tile: your number vs each yardstick.
257
+ // ▼ mint = you sit at or under the reference, ▲ coral = you're over it.
258
+ function row(label, valText, refVal, value) {
259
+ if (refVal === null || refVal === undefined) return { label, val: valText, dir: '' };
260
+ if (value === refVal) return { label, val: valText, dir: 'eq' };
261
+ return { label, val: valText, dir: value < refVal ? 'down' : 'up' };
262
+ }
263
+ function tile(value, label, metric, fallbackTarget, healthValue = value) {
264
+ const health = healthOf(metric, healthValue);
265
+ const iv = ideal(metric), mv = median(metric), rm = refMedian(metric);
266
+ const pct = percentile(metric, value);
267
+ const rows = [];
268
+ if (iv !== null) rows.push(row('Ideal Design System', ZERO_IDEAL.has(metric) ? String(iv) : `~${n(iv)}`, iv, value));
269
+ else rows.push({ label: fallbackTarget, val: '', dir: '' });
270
+ const clean = cleanerPct(metric, value);
271
+ const avgNote = pct !== null && pct >= 60 && health !== 'good' ? ` · messier than ${pct}%`
272
+ : clean !== null && clean >= 60 ? ` · cleaner than ${clean}%` : '';
273
+ const av = displayAvg(metric);
274
+ if (av !== null) rows.push(row('Avg Design System', `${n(av)}${avgNote}`, av, value));
275
+ if (rm !== null && (rm > 0 || ZERO_IDEAL.has(metric))) rows.push(row('Reputable systems', n(rm), rm, value));
276
+ return { num: n(value), label, health, rows };
277
+ }
278
+ const bigStats = [
279
+ tile(colors.length, 'distinct colours', 'colors', 'a system needs ~24', tokenLed ? colorStrays : colors.length),
280
+ tile(greys.length, 'shades of grey', 'greys', 'a scale has up to 13', tokenLed ? greyStrays : greys.length),
281
+ tile(spacingTotal, 'off-scale spacing values', 'spacing', 'a dozen deliberate exceptions'),
282
+ tile(hardDupes.length, 'duplicated components', 'exactDuplicates', 'should be 0'),
283
+ tile(inline.count, 'inline style blocks', 'inlineStyles', 'invisible to any system'),
284
+ { num: String(reusable.length), label: 'components defined', health: 'info',
285
+ rows: [ { label: 'the raw material', val: '', dir: '' },
286
+ ...(median('components') ? [{ label: 'Avg Design System', val: n(median('components')), dir: '' }] : []),
287
+ ...(refMedian('components') ? [{ label: 'Reputable systems', val: n(refMedian('components')), dir: '' }] : []) ] },
288
+ ];
289
+
290
+ // Health score for the hero: the scored tiles averaged (good 100 / warn 55 / bad 10).
291
+ const SCORE_OF = { good: 100, warn: 55, bad: 10 };
292
+ const scoredTiles = bigStats.filter((s) => s.health in SCORE_OF);
293
+ const healthScore = scoredTiles.length
294
+ ? Math.round(scoredTiles.reduce((sum, s) => sum + SCORE_OF[s.health], 0) / scoredTiles.length)
295
+ : null;
296
+
297
+ // A repo with essentially no colour/spacing signal most likely has no design
298
+ // system in it at all; say that up front instead of quietly scoring zeros.
299
+ const noSystemLikely = colors.length === 0 || (colors.length < 3 && spacingTotal === 0);
300
+
301
+ // ---------- section renderers ----------
302
+ const DIR = {
303
+ down: '<span class="dir d-down">▼</span>',
304
+ up: '<span class="dir d-up">▲</span>',
305
+ eq: '<span class="dir d-eq">=</span>',
306
+ '': '',
307
+ };
308
+ const statTile = (s) => `<div class="stat glass st-${s.health}">
309
+ ${ICONS[s.health]}
310
+ <div class="num">${s.num}</div><div class="lab">${s.label}</div>
311
+ <div class="rows">${s.rows.map((r) => `<div class="row"><span class="rlab">${esc(r.label)}</span><span class="rval">${esc(r.val)} ${DIR[r.dir]}</span></div>`).join('')}</div>
312
+ </div>`;
313
+
314
+ const eyebrow = (text) => `<div class="eyebrow">${text}</div>`;
315
+ const sectionHead = (title, subtitle) => `<div class="sec-head"><h2>${title}</h2>${subtitle ? `<p class="sub">${subtitle}</p>` : ''}</div>`;
316
+
317
+ function paletteSection() {
318
+ if (!colors.length) return '';
319
+ const tokens = colors.filter((c) => c.isToken).length;
320
+ const strays = colors.length - tokens;
321
+ const tokenPct = Math.round((tokens / colors.length) * 100);
322
+
323
+ // Usage-weighted colour bar: every colour, width proportional to use.
324
+ // Beyond 60 segments the bar turns to noise; fold the tail into one cell.
325
+ const sorted = [...colors].sort((a, b) => b.count - a.count);
326
+ const shown = sorted.slice(0, 60);
327
+ const rest = sorted.slice(60);
328
+ const cells = shown.map((c) => {
329
+ const src = c.files?.[0] ? ` in ${c.files[0].file}` : '';
330
+ return `<div class="uc${c.isToken ? '' : ' stray'}" style="background:${esc(c.value)};flex-grow:${Math.max(c.count, 1)}" title="${esc(c.value)} ×${c.count}${esc(src)}">${c.isToken ? '' : '<i></i>'}</div>`;
331
+ }).join('');
332
+ const restCell = rest.length ? `<div class="uc uc-rest" style="flex-grow:${Math.max(rest.reduce((s, c) => s + c.count, 0), 1)}" title="${rest.length} more colours">+${rest.length}</div>` : '';
333
+
334
+ const rampFor = (list) => `
335
+ <div class="grey-strip">${list.map((c) => `<div class="grey-cell" style="background:${esc(c.value)}" title="${esc(c.value)} ×${c.count}"></div>`).join('')}</div>
336
+ ${list.length <= 8 ? `<div class="ramp-labels">${list.map((c) => `<span>${esc(c.value)}</span>`).join('')}</div>` : ''}`;
337
+ const ramp = greys.length >= 4 ? `
338
+ <div class="ramp-head">${eyebrow('Grey ramp · sorted dark → light · how many can you tell apart?')}</div>
339
+ <div class="only-dark">${rampFor(greys)}</div>
340
+ <div class="only-light">${rampFor(greysLight)}</div>` : '';
341
+
342
+ return `<section class="palette-grid">
343
+ <div class="glass pal-hero grad">
344
+ <div class="blob b1"></div>
345
+ ${eyebrow('Palette')}
346
+ <div class="pal-num"><span class="huge">${n(colors.length)}</span><span class="huge-unit">colours</span></div>
347
+ <p class="sub">A healthy product palette is <b>up to ~24 colours</b>: one brand hue with a few tints, one accent, up to 13 greys, and status colours.</p>
348
+ <div class="split-track"><div class="split"><span class="sp-tok" style="width:${tokenPct}%"></span><span class="sp-stray" style="width:${100 - tokenPct}%"></span></div></div>
349
+ <div class="split-legend"><span>${n(tokens)} tokens</span><span>${n(strays)} strays</span></div>
350
+ ${tokens ? '' : `<p class="sub warn-text">None of these are defined as CSS variables. Every single one is a hardcoded value.</p>`}
351
+ </div>
352
+ <div class="glass pal-usage">
353
+ <div class="pal-usage-head">
354
+ <div>${eyebrow('Colour usage')}<div class="h3d">Sized by how often each is used</div></div>
355
+ <div class="legend"><span><i class="lg lg-tok"></i> token</span><span><i class="lg lg-stray"></i> stray</span></div>
356
+ </div>
357
+ <div class="usage-bar">${cells}${restCell}</div>
358
+ ${ramp}
359
+ </div>
360
+ </section>`;
361
+ }
362
+
363
+ function spacingBars() {
364
+ const hasTw = twSpacing.length > 0;
365
+ const merged = [...spacing.map((s) => ({ label: s.value, count: s.count, off: hasTw })),
366
+ ...twSpacing.map((s) => ({ label: `·${s.value}`, count: s.count, off: s.value.startsWith('[') }))]
367
+ .sort((a, b) => b.count - a.count).slice(0, 18);
368
+ if (!merged.length) return '';
369
+ const max = merged[0].count;
370
+ const rows = merged.map((s) => `
371
+ <div class="bar-row"><span class="bar-label${s.off ? ' off' : ''}">${esc(s.label)}</span>
372
+ <div class="bar-track"><div class="bar${s.off ? ' bar-off' : ''}" style="width:${Math.max(2, Math.round((s.count / max) * 100))}%"></div></div>
373
+ <span class="bar-count">${n(s.count)}</span></div>`).join('');
374
+ const arb = arbitraryCount ? `
375
+ <div class="receipts">${eyebrow(`${n(arbitraryCount)} arbitrary values punched through the scale, one bracket at a time`)}
376
+ <div class="chips-row">${arbitrary.slice(0, 10).map((a) => `<span class="vchip bad" title="${esc(a.files?.[0]?.file ?? '')}">${esc(a.value)} ×${a.count}</span>`).join('')}${arbitrary.length > 10 ? `<span class="vchip dim">+${arbitrary.length - 10} more</span>` : ''}</div></div>` : '';
377
+ return `<section class="glass pad">
378
+ ${sectionHead(`${n(spacingTotal)} off-scale spacing values`, `a disciplined repo keeps these around a dozen · on-scale Tailwind steps (·) shown for context · off-scale in coral`)}
379
+ <div class="bars">${rows}</div>${arb}</section>`;
380
+ }
381
+
382
+ function duplicatesSection() {
383
+ if (!exactDupes.length && !families.length && !iconCollisions.length) return '';
384
+ const iconCard = iconCollisions.length ? `
385
+ <div class="fam">
386
+ ${eyebrow(`two icon sets collide on ${iconCollisions.length} names · one problem, not ${iconCollisions.length}`)}
387
+ <div class="chips-row">${iconCollisions.slice(0, 16).map((d) => `<span class="vchip">&lt;${esc(d.name)}&gt;</span>`).join('')}${iconCollisions.length > 16 ? `<span class="vchip dim">+${iconCollisions.length - 16} more</span>` : ''}</div>
388
+ <p class="sub" style="margin-top:8px">${esc(iconCollisions[0].files[0])} vs ${esc(iconCollisions[0].files[1])}</p>
389
+ </div>` : '';
390
+ const dupeCards = exactDupes.slice(0, 8).map((d) => `
391
+ <div class="fam">
392
+ ${eyebrow(`&lt;${esc(d.name)}&gt; · ${d.wrapped ? 'defined twice, one wraps the other' : `${d.files.length} implementations`}`)}
393
+ <div class="fam-rows">
394
+ ${d.files.slice(0, 6).map((f) => `<div class="mini-card">${fileLink(f)}</div>`).join('')}
395
+ ${d.files.length > 6 ? `<div class="mini-card dim">…and ${d.files.length - 6} more</div>` : ''}
396
+ </div>
397
+ </div>`).join('');
398
+ const famCards = families.slice(0, 6).map((f) => `
399
+ <div class="fam">
400
+ ${eyebrow(`${esc(f.root)} family · ${f.members.length} variants`)}
401
+ <div class="fam-rows">
402
+ ${f.members.slice(0, 6).map((m) => `<div class="mini-card"><div class="mc-main"><div class="mc-name">&lt;${esc(m.name)}&gt;</div><div class="mc-path">${esc(m.file)}</div></div>${m.usageCount ? `<span class="pill pill-mint">used ${m.usageCount}×</span>` : ''}</div>`).join('')}
403
+ ${f.members.length > 6 ? `<div class="mini-card dim">…and ${f.members.length - 6} more</div>` : ''}
404
+ </div>
405
+ </div>`).join('');
406
+ return `<div class="glass pad half">
407
+ ${sectionHead('Duplicated components', 'every duplicate is a place where your agent has to guess which one is canonical, and it picks wrong half the time. Paths open in VS Code')}
408
+ <div class="fams">${iconCard}${dupeCards}${famCards}</div></div>`;
409
+ }
410
+
411
+ function typographySection() {
412
+ if (!fontFamilies.length && !fontSizeTotal && !radiiTotal) return '';
413
+ const mini = [
414
+ typefaces.length ? tile(typefaces.length, 'typefaces', 'typefaces', 'brands use 2 or 3') : null,
415
+ fontSizeTotal ? tile(fontSizeTotal, 'font sizes', 'fontSizes', 'a type scale has 6 to 8') : null,
416
+ radiiTotal ? tile(radiiTotal, 'border radii', 'radii', 'a system has up to 10') : null,
417
+ shadows.length ? tile(shadows.length, 'shadow styles', 'shadows', '2 to 3 elevations') : null,
418
+ ].filter(Boolean);
419
+ const sizeChips = [...(h.tokens.fontSizes ?? []).map((v) => ({ label: v.value, count: v.count })),
420
+ ...(h.tokens.tailwind?.textSizes ?? []).map((v) => ({ label: `·${v.value}`, count: v.count }))]
421
+ .sort((a, b) => b.count - a.count).slice(0, 14);
422
+ const radiiChips = [...(h.tokens.radii ?? []).map((v) => ({ label: v.value, count: v.count })),
423
+ ...(h.tokens.tailwind?.radii ?? []).map((v) => ({ label: `·${v.value}`, count: v.count }))]
424
+ .sort((a, b) => b.count - a.count).slice(0, 14);
425
+ const receipts = (title, chips) => chips.length < 2 ? '' :
426
+ `<div class="receipts">${eyebrow(title)}<div class="chips-row">${chips.map((c) => `<span class="vchip">${esc(String(c.label))} ×${c.count}</span>`).join('')}</div></div>`;
427
+ const fams = fontFamilies.slice(0, 8).map((f) =>
428
+ `<div class="mini-card"><span class="mc-path">${esc(f.value.slice(0, 70))}</span><span class="pill${f.count > 2 ? ' pill-coral' : ' pill-mint'}">×${f.count}</span></div>`).join('');
429
+ return `<section>
430
+ ${sectionHead('Typography &amp; shape', '')}
431
+ <div class="stats minis">${mini.map((s) => statTile(s)).join('')}</div>
432
+ ${receipts('the font sizes, by use', sizeChips)}
433
+ ${receipts('the radii, by use', radiiChips)}
434
+ ${fontFamilies.length > 1 ? `<div class="glass pad" style="margin-top:16px">${sectionHead(`${typefaces.length} typeface${typefaces.length === 1 ? '' : 's'}, declared ${fontFamilies.length} different ways`, 'every distinct declaration is a chance for the next one to be wrong')}<div class="fam-rows">${fams}</div></div>` : ''}
435
+ </section>`;
436
+ }
437
+
438
+ function offendersSection() {
439
+ if (offenders.length < 3) return '';
440
+ const rows = offenders.slice(0, 5).map((o, i) => `
441
+ <div class="ledger-row">
442
+ <span class="ledger-idx">${String(i + 1).padStart(2, '0')}</span>
443
+ ${fileLink(o.file)}
444
+ <span class="ledger-pills">
445
+ ${o.strayColors ? `<span class="pill pill-amber">${o.strayColors} stray colour${o.strayColors === 1 ? '' : 's'}</span>` : ''}
446
+ ${o.inlineBlocks ? `<span class="pill pill-coral">${o.inlineBlocks} inline</span>` : ''}
447
+ </span>
448
+ </div>`).join('');
449
+ return `<section class="glass pad">
450
+ ${sectionHead('Where it hurts most', 'the 5 files carrying the most off-system styling')}
451
+ <div class="ledger">${rows}</div></section>`;
452
+ }
453
+
454
+ function inlineSection() {
455
+ if (inline.count < 5) return '';
456
+ const max = Math.max(...inline.files.slice(0, 6).map((f) => f.count), 1);
457
+ return `<div class="glass pad half">
458
+ ${sectionHead(`${n(inline.count)} inline style blocks`, 'styling no system can see')}
459
+ <div class="fam-rows">${inline.files.slice(0, 6).map((f) => `
460
+ <div class="mini-card col">
461
+ <div class="mc-row">${fileLink(f.file)}<span class="mc-count">×${f.count}</span></div>
462
+ <div class="mc-track"><div class="mc-bar" style="width:${Math.round((f.count / max) * 100)}%"></div></div>
463
+ </div>`).join('')}</div></div>`;
464
+ }
465
+
466
+ function componentsSection() {
467
+ const top = reusable.filter((c) => c.usageCount > 0).slice(0, 10);
468
+ if (!top.length) return '';
469
+ const rows = top.map((c) => `
470
+ <tr><td class="mono strong">&lt;${esc(c.name)}&gt;</td>
471
+ <td><span class="pill pill-mint">${c.usageCount}×</span></td>
472
+ <td class="path">${esc(c.file)}</td>
473
+ <td>${c.propsHint?.named?.length ? c.propsHint.named.slice(0, 5).map((p) => `<span class="chip chip-xs">${esc(p)}</span>`).join(' ') : '<span class="dim">none</span>'}</td></tr>`).join('');
474
+ return `<section class="glass pad">
475
+ ${sectionHead('What you actually use', 'top components by adoption · the real system, buried in here')}
476
+ <div class="tbl-wrap"><table><thead><tr><th>component</th><th>used</th><th>defined in</th><th>props</th></tr></thead><tbody>${rows}</tbody></table></div></section>`;
477
+ }
478
+
479
+ // "Where to start" — at most three moves, every one derived from this repo's
480
+ // own numbers with a receipt. Deliberately shallow: a starting push, not a
481
+ // remediation plan.
482
+ function whereToStartSection() {
483
+ const c = [];
484
+ if (agentFiles.length === 0) c.push({ score: 60, title: 'Write the agent rules file',
485
+ sub: `No CLAUDE.md, no AGENTS.md. One page naming the canonical components and the tokens file stops your agent guessing on every UI change. Cheapest fix on this list.` });
486
+ if (hardDupes.length > 0) {
487
+ // Pick the pair with the strongest copy-paste evidence: sibling files in
488
+ // app code (same basename, neither in a shared package) are true
489
+ // duplicates; a library-vs-app pair may be an intentional override, and
490
+ // "delete one" is dangerous advice there.
491
+ const inLibrary = (f) => /(^|\/)packages\//.test(f) || /(^|\/)components\/ui\//.test(f);
492
+ const strength = (d) => {
493
+ const bases = d.files.map((f) => f.split('/').pop());
494
+ let sc = 0;
495
+ if (new Set(bases).size === 1) sc += 2;
496
+ if (!d.files.some(inLibrary)) sc += 3;
497
+ return sc;
498
+ };
499
+ const d = [...hardDupes].sort((a, b) => strength(b) - strength(a))[0];
500
+ c.push({ score: 25 + hardDupes.length * 4, title: `Crown the canonical &lt;${esc(d.name)}&gt;`,
501
+ sub: `${hardDupes.length} component name${hardDupes.length > 1 ? 's have' : ' has'} competing implementations. Start with &lt;${esc(d.name)}&gt;: ${esc(d.files[0])} vs ${esc(d.files[1] ?? '')}. Decide which one is canonical, re-export it from one home, and rename or fold in the other.` });
502
+ }
503
+ if (iconCollisions.length >= 5) c.push({ score: 20 + iconCollisions.length * 3, title: 'Merge the two icon sets',
504
+ sub: `${iconCollisions.length} icon names exist in both sets, so every import is a coin flip. Pick one home and rename or delete the rest. Receipt: ${esc(iconCollisions[0].files[0])} vs ${esc(iconCollisions[0].files[1])}.` });
505
+ const strayOffender = offenders.find((o) => o.strayColors > 0);
506
+ if (colorStrays > 12) {
507
+ // When the top stray file lives beside the token definitions, say so:
508
+ // otherwise "your variables file is the top offender" reads like a bug.
509
+ const tf = h.tokens.tokenFile;
510
+ const besideTokens = strayOffender && tf && (strayOffender.file === tf
511
+ || strayOffender.file.split('/').slice(0, -1).join('/') === tf.split('/').slice(0, -1).join('/')
512
+ || /variables|tokens|theme/i.test(strayOffender.file));
513
+ c.push({ score: colorStrays, title: `Tokenise the ${n(colorStrays)} stray colours`,
514
+ sub: `They are hardcoded where no token names them${strayOffender ? (besideTokens
515
+ ? `, including ${strayOffender.strayColors} in ${esc(strayOffender.file)}, sitting right next to the token definitions. Those are the cheapest wins on this page`
516
+ : `; ${esc(strayOffender.file)} alone carries ${strayOffender.strayColors}`) : ''}. Every one is a value your agent will happily copy.` });
517
+ }
518
+ if (inline.count > 10) { const worst = inline.files[0];
519
+ c.push({ score: inline.count / 2, title: `Fold ${n(inline.count)} inline style blocks back into the system`,
520
+ sub: `These are static values written as style attributes${worst ? `; start with ${esc(worst.file)} (${worst.count} blocks)` : ''}. Dynamic positioning was already excluded, so all of these could be classes or tokens today.` }); }
521
+ if (arbitraryCount >= 20) {
522
+ // Repeated brackets read as decisions; singletons are worth a pass. Small
523
+ // px values are often deliberate optical nudges — the advice must not
524
+ // steamroll craft.
525
+ const repeats = arbitrary.filter((a) => a.count >= 5);
526
+ const nudges = arbitrary.filter((a) => /^\[-?[0-3](?:\.\d+)?px\]$/.test(a.value)).length;
527
+ const singles = arbitrary.filter((a) => a.count === 1).length;
528
+ const top = arbitrary[0];
529
+ const tokenFile = h.tokens.tokenFile;
530
+ c.push({ score: arbitraryCount / 3, title: `Give your repeated bracket values names`,
531
+ sub: repeats.length
532
+ ? `${esc(top.value)} appears ${top.count} times, which reads as a decision, not drift. If it is one, name it${tokenFile ? ` in ${esc(tokenFile)}` : ' in the scale'} so the next component (and your agent) can reach for it${repeats.length > 1 ? `; ${repeats.length - 1} more repeated value${repeats.length > 2 ? 's' : ''} deserve the same look` : ''}. The ${singles} values used once are worth a pass: keep the deliberate ones (optical nudges${nudges ? ` like the ${nudges} under 4px` : ''}, one-off layout widths) and round the accidents to a neighbouring step.`
533
+ : `${n(arbitraryCount)} bracket values sit outside the scale, almost all used once. Keep the deliberate ones (optical nudges, one-off widths) and round the rest to a neighbouring step.` });
534
+ }
535
+ if (spacingTotal > 12 && arbitraryCount < 20) {
536
+ // "Round to the scale" is only advice when a scale exists. Without one,
537
+ // the first move is to define it, next to the tokens the repo already has.
538
+ const hasScale = twSpacing.length >= 5;
539
+ const tf = h.tokens.tokenFile;
540
+ c.push({ score: spacingTotal / 2, title: hasScale
541
+ ? `Fold ${n(spacingTotal)} off-scale spacing values back to the scale`
542
+ : `Define a spacing scale, then fold ${n(spacingTotal)} values into it`,
543
+ sub: hasScale
544
+ ? `${n(spacingTotal)} values sit outside the scale you already use. Keep the deliberate exceptions and round the accidents to a neighbouring step.`
545
+ : `${n(spacingTotal)} distinct spacing values and no named scale to hold them. Define ~8 steps${tf ? ` in ${esc(tf)}, next to the tokens you already keep there` : ''}, then migrate values as you touch each file. No mass rounding.` });
546
+ }
547
+ const top3 = c.sort((a, b) => b.score - a.score).slice(0, 3);
548
+ if (!top3.length) return '';
549
+ return `<section class="glass pad">
550
+ ${sectionHead('Where to start', `${['One tweak', 'Two tweaks', 'Three tweaks'][top3.length - 1]} to increase your score.`)}
551
+ <div class="ledger">${top3.map((item, i) => `
552
+ <div class="ledger-row start-row">
553
+ <span class="ledger-idx">${String(i + 1).padStart(2, '0')}</span>
554
+ <div class="start-body"><div class="start-title">${item.title}</div><div class="sub">${item.sub}</div></div>
555
+ </div>`).join('')}</div></section>`;
556
+ }
557
+
558
+ function agentSection() {
559
+ const have = agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)} · ${esc(c.tool ?? c.kind)}</span>`).join('');
560
+ const msg = agentFiles.length
561
+ ? `<p class="sub">Your agent reads ${agentFiles.map((c) => `<span class="mono">${esc(c.file)}</span>`).join(', ')}. But none of it points at a single source of truth for components and tokens, because there isn't one yet. The numbers below are what your agent actually works from.</p>`
562
+ : `<p class="sub">No <span class="mono">CLAUDE.md</span>, no <span class="mono">AGENTS.md</span>, no <span class="mono">.cursorrules</span>. Every time your AI builds UI here, it guesses, from everything below. This is why its output looks almost-but-not-quite right.</p>`;
563
+ return `<section class="glass pad agent">
564
+ ${sectionHead('What your AI agent sees today', '')}
565
+ ${msg}${have ? `<div class="chips">${have}</div>` : ''}</section>`;
566
+ }
567
+
568
+ // ---------- page ----------
569
+ const stack = [
570
+ h.profile.framework !== 'unknown' ? h.profile.framework : null,
571
+ h.profile.typescript ? 'TypeScript' : null,
572
+ ...(h.profile.stylingDeps ?? []),
573
+ ds.kind === 'shadcn' ? 'shadcn/ui' : ds.kind === 'library' ? ds.name : null,
574
+ h.profile.monorepo ? 'monorepo' : null,
575
+ ].filter(Boolean);
576
+
577
+ const html = `<!doctype html>
578
+ <html lang="en" data-theme="${themeName}"><head><meta charset="utf-8">
579
+ <meta name="viewport" content="width=device-width, initial-scale=1">
580
+ <title>Design-system diagnosis: ${esc(repoName)}</title>
581
+ <meta name="author" content="Greg Kozakiewicz · gregkozakiewicz.com">
582
+ <link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAACXBIWXMAAAAAAAAAAQCEeRdzAAACPklEQVR4nOVWTYh5URR/PlJYEKV8LKwkZalsmI2dZmMnK8pKmaZ87JSanexEkayUhSxsZyfJNBQ7UYqVhSShfM+v/6mXmtU81+p/Frfze+/d3++ce88993Hcf2cikehZ1GazWafTPUVDLBZjLBQKnU6HMTVvcrl8Op3ebrdsNgsokUiYUVP4Pp/v9s96vd79cwZGK95sNkkgGAwC2u12lhrY3u12C/b1ei2TyV5eXjabjV6vZ6BBa51MJin8arUKWKlU4H99fSkUCo5JUQ2HQxJwuVzIYLVawcdIVSs8CQoNpMQ+Go0Aw+EwwXw+D+h2u4Vr0LRSqUSMqVQKsN1uE3Q4HEql8ng8BgIBTnDhqlSqxWIBuvP5bDAYLBbL5XIBHAwGeBuJRODvdjun0/nnPOjraDRK8TYaDcBMJkMwFosBfn9/E0wkEn9OgjYgnU6fTidQwAGcTCbw9/u9VqvFUYB/vV5RskajkRNcTiaTKR6PI9j5fE7xlstlPM/lcgTr9ToneJ/5oOB4PB70O8Tb7/f9fv9sNqPkXl9fhQsQ9f1ktVqNvUVCxA6ZRxsf5pMAxnsuVBSOwvv7+6Phk0PsBNn0OOLC9haLReqdvBL/wUNKNPnt7Q0LjeVutVqhUEij0TwU9W8BVA4JUEUul8tarcZGhpbIarWiSeA0HQ4Hkvn4+GB56VMvov6DA+z1eplR89btdsE+Ho9tNhv3jOv+8/MTFz06M3ypVMqMnTfcwPhn4djG/tue+Mf4RGom9gMt6lAx16huIwAAAABJRU5ErkJggg==">
583
+ <link rel="icon" type="image/png" sizes="192x192" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMAAAADACAIAAADdvvtQAAAACXBIWXMAAAAAAAAAAQCEeRdzAAAQAElEQVR4nO1de0xVV/Y+tJ2mVSeAEBQlokMUNFZsUDSOjqmisaNNRqcRrbE+Ym0NLWJ8jKGCkRlMO9ZowbSOjVaMUyW+CLYayoypWkypNlRrfGAc0OioKT5SO5oWrvf3zfnmrN/23HuP1lYvj/X9cXO4HDaHuz/W+tbaa69tWQqFQqFQKBQKhUKhUCgUCoVCoVAoFAqFQqFQKBQKhUKhUCgUCoUi7Hjsscd48atf/eqpp57Cl08++eTTTz8d3qdStBg8/vjjXbp06dWrl3wJGuE1vE+laBkgUUaPHr1t27YBAwaY3xLLpFCEBAjUvn37xYsX+/3+HTt2dO3a1fxWGB9M0QJAG5OWllZRUXH79u3Gxsa8vLyOHTviTXixiIiIcD+gorniMRu8fuONN3w+339s/Pjjj7m5ubQ9TzzxRFifUdGMAfaQH3Fxce+99x78161bt77//ntcnDp1asqUKZYdlKkRUoQEYnXwY8KECV988QUsEPzXDz/8cO3aNXDo2LFjv//973EPblAprQiOdu3a4bWwsBCMaWpqunPnDjgEAsGL4Z3y8vLk5GTeqXZIEQSwQNHR0bt27QJdSBrQCDLoxo0bMEV4LSkpiY2NtTQcU7ggFuXVV1+tr68ngWCBfDZwQT7hdcGCBSBZ4A8q2i5AAgmvSktLQRR4LtgeXwDwLcjq8ePHQ03zR5RAiv/GX+RBSkrK119/DZbAYQWyh6oI3z1z5kxGRgbu79ChQ7ifXdEMIFFVbm7u1atX6aoCCQT2gEPXr1/HDfv37x84cKClmSGF5ZCgffv21dXVFM5B/RcJBClNPbRly5bOnTtzBKhvOLWw/hGKcIBJHbqwIUOGMOUD/9VkIKgMun37Nm4uKiriKgfi/6eeeircf43ikUPkc1RUVH5+PvPOVNBBCUQNBCN0+fJlXDQ0NOTk5MD8mAOG769RPEJE2GClGL7s27fv0aNH/TY8CMS0EF7pxWCrPvzwQ/g+y15q1Qx1GwJNxeOPPw4LhImfMWMG2XPr1i2Qw8OFgV64jb7s/PnzWVlZpKDJHrIzbH+b4hGAqWTOfVJS0vbt2yXv7GGBhEBwdrj+8ssv8bMmV3Atalo51JpBI0ECjRgxoq6ujo4JXikUgSQKg4LGBV4/+OADF0t0qbUNgc4LF9nZ2WDDjzZc7HERCK+wPVeuXKH/Gj9+vDmgUEdktRqhVgtMLb0Y5PPu3btBCHKoKQCBURhziWVlZTExMa4xeaHZxVYOOi/O96xZsy5evAhmBDqvoBqIb0IqLV682ApmY6iBXDpaTVGrAsvmLTv9s379ejE/jTa8CUSVvW/fPigny1lHM6UPuYIvWV1k2ZRSYdTawGkeOXIkV09Z++xNIBHRuP+tt97iPsNAAuEiISEhPj6eRfhqe1obZEbhxZYuXfrdd98x/dPowNsCgWdweRMmTLCcEmkBpQ+IlZOTU1hY+Jvf/MayVznMVLWixUMI1Llz58rKSiaUxfx4EAjXTDb+/e9/T0pKspxyDjEzXA6Ljo7GsLgf7ISvxLfgKLWCsfVActDwX+fOnaP/ajQQSCB5kwSaN28e9Y25Ai80Sk1NvXDhAm7D6xtvvGGFSEyra2uRkOgdDFi9ejVXTwPXv0zgnps3b5I98HT19fWDBg3iCKZdoQbq2LHjwoULYc+gyqGZampqxo0bZ+pr2iTLIZBZDKloARACdevWraqqirnBUNQhQBqwAW6OVNu8eTMEsuVs4RCQBz169Pj8889xP36KnDt48CBLz/B7zaYflpF41BitBYB+hOzB/EEF0/xwx4UHjWicQAg6u8zMTMvJJJnjkwQvvPACkwL8KXKuoqICxLJsDokFshzNZGnisUXAXPzq1KlTSUkJCYEojPF5KFBlgxO4QMzfvXt3y47gTLPB65iYmFWrVpE0PzrglytWrOCvZv4p8NkezYegeHBwjjmLaWlply5dAnsaGhromzw0ENfeucmwuLiYkZfZbAoj07ANGzbs8OHDIqpEYOFXXL16ddq0abyfdojPoxqoxUBsBhzHnDlzMK/c7SVrqKGiMLokvP7rX//KyMjgfJuzLlJ67ty5IpW4LZpqGkzF69mzZydNmmQ5JHbpHjVCzR2YNhqPZ599ds+ePRQ990Mg7tCglIGTMpt4WDYdSSC4to0bN5JtFNEYii6SNgx8ggeESOIPyl4iRYsBJ2zq1KlwKCSNizpBCQQSUGvn5uZaRhwnIJ8mT5584sQJWDWpeQVpOD449IMNjLN79+6UlBQYMK0cannAhHXs2PGvf/0rdUkgdYISiItf1dXVQ4cODTos9RDlM9hDcwUm0RThF5FJjOOA0tLSuLg4SytfWwrMxB1ETFVVFaOk+yQQLdDatWvbtWsXWH9IFQyjwlURYQk5xPCNYgsXN2/eZOEsSMz9QE/YUBo1a5grnQsXLoQ9oJe5H7AC+uLFi7NmzZLRZGR2b8VFTk7O+fPnzWhOqs/IJNFblERAdnZ2ZGQkRoMBU1/WfCFL5bAfCQkJmzdvDtw66AEmcvbu3QvpbRnL75xyfBkVFQUGbNiwgZ4LBDLLPwizEo0MwwN88803bAGr66zNFEIdmaEXX3wRoTgDpaB1hqEI9Oabb3IEcEUcopgNcKu2tjawJYMHgfBaU1PDeo+gqUVF+CGmQnI2q1evpluh1A1FIJcYOn369PDhwy27fJFBO4flehZeQS/qJCa1PQjE5BClEn5KqdPcIQtPmObU1NQjR45IVtAXAk3OAhbVD/D222+ziYJZvAFS8svY2Fi2ZKBMNrnoczZymASSnKSrhbmiOULiZMx3VlYWd1NwhaEpWP8N03KQSZjy0aNHWwGtgGQldcSIEWzJQPXj8oDmsLK8CkO1adMmRmGEiujmCxII079lyxZOIdyNaRgC2cNwCRegGveeWk7GmVvJoIRkUfb999+nUQlMAbhGZlqIMd20adNkLUwQ3g9KERKYm/T09H//+98UH6yfD0UggtoZN+fm5lKpmHIK79B/DRkyhNE7TZo3gTAa80DwpCkpKXw2rqoqgZo1uHrKWeSqAuYyFHUwwZhmZp9xc1paWuCA0k4qMzPT7zQEpunyIBBvwK9et24dx3nMgcke11qbIpzgNMMH7dq1iwKF29rNdF8gpILsk08+oXx2zSiThz179vzoo4+YJ5RUoQeBKIBOnDgxYcIE2TsmEi0wIjMX/CNC4CF/fm0bsBOc6VGjRsGi0AA0Ou1XPQjESBtKZdGiRZxFieYw5ZBTkZGRuB43bhzuIS0C/VcoAm3bti0uLs4lgPjAv/71r+Pj4zMyMiRpyYUO8XFKoEcKLl3JwU1kBqVuKP9FR0OlcvTo0d/97nccykUgCpclS5b4nYaKQevRXCNzHSMvL0+e0EUF0B2kPHToUHFxMfOfUOvMgiqBwgDRuQcPHnQ13/AgEHmGOxG1gXyiUQQ0Hv3795c9ZaGqGV1u0W/3dn3uueesAOrgUbmkv3z5ctx28uTJF154wVziUAKFAZyA1157jdWl98kezjRiq7lz53IcF4Ho1LKystjkJWhDDxeBWA6Lm8EPUzi7EBMTs2/fPtoz2L/BgwfzTi7XK4HCgB49eqxdu9Zn52BM9nhMNgXQP//5z2eeeQYzBLciHelFSst5Gty7c0/zwzj/22+/ZT+hqKgoyzAqUqwNcc1cQ0NDA15ra2tl/xBsIcWQ1FMrhx4u+MliSo4cOcJCHNPReAheZoDeeecdy146ZWMNc6aBsWPHsnj+nmUhPievjd+4fft2JgVYne0yafhF4DrtGTPm+Fm40W7dullO4jvC7p9nPpIS6GGBn+zKlSu5dgH1KksNQavGxDJx7w4oYhmLX5wwEgivRUVFMBIY08MC8RfR/DTZSYFXXnkFcVaEE7cLKTlscnLy6dOnxaqBSXCR4P2KFSuYSohw9iTRqWn68aEjKSkJnogEMqP3oJWHpvlZt24d5AgXLjhUhFHT2KlTJ6hyzjSzSqFsD9dWeee5c+egaayAdjCyUQT0ovekt+UZU0xpzp8/H9Sh85LYXjgUts+3tUKCF+hcObhJUjVBS1fF/PDO7Oxsy55UOeAywum9igvomLq6Oi5+eStoRnMk0NatW1n9QxLIozJTlZCQsG3bNgptmiumEkip48ePT5kyhX9XZGQka5JECYXjM269iDC67JaVlfmdY1PEp4TyX3gT0wax8tlnn1GpYBzhomwJwjtQKix8DtqR0wR3aLAyevLkyXRA5tZmUIEb7DMyMiCx+Rg+p5MVwd914MABbrCnBlIB9FDAdXJ+rH379j158qRUCTaFkD5kD1N8nKrCwkIWWpidD2SqEhMT9+/fz+ULb/Y0Ov3IceeJEyfg+CyjLywGhB6i+YFRWbp0qaQ6fU4VkYzD0oBPP/20d+/eLApQ2/OwIIdz5+bmskyHXiYUe4RDjLShW1n9Y86QlP6AnTNmzJBhvQkku+Iht9esWcNBZAOGmSCANuJGEdYrNhkrLTRjkEH8E2D8QDvzkRS/MEggSGDTToTyXC5rAVRWVgYWH4JMXOmEFYFS4Z0e6sfFodra2jFjxtADCi/5Sv81bdo0bv0JmqBqsivwWTKLaCA/P5/PpocD/fIQB/Hb3/6WPcK89yybM0QP8qc//YkT41pGIIEw7NWrV71beZhg6eOePXvw4+aqluVsb4UhASnfffddv3P4RlAaSaEIl3ghp+TvffQfcmsG/6HBoWXLlkkUcz/mh5OH2Kpfv35WQPEG/QX+77l6+pPMD6QxforjuE5j4dM+//zz33zzDYMvqQwhhyS3Kc/JNZmvvvqKJ9irEfqFwZnu2bMn08TSO9ybQJxpzNOOHTtoJFwbtThPUOXMKt1TPhOsXsWTDB8+XIRz4NOC6ySlz9jFEbTitslJoOOB4WrhFh/ZB9u2MH36dL/TEqopdOhu/lvj/tOnT7/66qscwUUgfvnKK694Zw6DEmj9+vUwXS7NK0xKSkrikj41kDeBhEO8LigosO5uU6T4WeCsYErYeb7JVjbeAoiTwZmurq7myqU5mlxERUVRqcCDeJCGv8jn1DQipnv99dctw9e4jNDEiRO50dGjxNYXsDcIgBKaOXNm4ICKBwfdBMQB5DN1rrej8Tmnn9LTvfXWW+ZoInU57HPPPcflC4/VUwnluBbB4sPevXtLupl4ygavN2zYIKLKgz3SKg/XTFaVlpYmJydbSqCfCXPXKYEwShIq3sKZW1QZKB0/fnzkyJHmOJLqpf/Ky8trsqMh79VTn1P6Y26Ihlg22yfIyZhgwLFjx/xON0UPC8RTzPjYJBA7FcE5ujrFKn4aWIsukmXgwIGffPKJ3+mG4YFGo4UqsHXr1i5dupgjy0IBJrtr165yHpR3AZD04sSUwze9+OKLlj3NPDRDRubF/PnzeXJv0DU102fRIyOu5OC1tbWI3Sw9ROHngytfmB7+I86ZM4dpYuKeHOJ/840bN9hP3jyqfiQsIQAAD1ZJREFUUvpmWrYqP3v2LEjZ0NBgzrQpSpocqybFh5BiiYmJVkDykK8xMTEVFRU+p9jDg0Bi1bidDfdDjfGcMhamKR4csu/Csl3DBx984Lc3c92TQNS5IAQ4BPmckZFhGZuXWaMjBNq4caP0qwuMj1wEwiuL10A7VzQX4VTOg+79+/evr69nirnJ1vuhCESJBki3IZY1mmu9igcHOETzM2rUqAMHDrCfnGRWvNM/sD1g25///Ofo6GhzTOm8YdlFsTU1NX57t2HjvbZz0IVh5MOHD/fp08cKSBZT/eDXLVq06OrVq01OqHhPCwSe0V4eOXKE8lnPkvoFwCI9NsooKipiMIyZBi1YROYL3fiHJKurqxs3bpxlmx+MI//TEivNmDFD9kQLHUMRiAUhuKGgoCCwG7AgJSXlyy+/5NN6VJiYwF/ENyHMuStNClsf3cfdioHJnj179j/+8Y+LFy9KWRYbXAZdNpd39u7dm5qaKuOw0k++7Ny5c3l5udSYehCoyRHR+NWQxkOGDOEIQSd40qRJ0jgxlI00eclO0ywWGDp0qEg0JdDPgiuuwX/kgAEDcnJySktLEafAQYialn90mRsqaCju/fv3L1iwAD9oejGxQ6zzktROUAKZpKQzqqysRGwoq10CRkyI6YqLi/lU97MlyOdUmeFN6G6zrkgJ9EvCtByDBg3Kz89HIM3gxecsM7k8mpTsnDhxAh4QUTeCeTnrFDEO5JFrUdaDQDQ/8HdgJKfWtdhJkQ5ScvXU5ynRzPH5kBcuXEDkL/8tSqCHCGqj+Pj4559//r333jt16hRnl1ES0zk+p4iMEpXq+/PPP8/Ly+Nput27d6+qquLiV6CvMSdY1skxGn5XQkIC66lduwcZM0Ie+Z06f4+VFpOjlM8wq/369WNJGv2XNvH4hUFxYP5T4oPu2LFjWloatPCmTZuYuJPVA+nz4jdw6dIlBPZ/+9vfSkpKGDx7z3GTU3VKe7Z161bJ95jgZCM027Fjh//uI1q9CcQv8RgYWepVBOH6qFs5+D/Kne18B/+4SUlJf/zjHwsLCz/++GOwhE7klg36OFoFYRKdlyT6glogYQ9zxKdPnwZT5TFMC0RdNW3atPr6epHP90Mg8hKGjQe1cFuPuaFM8VAgu664DVQ+a7Bq4MCBM2fOhI05dOiQHK5Dt8IjUfilubQuGSDOrsy3TD/ZJiLXups9liPRuHqKm++TQCKtEAzCM1pOnKgW6NFBasS4T9l8f8yYMWvWrIHDgj6VIwrE3lAVSaKPGYGmu+vzxfwwzF69erVlt/kx9+4I0tPTjx49ituYQvQgkM8p4ZCOIsuXLwdvJFeuGihsoGEw17EhkhB/bd68GQ4Iro3N7WSNk6QBva5fv06SueZeTsA4duwYj3Iy64pMIDBkSTXzjd4WiG/Sf4HirhpW3RsfNgR+6PBxT9tISUmZPXs2FZKsPf3gQGJ+KdDhApZ4wJUrV8bFxWE02QAvRgIGIzExEW6I4v27777jwkhQAvnsGjfwWI7thfxnsQAeMrDVS1g+xrYL7w8dPqJbt24jR47Mzc2trKwU0rA5Jju8kkzSVlxYtWrVKi6Syy+SbsC4gHHiRkdQh9S8pwtrchZZs7KyLGcftKuzhxIozKDK5sqXWVsTGRnZu3dvBD7FxcWHDx8WS4MZpf2gNiIVaI3OnDlTVla2ZMmSIUOGSF6bO2XxW0AvUkcSm94uTBwZ/BcPWjCDSrMyRBFOUFxLJ01WF5laG/4I+qOgoAAOiPXLorVZoMMaj0Yn/sf7Bw4cKCoqmjx5ct++fSl7Y2NjWXwousqjXpsuTGoK8vLy2CpEN/G0DEjaRppBE5DG06dPp9bmIbr0Xz5nQyAMjJwBDVy7dg1R/Zw5cwYPHjxv3jwKbXPxK5TVaTJOacVPsdGnBlwtD7LoHWGczI13evXq9dprr+3evRtam5NN34QLRGqsJJGNpADePH/+vPd2DpfbEmO2c+fOnj17WnqmWMsF2YOY3zWFCQkJ0NrwL9Ao0n8IUfrly5dhe7jZg0cj+p2mY77QxUku80MCXblyZerUqbSCaoFaMLj3QwjETmF0c9DazzzzDKZ5w4YNbFBnam14NDlaSnJLPs9eM03GXsezZ892797d0gPCWxkYtZnyyLIrX4cOHTp37tyPP/4YE+83ADLBi0nwb+a1G53TCwMJBLe4fft2uk7detE6QSPkcm2IvDIzMxH8Hzx4kN16aYFu3LjB5KSscIXqHEKjdeHChZdfftnso6hotSCTYC1k02D79u0HDBgAg7Rr164zZ87IRhFRx40BcBGIW61ddk7RykFVFB0dLWYpJiYGcXhBQQEIAX0tCxS+u/vFCoGYVYL0ZqNqRRuC6Fxm/GRDKhRMXFxcp06dJk2a9P7773/99deNTpcWrrKZ5odppJqamj/84Q8aurdpBJZb4MuoqCi4tuzs7C1btrBc32/XIXGpn2tteKesrAx3muf0hOmPUDQbmGSCcerTp8+ECRP+8pe/fPrpp6KQuIIBLF682DKOLQ/fUyuaGVztRDp06IDgf+HChTt37jx16hSZdOjQoUGDBllOR2k1P4r/gUlt5iTBJDOZhHcgelatWnX8+PElS5aAWIzslECK/4dpe+QcBbNvEF67dOnCBXzpKK1QuGEyQ2yMuRnIUu2suE+IUpbt7rrpXfETYO48lFVbNT+PDuYHLamXlvXpmwSSHe8t609ohTAby0UEbHluhjMUEYBwP1FbglCEmxYsJ2bmPzRXFczjB10bG3Ta2iioNy07t9utW7esrKx58+YlJSXxu+3bt5dCdGZcWEIvDFMCtXXIlNO6rFix4tatWzU1NRs3bszMzIyNjZU7QaZ27dpJeY2exK74L+ieZOLl4JLr16+fPHmyrKyssLBw3Lhxru5jrlMjTQ2rBGpbkHaqnPjXX3/90qVLV69eNbtFV1dXFxcXz549Oz09XZoTyI+zpNBcsFQatUWQARMnTmRl++3bt3lQvFnQXlVVtWjRoqFDh0IksdGp68cVbRdkwOjRo7/44gsSiG1QuZePp6+TT/BuFRUVkNspKSk8XJeIcHpAKdoi6HT69eu3c+dOv3N8SZPTkJWFf+Y2ie+///7y5cslJSXjx4/v2rWrGeFLXbOiDYEESkxMZAuwwHr1RrsJJvdFyJkBkEpnzpzZt2/f22+/PWrUKB6MGmF31dCIrG2B8/3kk08uX77ctQXdbJUiZBITJUWlp06d+uijj2bOnAmR5GovL/vkufVCidU6QQUzf/58OimhjnSIDuwTTe/GbTQEIjgopPz8fAT/vXr1kgMlZcuOHAytaG0ggaZPn474SwSQ6whSj63Et27dMndvQWtv2bJl1qxZffv2RcjmsjpBWyAqWjbod8aMGVNbW8tNn/dPIN7DBuRmo1ZQCmHdihUrEN9BYJnncymBWhuYUUxPT6+urm50DlDyGyexezTHkIaHuICyZudo5gKa7KMCv/322wMHDuTm5o4YMYJa27IpK8VfKrpbPGiBevbsWVpa6ndOmwt1frYL5s3SO4yH/UgeEjeARjBv5eXlOTk5qamporXN2viw/f2KnwlOXnx8/Jo1a/zOUSb3wx7CRTXxbjRONEWikM6dO1dZWbl27VpEbT169JBn0DYaLRgkEATv0qVL/c65u/dPIBd1mgL6H0ijVrNNwsmTJ0tKShYtWjRs2DDXQpuihUE6Ms+ZM0eczgMQSGgU2ECDkDzkDRu8Lioq4sKIKqGWCpm5zMxMupsHZo/POIkyEGxBx46IVOifffbZ8OHDdRm/ZSPC2aTXv3//CxcuSGenOz8RQiB4Kx5U6LJGZM+VK1e4ZrJ69WoIL7OaVtFSQRmUnJzMM00ejEB37LMsmJ6+YzfKZIQvBMI7Fy9eZLJxwYIFrsoQRQsG4+rExMS9e/f6nCbOD0AgJoQYxrucF8+o89unZE6dOhXCmcdG8wHUhbVscCLj4uI2bdrU6Jzd/FPZI8z7jw26M+lnSMP21VdfjR071rKVe5cuXVi6j1dZO1O0SDChFxUV9c4774hYCRTI9+QQzQ+VuHlMmM8+lL68vDwtLc2y7Q0PRmUX6cCGm4oWBhIIPiUrK4sEkmX5n0QjsOfatWty5CVtEsY5e/bs2rVreSahnLChaD3gjMIkTJw4kRkaCaPM8iBvAvntxvJwXo32keF3bE2NNxsaGt58801ZCOPujrD+uYpfGuJBBg8eXFdX53fOew9KoFDWSOwWrmGHWOOBsO7ll1/mIYfgDYycbOdQ4dwK0b17d0RJ1ED3QyDhEC/otuDCGMxXVlaOGTNGWl5atqnj6clKoNaJ6OjoqqqqO/YxXmZ5a1ACNRmlZ36jCzgF0J49e9LT0zmsmSrUM29bG0xLAF+zY8cOltCLFxO6mEySL+84K1zcU0YltGnTJu60ZzW0pTvIWjFMAsFUFBUVkQeuQCwogXxO0Q+DdryeO3du2bJlpIsUaeiZga0fsiafnZ196dIlWpSgBDIrfoQ9sDp45/jx4y+99BLHYcNUbsmw7m6UqWiFEKmLSL6+vp4E8hn1Yi4C+Zyzc3gcMyXz2LFjaXXkxMIIo4NYuP9ExcOETPCzzz7LY7apgcAP023xAu9fv34dDKPQxuvmzZtFMovDinA6nWmiufVDittjY2O5T57rEjdu3JCqZylOZU0Pz0fGxbvvvisnBJqWRmvm2xY40/BB5eXlLNnhfh1TBgmBmPKBrVq6dClLCiMjI2WPswyoBGpDEOOxbt068oMLGkEzh3gf7JkyZYplc84kjQyoBGpbkJleuXIldbGcNSmA7aEqqqioGDZsGO/XKF3xXwiBcnJyGMmTRuANTwukC4NZWr9+ff/+/XmzFmMo/gch0EsvvVRbW8uc8h2n2RRX6SGZCwoKUlNTeaerqXTYHl3RHCAMyMjIqKmp8dvdW2Q7DswPWLVs2bK4uDjLdlu8X/vDK9zo06fPwYMHuYGLeULg8OHDU6dObdeuHUMt6bOhSlnhRufOnffs2UMCsaMUJPPgwYMto4+93KwEUtwFdjssLi7mYipo9OGHHyYnJ1tORVjQH1EOKf4HWhd2vLt582ZRUVF8fLz5LRMRAXjUj6tobmBMvnjx4rq6uvz8fGlWH2o5XUmjuAskxLBhwyZNmsRDM0T0ROhZ2or7AQ/EsGyf1aFDB3N1Xe2NQqFQKBQKhUKhUCgUCoVCoVAoFAqFQqFQKBQKhUKhUCgUCoVC0Wrxf0UR0D7z0nlYAAAAAElFTkSuQmCC">
584
+ <link rel="apple-touch-icon" href="data:image/png;base64,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">
585
+ <style>
586
+ :root { ${THEMES.dark.css} }
587
+ html[data-theme="light"] { ${THEMES.light.css} }
588
+ * { margin:0; padding:0; box-sizing:border-box; }
589
+ body { color:var(--text); font:15px/1.6 var(--sans); -webkit-font-smoothing:antialiased; background:var(--bg); }
590
+ /* fixed gradient wash on its own layer: background-attachment:fixed +
591
+ backdrop-filter makes Chromium skip repaints on scroll */
592
+ body::before { content:''; position:fixed; inset:0; z-index:-1; background:var(--page-grad), var(--bg); }
593
+ .wrap { max-width:1120px; margin:0 auto; padding:48px 28px 72px; }
594
+
595
+ .glass { background:var(--card); backdrop-filter:blur(24px) saturate(140%); -webkit-backdrop-filter:blur(24px) saturate(140%);
596
+ border:1px solid var(--line); border-radius:16px;
597
+ box-shadow:0 1px 0 var(--glass-inset) inset, var(--glass-shadow); }
598
+ .pad { padding:26px 28px; }
599
+
600
+ .eyebrow { font:700 10.5px/1.4 var(--sans); letter-spacing:.16em; text-transform:uppercase; color:var(--dim); }
601
+ .sec-head h2 { font:600 19px/1.3 var(--disp); letter-spacing:-.01em; }
602
+ .sec-head .sub { margin-top:3px; }
603
+ .sub { color:var(--dim); font-size:13.5px; }
604
+ .h3d { font:600 16px/1.3 var(--disp); letter-spacing:-.01em; margin-top:3px; }
605
+ .dim { color:var(--dim2); }
606
+ .mono { font-family:var(--mono); font-size:.9em; }
607
+
608
+ /* hero */
609
+ .hero { display:flex; justify-content:space-between; align-items:flex-end; gap:24px; flex-wrap:wrap; }
610
+ .hero .kicker { display:flex; align-items:center; gap:8px; font:700 10.5px/1 var(--sans);
611
+ letter-spacing:.16em; text-transform:uppercase; color:var(--accent); }
612
+ .kicker .dot { width:6px; height:6px; border-radius:99px; background:var(--accent); box-shadow:0 0 8px var(--accent-glow); }
613
+ h1 { font:700 clamp(28px,4.5vw,38px)/1.15 var(--disp); letter-spacing:-.02em; margin:10px 0 8px; }
614
+ .hero .meta { color:var(--dim); font-size:16px; }
615
+ .hero .meta .mono { color:var(--text); font-size:.95em; font-weight:600; }
616
+ .score { text-align:right; }
617
+ .score .eyebrow { color:var(--accent); }
618
+ .score .val { font:700 44px/1.1 var(--disp); letter-spacing:-.02em; }
619
+ .score .val .slash { color:var(--accent); }
620
+ .score .val .of { font-size:26px; color:var(--dim); font-weight:600; }
621
+
622
+ .chips { display:flex; flex-wrap:wrap; gap:8px; margin-top:18px; }
623
+ .chip { border:1px solid var(--line); background:var(--card); border-radius:99px;
624
+ padding:4px 12px; font:500 11.5px/1.5 var(--sans); color:var(--text); }
625
+ .chip-agent { background:var(--text); color:var(--bg); border-color:var(--text); font-weight:600; }
626
+ .chip-xs { display:inline-block; border:1px solid var(--line); background:var(--chip-bg); border-radius:99px;
627
+ padding:1px 8px; font:500 10px/1.6 var(--mono); color:var(--text); }
628
+
629
+ .nods { display:flex; gap:12px; align-items:flex-start; background:var(--nods-bg); border:1px solid var(--nods-line);
630
+ border-radius:16px; padding:14px 16px; margin-top:20px; font-size:14px; }
631
+ .nods .ic { position:static; flex-shrink:0; width:22px; height:22px; }
632
+
633
+ /* verdict hero card */
634
+ .verdict-card { position:relative; overflow:hidden; margin-top:22px; padding:26px 28px; }
635
+ .blob { position:absolute; border-radius:99em; pointer-events:none; }
636
+ .verdict-card .b1 { right:-64px; top:-64px; width:256px; height:256px; background:var(--blob); filter:blur(48px); }
637
+ .verdict-card .b2 { left:-40px; bottom:-80px; width:224px; height:224px; background:var(--blob2); filter:blur(48px); }
638
+ .verdict { position:relative; font:600 clamp(18px,2.6vw,24px)/1.45 var(--disp); letter-spacing:-.01em; margin-top:10px; }
639
+
640
+ section, .agent { margin-top:28px; }
641
+
642
+ /* stat tiles */
643
+ .stats { display:grid; grid-template-columns:repeat(3,1fr); gap:14px; margin-top:28px; }
644
+ .stats.minis { margin-top:14px; }
645
+ @media (max-width:900px) { .stats { grid-template-columns:repeat(2,1fr); } }
646
+ @media (max-width:560px) { .stats { grid-template-columns:1fr; } }
647
+ .stat { position:relative; padding:20px; transition:border-color .2s; }
648
+ .stat:hover { border-color:var(--hover-ring); }
649
+ .stat.st-bad:hover { border-color:var(--bad-ring); }
650
+ .stat.st-bad { border-top:3px solid var(--coral); }
651
+ .stat.st-warn { border-top:3px solid var(--amber); }
652
+ .stat .num { font:700 32px/1.1 var(--disp); letter-spacing:-.02em; font-variant-numeric:tabular-nums; margin-top:2px; }
653
+ .stat .lab { color:var(--dim); font-size:12px; margin-top:3px; }
654
+ .stat .rows { margin-top:14px; border-top:1px solid var(--line-soft); padding-top:10px; display:grid; gap:6px; }
655
+ .stat .row { display:flex; justify-content:space-between; align-items:center; font-size:11px; }
656
+ .stat .rlab { color:var(--dim); }
657
+ .stat .rval { color:var(--text); font-weight:600; font-variant-numeric:tabular-nums; display:flex; align-items:center; gap:5px; }
658
+ .dir { font-size:9px; } .d-down { color:var(--ok); } .d-up { color:var(--coral); } .d-eq { color:var(--dim2); }
659
+ .ic { position:absolute; top:16px; right:16px; width:22px; height:22px; fill:none; stroke-width:2.2; stroke-linecap:round; stroke-linejoin:round; }
660
+ .ic-good { stroke:var(--ok); stroke-dasharray:26; stroke-dashoffset:26; animation:ic-draw .55s ease .35s forwards; }
661
+ .ic-warn { stroke:var(--amber); animation:ic-pulse 1.9s ease-in-out infinite; }
662
+ .ic-bad { stroke:var(--coral); animation:ic-shake .5s ease .35s 2; }
663
+ @keyframes ic-draw { to { stroke-dashoffset:0; } }
664
+ @keyframes ic-pulse { 0%,100% { opacity:1; transform:scale(1); } 50% { opacity:.5; transform:scale(.9); } }
665
+ @keyframes ic-shake { 0%,100% { transform:translateX(0); } 20% { transform:translateX(-3px); } 40% { transform:translateX(3px); } 60% { transform:translateX(-2px); } 80% { transform:translateX(2px); } }
666
+
667
+ /* gradient hero cards (light mode only; .grad is inert in dark) */
668
+ html[data-theme="light"] .grad { background:var(--hero-grad); border-color:transparent; color:#fff; }
669
+ html[data-theme="light"] .grad .eyebrow, html[data-theme="light"] .grad .lab, html[data-theme="light"] .grad .rlab, html[data-theme="light"] .grad .sub, html[data-theme="light"] .grad .huge-unit, html[data-theme="light"] .grad .split-legend { color:rgba(255,255,255,.78); }
670
+ html[data-theme="light"] .grad .num, html[data-theme="light"] .grad .rval, html[data-theme="light"] .grad .huge, html[data-theme="light"] .grad .verdict, html[data-theme="light"] .grad .sub b { color:#fff; }
671
+ html[data-theme="light"] .grad .rows { border-top-color:rgba(255,255,255,.25); }
672
+ html[data-theme="light"] .grad .split-track { background:rgba(255,255,255,.22); }
673
+ html[data-theme="light"] .grad .sp-tok { background:#4ade80; }
674
+ html[data-theme="light"] .grad .warn-text { color:#ffc9c0; }
675
+ html[data-theme="light"] .grad .ic-good, html[data-theme="light"] .grad .ic-warn, html[data-theme="light"] .grad .ic-bad { stroke:#fff; }
676
+ html[data-theme="light"] .grad .d-up { color:#ffc9c0; } html[data-theme="light"] .grad .d-down { color:#b8f7d0; }
677
+ /* theme-conditional blocks */
678
+ .only-light { display:none; }
679
+ html[data-theme="light"] .only-light { display:block; }
680
+ html[data-theme="light"] .only-dark { display:none; }
681
+ /* theme toggle */
682
+ .theme-toggle { position:fixed; top:18px; right:18px; z-index:10; width:40px; height:40px; border-radius:99px;
683
+ display:grid; place-items:center; cursor:pointer; color:var(--dim); }
684
+ .theme-toggle:hover { color:var(--text); }
685
+ .theme-toggle svg { width:18px; height:18px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
686
+ .theme-toggle .only-light, html[data-theme="light"] .theme-toggle .only-light { display:none; }
687
+ html[data-theme="light"] .theme-toggle .only-light { display:block; }
688
+ .theme-toggle .only-dark { display:block; }
689
+
690
+ /* palette */
691
+ .palette-grid { display:grid; grid-template-columns:1fr 1.6fr; gap:16px; }
692
+ @media (max-width:860px) { .palette-grid { grid-template-columns:1fr; } }
693
+ .pal-hero { position:relative; overflow:hidden; padding:26px 28px; }
694
+ .pal-hero .b1 { right:-40px; top:-40px; width:160px; height:160px; background:var(--blob); filter:blur(32px); }
695
+ .pal-num { display:flex; align-items:baseline; gap:10px; margin:14px 0 10px; }
696
+ .huge { font:700 clamp(44px,6vw,64px)/1 var(--disp); letter-spacing:-.03em; font-variant-numeric:tabular-nums; }
697
+ .huge-unit { color:var(--dim); font-size:14px; }
698
+ .split-track { margin-top:20px; background:var(--deep); border-radius:99px; padding:4px; }
699
+ .split { display:flex; height:8px; border-radius:99px; overflow:hidden; }
700
+ .sp-tok { background:var(--ok); } .sp-stray { background:var(--coral); }
701
+ .split-legend { display:flex; justify-content:space-between; margin-top:8px; font-size:11px; color:var(--dim); }
702
+ .warn-text { color:var(--coral); margin-top:10px; }
703
+ .pal-usage { padding:26px 28px; }
704
+ .pal-usage-head { display:flex; justify-content:space-between; align-items:flex-end; gap:16px; flex-wrap:wrap; }
705
+ .legend { display:flex; gap:12px; font:700 9.5px/1 var(--sans); letter-spacing:.14em; text-transform:uppercase; color:var(--dim); }
706
+ .legend span { display:flex; align-items:center; gap:6px; }
707
+ .lg { width:8px; height:8px; border-radius:2px; display:inline-block; }
708
+ .lg-tok { background:var(--text); } .lg-stray { background:var(--coral); }
709
+ .usage-bar { display:flex; height:96px; gap:2px; margin-top:18px; border-radius:16px; overflow:hidden; }
710
+ .uc { position:relative; min-width:3px; box-shadow:0 0 0 1px var(--cell-ring) inset; }
711
+ .uc:first-child { border-radius:16px 0 0 16px; } .uc:last-child { border-radius:0 16px 16px 0; }
712
+ .uc.stray i { position:absolute; top:4px; right:4px; width:6px; height:6px; border-radius:99px; background:var(--coral); box-shadow:0 0 0 2px var(--card-solid); }
713
+ .uc-rest { background:repeating-linear-gradient(45deg,var(--deep),var(--deep) 4px,var(--card-solid) 4px,var(--card-solid) 8px);
714
+ display:flex; align-items:center; justify-content:center; font:600 10px var(--mono); color:var(--dim); }
715
+ .ramp-head { margin-top:26px; }
716
+ .grey-strip { display:flex; height:44px; border-radius:16px; overflow:hidden; margin-top:10px; box-shadow:0 0 0 1px var(--cell-ring) inset; }
717
+ .grey-cell { flex:1; }
718
+ .ramp-labels { display:flex; justify-content:space-between; margin-top:6px; font:400 10px var(--mono); color:var(--dim2); }
719
+
720
+ /* spacing bars */
721
+ .bars { margin-top:18px; display:grid; gap:7px; }
722
+ .bar-row { display:flex; align-items:center; gap:14px; }
723
+ .bar-label { font:12px var(--mono); color:var(--text); width:72px; text-align:right; flex-shrink:0; }
724
+ .bar-label.off { color:var(--coral); }
725
+ .bar-track { position:relative; height:22px; flex:1; background:var(--deep); border-radius:8px; overflow:hidden; }
726
+ .bar { height:100%; border-radius:8px; background:var(--bar-grad); min-width:4px; }
727
+ .bar-off { background:var(--coral); }
728
+ .bar-count { font:11.5px var(--mono); color:var(--dim); width:44px; text-align:right; font-variant-numeric:tabular-nums; }
729
+
730
+ /* offenders ledger */
731
+ .ledger { margin-top:14px; }
732
+ .ledger-row { display:flex; align-items:center; gap:16px; padding:12px 8px; border-radius:12px;
733
+ border-bottom:1px solid var(--line-soft); transition:background .15s; }
734
+ .ledger-row:last-child { border-bottom:none; }
735
+ .ledger-row:hover { background:var(--hover-row); }
736
+ .ledger-idx { font:600 11.5px var(--mono); color:var(--dim2); font-variant-numeric:tabular-nums; }
737
+ .ledger-row a.path { font-size:13px; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
738
+ .ledger-pills { margin-left:auto; display:flex; gap:8px; flex-shrink:0; }
739
+
740
+ .start-row { align-items:flex-start; }
741
+ .start-body { min-width:0; }
742
+ .start-title { font:600 15px/1.4 var(--disp); }
743
+ .start-row .sub { margin-top:2px; overflow-wrap:anywhere; }
744
+
745
+ .pill { border-radius:99px; padding:3px 10px; font:500 10.5px/1.5 var(--sans); white-space:nowrap; }
746
+ .pill-mint { background:var(--ok-soft); color:var(--ok); }
747
+ .pill-coral { background:var(--coral-soft); color:var(--coral); }
748
+ .pill-amber { background:var(--amber-soft); color:var(--amber); }
749
+
750
+ /* value receipts */
751
+ .receipts { margin-top:18px; }
752
+ .chips-row { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
753
+ .vchip { font:11.5px var(--mono); color:var(--text); border:1px solid var(--line); background:var(--chip-bg);
754
+ border-radius:99px; padding:2px 10px; }
755
+ .vchip.bad { color:var(--coral); border-color:var(--coral-soft); background:var(--coral-soft); }
756
+ .vchip.dim { color:var(--dim2); }
757
+
758
+ /* two-up cards, families, mini-cards */
759
+ .two-up { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-top:28px; }
760
+ @media (max-width:860px) { .two-up { grid-template-columns:1fr; } }
761
+ .half { min-width:0; }
762
+ .fams { display:grid; gap:20px; margin-top:16px; }
763
+ .fam-rows { display:grid; gap:8px; margin-top:10px; }
764
+ .mini-card { display:flex; align-items:center; justify-content:space-between; gap:12px;
765
+ border:1px solid var(--line); background:var(--chip-bg); border-radius:14px; padding:10px 14px; min-width:0; }
766
+ .mini-card.col { display:block; }
767
+ .mini-card.dim { border-style:dashed; }
768
+ .mc-main { min-width:0; }
769
+ .mc-name { font:600 13px var(--mono); }
770
+ .mc-path, .mini-card .path { font:11.5px var(--mono); color:var(--dim); overflow-wrap:anywhere; }
771
+ .mc-row { display:flex; align-items:center; justify-content:space-between; gap:12px; }
772
+ .mc-count { font:600 11.5px var(--mono); color:var(--coral); font-variant-numeric:tabular-nums; flex-shrink:0; }
773
+ .mc-track { height:6px; background:var(--deep); border-radius:99px; overflow:hidden; margin-top:8px; }
774
+ .mc-bar { height:100%; border-radius:99px; background:var(--coral); }
775
+
776
+ a.path { color:var(--dim); text-decoration:none; border-bottom:1px dotted var(--line); font:12.5px var(--mono); overflow-wrap:anywhere; }
777
+ a.path:hover { color:var(--text); border-bottom-color:var(--accent); }
778
+ .path { font:12.5px var(--mono); color:var(--dim); }
779
+ .strong { color:var(--text); font-weight:600; }
780
+
781
+ /* table */
782
+ .tbl-wrap { overflow-x:auto; margin-top:14px; }
783
+ table { width:100%; border-collapse:collapse; font-size:13.5px; }
784
+ th { text-align:left; color:var(--dim); font:700 10px var(--sans); text-transform:uppercase;
785
+ letter-spacing:.16em; padding:8px 12px 8px 0; border-bottom:1px solid var(--line); }
786
+ td { padding:10px 12px 10px 0; border-bottom:1px solid var(--line-soft); vertical-align:top; }
787
+ tr:hover td { background:var(--hover-cell); }
788
+
789
+ footer { display:flex; justify-content:space-between; align-items:flex-start; gap:14px; flex-wrap:wrap;
790
+ border-top:1px solid var(--line); margin-top:40px; padding-top:22px; color:var(--dim); font-size:11.5px; }
791
+ .foot-left { display:grid; gap:5px; }
792
+ .foot-sub { color:var(--dim2); }
793
+ footer .brand { color:var(--accent); font-weight:700; text-decoration:none; }
794
+ footer .brand:hover { text-decoration:underline; }
795
+ footer .creds { display:flex; gap:16px; font:700 9.5px/1.6 var(--sans); letter-spacing:.16em; text-transform:uppercase; color:var(--dim2); }
796
+ .author { color:var(--accent); font-weight:700; text-decoration:none; }
797
+ .author:hover { text-decoration:underline; }
798
+ </style></head><body>
799
+ <button class="theme-toggle glass" aria-label="Switch between light and dark mode" onclick="(function(){var r=document.documentElement,t=r.getAttribute('data-theme')==='light'?'dark':'light';r.setAttribute('data-theme',t);try{localStorage.setItem('roast-theme',t)}catch(e){}})()">
800
+ <svg class="only-dark" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><line x1="12" y1="2" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="22"/><line x1="2" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="22" y2="12"/><line x1="4.9" y1="4.9" x2="7" y2="7"/><line x1="17" y1="17" x2="19.1" y2="19.1"/><line x1="4.9" y1="19.1" x2="7" y2="17"/><line x1="17" y1="7" x2="19.1" y2="4.9"/></svg>
801
+ <svg class="only-light" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
802
+ </button>
803
+ <script>try{var t=localStorage.getItem('roast-theme');if(t==='light'||t==='dark')document.documentElement.setAttribute('data-theme',t)}catch(e){}</script>
804
+ <div class="wrap">
805
+
806
+ <header>
807
+ <div class="hero">
808
+ <div>
809
+ <div class="kicker"><span class="dot"></span>Scan complete · ${h.tookMs}ms · ${n(h.files.code)} code files</div>
810
+ <h1>Design System Diagnosis</h1>
811
+ <div class="meta"><span class="mono">${esc(repoName)}</span> · scanned ${esc((h.harvestedAt ?? '').slice(0, 10))}</div>
812
+ </div>
813
+ ${healthScore !== null ? `<div class="score">${eyebrow('Health score')}<div class="val">${healthScore}<span class="slash">/</span><span class="of">100</span></div></div>` : ''}
814
+ </div>
815
+ <div class="chips">${stack.map((s) => `<span class="chip">${esc(s)}</span>`).join('')}${agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)}</span>`).join('')}</div>
816
+ ${noSystemLikely ? `<div class="nods">${ICONS.warn}<span>There is most likely <b>no design system in this repo</b>: almost no colour or spacing values were found. Styling may live outside this codebase (CDN stylesheets, a parent repo, or generated output).</span></div>` : ''}
817
+ <div class="glass verdict-card">
818
+ <div class="blob b1"></div><div class="blob b2"></div>
819
+ ${eyebrow('Summary')}
820
+ <div class="verdict">${esc(verdict)}</div>
821
+ </div>
822
+ </header>
823
+
824
+ ${whereToStartSection()}
825
+
826
+ <div class="stats">${bigStats.map((s) => statTile(s)).join('')}</div>
827
+
828
+ ${agentSection()}
829
+
830
+ <section style="margin-top:16px">${paletteSection()}</section>
831
+
832
+ ${spacingBars()}
833
+ ${typographySection()}
834
+ ${offendersSection()}
835
+ <div class="two-up">
836
+ ${duplicatesSection()}
837
+ ${inlineSection()}
838
+ </div>
839
+ ${componentsSection()}
840
+
841
+ <footer>
842
+ <div class="foot-left">
843
+ <div>Generated by <a class="brand" href="https://github.com/pencilrebel/roast-my-design-system">roast-my-design-system</a>, a free skill for Claude Code and Codex</div>
844
+ <div class="foot-sub"><span class="ver">ver. ${VERSION}</span> · built and designed by <a class="author" href="https://gregkozakiewicz.com">Greg Kozakiewicz</a></div>
845
+ </div>
846
+ <span class="creds"><span>Non-destructive scan</span><span>Read-only</span><span>Paths are real</span></span>
847
+ </footer>
848
+ </div></body></html>`;
849
+
850
+ writeFileSync(outPath, html);
851
+
852
+ // Machine-readable summary for wrappers (the npx CLI reads this instead of
853
+ // parsing the HTML): --summary <path> writes score, verdict and per-tile health.
854
+ const summaryPath = arg('summary', null);
855
+ if (summaryPath) {
856
+ writeFileSync(resolve(summaryPath), JSON.stringify({
857
+ repo: repoName,
858
+ version: VERSION,
859
+ score: healthScore,
860
+ noSystemLikely,
861
+ verdict,
862
+ tiles: bigStats.map((s) => ({ label: s.label, value: s.num, health: s.health })),
863
+ report: outPath,
864
+ }, null, 2));
865
+ }
866
+
867
+ console.log(`✓ Diagnosis for ${repoName}`);
868
+ if (healthScore !== null) console.log(` score: ${healthScore}/100`);
869
+ console.log(` verdict: ${verdict}`);
870
+ console.log(` → ${outPath}`);