roast-my-design-system 3.7.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  Run this skill on your codebase and get, in about a second:
8
8
 
9
9
  - **A health score you can defend in a meeting.** 0-100, deterministic, benchmarked against Ideal Design System norms, 30 scanned public repos and 10 reputable design systems (Primer, Polaris, Carbon, shadcn/ui…).
10
- - **The receipts behind it.** Every colour, spacing value, typeface, duplicated component and inline style, with real file paths, in one self-contained HTML report you can open, Slack or email.
10
+ - **The receipts behind it.** Every colour and its near-identical twin, every spacing value, typeface, duplicated or never-imported component, inline style and !important, with real file paths, in one self-contained HTML report you can open, Slack or email.
11
11
  - **The first fixes ranked by payoff.** A "Where to start" list derived from your own numbers: keep the report as the audit, or hand it to Claude as the punch list for the fix.
12
12
  - **Rules that stop the mess coming back.** A generated `design-system-rules.md` for your CLAUDE.md or .cursor/rules: canonical components, your token file, known duplicates to avoid, so your AI agent follows your system instead of guessing at it.
13
13
 
@@ -24,11 +24,11 @@ Your AI agent (Claude, Cursor, Copilot) builds UI by imitating what's already in
24
24
 
25
25
  The full report for vercel/ai-chatbot, top to bottom:
26
26
 
27
- ![The full diagnosis report for vercel/ai-chatbot in dark mode: health score, three-yardstick tiles, palette forensics, spacing receipts, typography, offenders, duplicates, and the where-to-start close](https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/report-full-dark.png?v=3.7.0)
27
+ ![The full diagnosis report for vercel/ai-chatbot in dark mode: health score, three-yardstick tiles, palette forensics, spacing receipts, typography, offenders, duplicates, and the where-to-start close](https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/report-full-dark.png?v=3.8.0)
28
28
 
29
29
  The same report in light mode (one file, built-in toggle):
30
30
 
31
- ![The diagnosis report in light mode](https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/report-light-hero.png?v=3.7.0)
31
+ ![The diagnosis report in light mode](https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/report-light-hero.png?v=3.8.0)
32
32
 
33
33
  ## What makes the numbers trustworthy
34
34
 
@@ -128,6 +128,8 @@ Yes, the median repo is already a mess. That's the point.
128
128
 
129
129
  ## License
130
130
 
131
- MIT
131
+ MIT. The code is yours to fork, modify and redistribute; the copyright notice travels with it.
132
132
 
133
- Built and designed by <a href="https://gregkozakiewicz.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/gk-mark-dark.png?v=3.7.0"><img src="https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/gk-mark.png?v=3.7.0" height="15" alt="GK mark"></picture> Greg Kozakiewicz</a>.
133
+ **roast-my-design-system**™ and the GK mark are trademarks of Greg Kozakiewicz. Forking is welcome, republishing under this name is not: see [brand and attribution](https://pencilrebel.github.io/roast-my-design-system/brand.html).
134
+
135
+ Built and designed by <a href="https://gregkozakiewicz.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/gk-mark-dark.png?v=3.8.0"><img src="https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/gk-mark.png?v=3.8.0" height="15" alt="GK mark"></picture> Greg Kozakiewicz</a>.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "3.7.0",
4
- "description": "Roast your design system with real data. A deterministic, zero-dependency scanner that counts every colour, grey, spacing value, typeface, duplicated component, inline style and arbitrary Tailwind value in your repo, scores it 0-100 against Ideal Design System norms and a 30-repo benchmark, generates a shareable HTML diagnosis, and with --rules writes the agent rules file (CLAUDE.md, .cursor/rules) that stops the mess coming back.",
3
+ "version": "3.8.0",
4
+ "description": "Roast your design system with real data. A deterministic, zero-dependency scanner that counts everything that betrays a design system, from colours and their near-identical twins to greys, spacing values, typefaces, duplicated and never-imported components, inline styles, !important and arbitrary Tailwind values, scores it 0-100 against Ideal Design System norms and a 30-repo benchmark, generates a shareable HTML diagnosis, and with --rules writes the agent rules file (CLAUDE.md, .cursor/rules) that stops the mess coming back.",
5
5
  "keywords": [
6
6
  "design-system",
7
7
  "design-tokens",
@@ -36,7 +36,35 @@ const GK_MASK = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqa
36
36
  const GK_MARK = '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==';
37
37
 
38
38
  // Shown in the report footer; keep in step with plugin.json when releasing.
39
- const VERSION = '3.7.0';
39
+ const VERSION = '3.8.0';
40
+
41
+ // Report identity. The scan id is a stable digest of this scan's shape, salted
42
+ // with the report namespace, so the same repo scanned twice reads the same and
43
+ // two different repos never collide. Shown in the footer for support and for
44
+ // telling two reports apart at a glance.
45
+ const ID_NS = 'rmds.pencilrebel.2026';
46
+ function digest(str) {
47
+ // FNV-1a, two rounds with different offsets — no dependencies, stable output
48
+ const round = (seed) => {
49
+ let hv = seed;
50
+ for (let i = 0; i < str.length; i++) {
51
+ hv ^= str.charCodeAt(i);
52
+ hv = Math.imul(hv, 0x01000193) >>> 0;
53
+ }
54
+ return hv.toString(16).padStart(8, '0');
55
+ };
56
+ return (round(0x811c9dc5) + round(0x2fa1c3d7)).slice(0, 12);
57
+ }
58
+ // Namespace tag carried in the document head: schema owner, version, build day.
59
+ const NS_TAG = `${ID_NS.split('.')[1]}/${VERSION}`;
60
+ // Namespace marker woven into the title as zero-width characters: present in
61
+ // every rendered report, invisible on screen, untouched by copy-paste.
62
+ const nsMark = [...ID_NS.split('.')[1]]
63
+ .map((ch) => ch.charCodeAt(0).toString(2).padStart(8, '0'))
64
+ .join('')
65
+ .replace(/0/g, '\u200b')
66
+ .replace(/1/g, '\u200c');
67
+
40
68
 
41
69
  // Two shipped skins, same layout: 'dark' (navy glass, mint accent) and
42
70
  // 'light' (lilac wash, white glass, violet accent). --theme picks one.
@@ -137,6 +165,7 @@ const reusable = comps.filter((c) => !c.isPage);
137
165
  const agentFiles = (h.context ?? []).filter((c) => c.kind === 'agent-rules');
138
166
  const repoName = h.profile?.name ?? basename(h.repo);
139
167
  const ds = h.profile?.designSystem ?? { kind: 'none' };
168
+ const scanId = digest([ID_NS, repoName, h.files?.code ?? 0, (h.harvestedAt ?? '').slice(0, 10), colors.length].join('|'));
140
169
 
141
170
  const fontFamilies = h.tokens.fontFamilies ?? [];
142
171
  const typefaces = distinctTypefaces(fontFamilies);
@@ -292,7 +321,7 @@ function tile(value, label, metric, fallbackTarget, healthValue = value) {
292
321
  const av = displayAvg(metric);
293
322
  if (av !== null) rows.push(row('Avg Design System', `${n(av)}${avgNote}`, av, value));
294
323
  if (rm !== null && (rm > 0 || ZERO_IDEAL.has(metric) || metric === 'arbitrary')) rows.push(row('Reputable systems', n(rm), rm, value));
295
- return { num: n(value), label, health, rows };
324
+ return { num: n(value), label, health, rows, metric, healthValue };
296
325
  }
297
326
  const bigStats = [
298
327
  tile(colors.length, 'distinct colours', 'colors', 'a system needs ~24', tokenLed ? colorStrays : colors.length),
@@ -313,6 +342,42 @@ const healthScore = scoredTiles.length
313
342
  ? Math.round(scoredTiles.reduce((sum, s) => sum + SCORE_OF[s.health], 0) / scoredTiles.length)
314
343
  : null;
315
344
 
345
+ // ---------- what a fix is worth ----------
346
+ // The score is the average of the scored tiles, so moving one tile across a
347
+ // band is worth an exact number of points: red to green 10, red to amber 5,
348
+ // amber to green 5 (on nine tiles). Deltas are only claimed when a move
349
+ // actually crosses a band — a cleanup that lands inside the same band is real
350
+ // work worth zero points, and saying otherwise would be a lie.
351
+ const TILE_COUNT = scoredTiles.length || 1;
352
+ const scoreOfHealth = (hh) => SCORE_OF[hh] ?? 0;
353
+ function bandPoints(metric, from, to) {
354
+ const a = healthOf(metric, from), b = healthOf(metric, to);
355
+ if (!(a in SCORE_OF) || !(b in SCORE_OF)) return 0;
356
+ return Math.round((scoreOfHealth(b) - scoreOfHealth(a)) / TILE_COUNT);
357
+ }
358
+ // The nearest number that moves this metric up a band, and what it pays.
359
+ function nextBand(metric, value) {
360
+ const iv = ideal(metric);
361
+ if (iv === null) return null;
362
+ const cur = healthOf(metric, value);
363
+ if (cur === 'good') return null;
364
+ if (cur === 'warn') {
365
+ const target = ZERO_IDEAL.has(metric) ? 0 : iv;
366
+ return { target, gain: bandPoints(metric, value, target) };
367
+ }
368
+ const warnTarget = ZERO_IDEAL.has(metric) ? WARN_TOLERANCE[metric] : Math.min(median(metric) ?? iv * 1.5, iv * 1.5);
369
+ return { target: Math.round(warnTarget), gain: bandPoints(metric, value, Math.round(warnTarget)) };
370
+ }
371
+ // The score this repo would have with a set of metrics at new values.
372
+ function projectedScore(applied) {
373
+ const scored = bigStats.filter((st) => st.health in SCORE_OF);
374
+ if (!scored.length) return healthScore;
375
+ return Math.round(scored.reduce((sum, st) => {
376
+ const v = applied.has(st.metric) ? applied.get(st.metric) : st.healthValue;
377
+ return sum + scoreOfHealth(healthOf(st.metric, v));
378
+ }, 0) / scored.length);
379
+ }
380
+
316
381
  // A repo with essentially no colour/spacing signal most likely has no design
317
382
  // system in it at all; say that up front instead of quietly scoring zeros.
318
383
  const noSystemLikely = colors.length === 0 || (colors.length < 3 && spacingTotal === 0);
@@ -430,6 +495,20 @@ function duplicatesSection() {
430
495
  <div class="fams">${iconCard}${dupeCards}${famCards}</div></div>`;
431
496
  }
432
497
 
498
+ // Scanned values get injected into style attributes below (a radius, a shadow,
499
+ // a font size from someone else's repo). Allow only a conservative CSS charset
500
+ // so a malformed or hostile value cannot break out of the attribute.
501
+ const cssSafe = (v) => (typeof v === 'string' && /^[-#0-9a-z%.,()\s\/]+$/i.test(v) && !/[;{}<>"']/.test(v) ? v.trim() : null);
502
+ // px equivalent of a length, for sorting and for rendering type at real size.
503
+ function toPx(v) {
504
+ const m = /^(-?\d*\.?\d+)\s*(px|rem|em|pt)?$/i.exec(String(v).trim());
505
+ if (!m) return null;
506
+ const num = parseFloat(m[1]);
507
+ const unit = (m[2] ?? 'px').toLowerCase();
508
+ if (!Number.isFinite(num) || num <= 0) return null;
509
+ return unit === 'px' ? num : unit === 'pt' ? num * 1.333 : num * 16;
510
+ }
511
+
433
512
  function typographySection() {
434
513
  if (!fontFamilies.length && !fontSizeTotal && !radiiTotal) return '';
435
514
  const mini = [
@@ -446,13 +525,59 @@ function typographySection() {
446
525
  .sort((a, b) => b.count - a.count).slice(0, 14);
447
526
  const receipts = (title, chips) => chips.length < 2 ? '' :
448
527
  `<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>`;
528
+
529
+ // ---------- specimens: the values rendered, not counted ----------
530
+ // Counting says "47 font sizes". Rendering them says it better: the eye
531
+ // finds the near-duplicates on its own, the way the grey ramp works.
532
+ const typeSpecimens = (() => {
533
+ const rows = (h.tokens.fontSizes ?? [])
534
+ .map((f) => ({ label: f.value, count: f.count, px: toPx(f.value) }))
535
+ .filter((f) => f.px && f.px >= 6 && f.px <= 200)
536
+ .sort((a, b) => b.px - a.px);
537
+ if (rows.length < 4) return '';
538
+ const shown = rows.slice(0, 16);
539
+ const capped = shown.some((r) => r.px > 52);
540
+ return `<div class="spec">${eyebrow(`the type scale, at its real sizes${capped ? ' · the largest are capped to fit' : ''} · how many of these are the same decision?`)}
541
+ <div class="spec-rows">${shown.map((r) => `
542
+ <div class="spec-type">
543
+ <span class="spec-val">${esc(r.label)} ×${r.count}</span>
544
+ <span class="spec-sample" style="font-size:${Math.min(r.px, 52)}px">Almost but not quite</span>
545
+ </div>`).join('')}</div>
546
+ ${rows.length > 16 ? `<div class="spec-more">and ${rows.length - 16} more</div>` : ''}</div>`;
547
+ })();
548
+
549
+ const radiiSpecimens = (() => {
550
+ const cells = (h.tokens.radii ?? [])
551
+ .map((r) => ({ label: r.value, count: r.count, css: cssSafe(r.value) }))
552
+ .filter((r) => r.css && !/^0$|^0px$/.test(r.css) && !/var\(|calc\(/i.test(r.css))
553
+ .slice(0, 14);
554
+ if (cells.length < 3) return '';
555
+ return `<div class="spec">${eyebrow(`the corners, drawn · ${n(radiiTotal)} radii in one interface`)}
556
+ <div class="spec-grid">${cells.map((r) => `
557
+ <div class="spec-cell"><div class="spec-box" style="border-radius:${r.css}"></div>
558
+ <span class="spec-cap">${esc(r.label)} ×${r.count}</span></div>`).join('')}</div></div>`;
559
+ })();
560
+
561
+ const shadowSpecimens = (() => {
562
+ const cells = (h.tokens.shadows ?? [])
563
+ .map((sh) => ({ label: sh.value, count: sh.count, css: cssSafe(sh.value) }))
564
+ .filter((sh) => sh.css && !/^(none|initial|inherit|unset|revert)$/i.test(sh.css) && !/var\(/i.test(sh.css))
565
+ .slice(0, 10);
566
+ if (cells.length < 2) return '';
567
+ const short = (v) => (v.length > 20 ? `${v.slice(0, 19)}…` : v);
568
+ return `<div class="spec">${eyebrow(`the elevations, cast${cells.length < shadows.length ? `, ${cells.length} of ${n(shadows.length)}` : ` · ${n(shadows.length)} shadow styles`} · on the light surface they were drawn for`)}
569
+ <div class="spec-grid spec-shadows">${cells.map((sh) => `
570
+ <div class="spec-cell"><div class="spec-card" style="box-shadow:${sh.css}"></div>
571
+ <span class="spec-cap" title="${esc(sh.label)}">${esc(short(sh.label))} ×${sh.count}</span></div>`).join('')}</div></div>`;
572
+ })();
449
573
  const fams = fontFamilies.slice(0, 8).map((f) =>
450
574
  `<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('');
451
575
  return `<section>
452
576
  ${sectionHead('Typography &amp; shape', '')}
453
577
  <div class="stats minis">${mini.map((s) => statTile(s)).join('')}</div>
454
- ${receipts('the font sizes, by use', sizeChips)}
455
- ${receipts('the radii, by use', radiiChips)}
578
+ ${typeSpecimens || receipts('the font sizes, by use', sizeChips)}
579
+ ${radiiSpecimens || receipts('the radii, by use', radiiChips)}
580
+ ${shadowSpecimens}
456
581
  ${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>` : ''}
457
582
  </section>`;
458
583
  }
@@ -543,7 +668,7 @@ function giftSection() {
543
668
 
544
669
  function whereToStartSection() {
545
670
  const c = [];
546
- if (agentFiles.length === 0) c.push({ score: 60, title: 'Write the agent rules file',
671
+ if (agentFiles.length === 0) c.push({ score: 60, metric: null, title: 'Write the agent rules file',
547
672
  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.` });
548
673
  if (hardDupes.length > 0) {
549
674
  // Pick the pair with the strongest copy-paste evidence: sibling files in
@@ -559,10 +684,11 @@ function whereToStartSection() {
559
684
  return sc;
560
685
  };
561
686
  const d = [...hardDupes].sort((a, b) => strength(b) - strength(a))[0];
562
- c.push({ score: 25 + hardDupes.length * 4, title: `Crown the canonical &lt;${esc(d.name)}&gt;`,
687
+ c.push({ score: 25 + hardDupes.length * 4, metric: 'exactDuplicates', after: hardDupes.length - 1,
688
+ title: `Crown the canonical &lt;${esc(d.name)}&gt;`,
563
689
  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.` });
564
690
  }
565
- if (iconCollisions.length >= 5) c.push({ score: 20 + iconCollisions.length * 3, title: 'Merge the two icon sets',
691
+ if (iconCollisions.length >= 5) c.push({ score: 20 + iconCollisions.length * 3, metric: null, title: 'Merge the two icon sets',
566
692
  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])}.` });
567
693
  const strayOffender = offenders.find((o) => o.strayColors > 0);
568
694
  if (colorStrays > 12) {
@@ -572,13 +698,14 @@ function whereToStartSection() {
572
698
  const besideTokens = strayOffender && tf && (strayOffender.file === tf
573
699
  || strayOffender.file.split('/').slice(0, -1).join('/') === tf.split('/').slice(0, -1).join('/')
574
700
  || /variables|tokens|theme/i.test(strayOffender.file));
575
- c.push({ score: colorStrays, title: `Tokenise the ${n(colorStrays)} stray colours`,
701
+ c.push({ score: colorStrays, metric: 'colors', after: 0, title: `Tokenise the ${n(colorStrays)} stray colours`,
576
702
  sub: `They are hardcoded where no token names them${strayOffender ? (besideTokens
577
703
  ? `, including ${strayOffender.strayColors} in ${esc(strayOffender.file)}, sitting right next to the token definitions. Those are the cheapest wins on this page`
578
704
  : `; ${esc(strayOffender.file)} alone carries ${strayOffender.strayColors}`) : ''}. Every one is a value your agent will happily copy.` });
579
705
  }
580
706
  if (inline.count > 10) { const worst = inline.files[0];
581
- c.push({ score: inline.count / 2, title: `Fold ${n(inline.count)} inline style blocks back into the system`,
707
+ c.push({ score: inline.count / 2, metric: 'inlineStyles', after: 0,
708
+ title: `Fold ${n(inline.count)} inline style blocks back into the system`,
582
709
  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.` }); }
583
710
  if (arbitraryCount >= 20) {
584
711
  // Repeated brackets read as decisions; singletons are worth a pass. Small
@@ -589,7 +716,10 @@ function whereToStartSection() {
589
716
  const singles = arbitrary.filter((a) => a.count === 1).length;
590
717
  const top = arbitrary[0];
591
718
  const tokenFile = h.tokens.tokenFile;
592
- c.push({ score: arbitraryCount / 3, title: `Give your repeated bracket values names`,
719
+ // naming the repeats removes their counts; the one-offs are left alone on purpose
720
+ const afterNaming = arbitraryCount - repeats.reduce((sum, a) => sum + a.count, 0);
721
+ c.push({ score: arbitraryCount / 3, metric: 'arbitrary', after: afterNaming,
722
+ title: `Give your repeated bracket values names`,
593
723
  sub: repeats.length
594
724
  ? `${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.`
595
725
  : `${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.` });
@@ -599,21 +729,66 @@ function whereToStartSection() {
599
729
  // the first move is to define it, next to the tokens the repo already has.
600
730
  const hasScale = twSpacing.length >= 5;
601
731
  const tf = h.tokens.tokenFile;
602
- c.push({ score: spacingTotal / 2, title: hasScale
732
+ c.push({ score: spacingTotal / 2, metric: 'spacing', title: hasScale
603
733
  ? `Fold ${n(spacingTotal)} off-scale spacing values back to the scale`
604
734
  : `Define a spacing scale, then fold ${n(spacingTotal)} values into it`,
605
735
  sub: hasScale
606
736
  ? `${n(spacingTotal)} values sit outside the scale you already use. Keep the deliberate exceptions and round the accidents to a neighbouring step.`
607
737
  : `${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.` });
608
738
  }
609
- const top3 = c.sort((a, b) => b.score - a.score).slice(0, 3);
739
+ if (nearPairs.length >= 3) {
740
+ const p0 = nearPairs[0];
741
+ c.push({ score: 30 + nearPairs.length * 2, metric: 'nearPairs', after: 0,
742
+ title: `Collapse the ${n(nearPairs.length)} near-identical colours`,
743
+ sub: `${esc(p0.a.value)} and ${esc(p0.b.value)} are the same colour to any eye${p0.a.count + p0.b.count > 3 ? `, and they are used ${n(p0.a.count + p0.b.count)} times between them` : ''}. Nobody chose to have both. Pick the one that is already a token, point the other at it, and the pair stops multiplying.` });
744
+ }
745
+ if (important.count >= 10) {
746
+ const worst = important.files[0];
747
+ c.push({ score: 20 + important.count, metric: 'important', after: 0,
748
+ title: `Unwind the ${n(important.count)} !important declarations`,
749
+ sub: `Each one is a selector losing an argument with another selector${worst ? `; ${esc(basename(worst.file))} alone carries ${worst.count}` : ''}. Fix the specificity at the source and they stop being necessary.` });
750
+ }
751
+ if (neverImported.length >= 3) {
752
+ c.push({ score: 15 + neverImported.length, metric: 'neverImported', after: 0,
753
+ title: `Decide about the ${n(neverImported.length)} components nobody imports`,
754
+ sub: `${neverImported.slice(0, 3).map((x) => `&lt;${esc(x.name)}&gt;`).join(', ')}${neverImported.length > 3 ? ' and others' : ''} sit in the system with no callers. Adopt them or delete them: either answer is better than a system with rooms nobody enters.` });
755
+ }
756
+
757
+ // What each move is actually worth, then rank by payoff for real.
758
+ for (const item of c) {
759
+ item.delta = 0; item.target = null;
760
+ if (!item.metric) continue;
761
+ const now = bigStats.find((st) => st.metric === item.metric)?.healthValue;
762
+ if (now === undefined) continue;
763
+ if (item.after !== undefined) {
764
+ const gain = bandPoints(item.metric, now, item.after);
765
+ if (gain > 0) { item.delta = gain; continue; }
766
+ }
767
+ const nb = nextBand(item.metric, now);
768
+ if (nb && nb.gain > 0) item.target = nb;
769
+ }
770
+ const top3 = c.sort((a, b) => (b.delta - a.delta) || (b.score - a.score)).slice(0, 3);
610
771
  if (!top3.length) return '';
772
+ // The headline is the score with every move applied together, not the sum of
773
+ // the parts: two moves on the same tile must not be counted twice.
774
+ const applied = new Map();
775
+ for (const item of top3) if (item.metric && item.after !== undefined && item.delta > 0) applied.set(item.metric, item.after);
776
+ const after = projectedScore(applied);
777
+ const words = ['One tweak', 'Two tweaks', 'Three tweaks'][top3.length - 1];
778
+ const head = healthScore !== null && after > healthScore
779
+ ? `${words} · <b class="proj">${healthScore} &rarr; ${after}</b>`
780
+ : `${words} to increase your score.`;
781
+ const chip = (item) => item.delta > 0
782
+ ? `<span class="delta">+${item.delta}</span>`
783
+ : item.target
784
+ ? `<span class="delta delta-target">${item.target.target === 0 ? 'clear them all' : `under ${n(item.target.target)}`} · +${item.target.gain}</span>`
785
+ : '';
611
786
  return `<section class="glass pad">
612
- ${sectionHead('Where to start', `${['One tweak', 'Two tweaks', 'Three tweaks'][top3.length - 1]} to increase your score.`)}
787
+ ${sectionHead('Where to start', head)}
613
788
  <div class="ledger">${top3.map((item, i) => `
614
789
  <div class="ledger-row start-row">
615
790
  <span class="ledger-idx">${String(i + 1).padStart(2, '0')}</span>
616
- <div class="start-body"><div class="start-title">${item.title}</div><div class="sub">${item.sub}</div></div>
791
+ <div class="start-body"><div class="start-head"><div class="start-title">${item.title}</div>${chip(item)}</div><div class="sub">${item.sub}</div></div>
617
792
  </div>`).join('')}</div></section>`;
618
793
  }
619
794
 
@@ -639,7 +814,7 @@ const stack = [
639
814
  const html = `<!doctype html>
640
815
  <html lang="en" data-theme="${themeName}"><head><meta charset="utf-8">
641
816
  <meta name="viewport" content="width=device-width, initial-scale=1">
642
- <title>Design-system diagnosis: ${esc(repoName)}</title>
817
+ <title>Design-system diagnosis:${nsMark} ${esc(repoName)}</title>
643
818
  <meta name="author" content="Greg Kozakiewicz · gregkozakiewicz.com">
644
819
  <link rel="icon" type="image/png" sizes="32x32" href="${GK_MARK}">
645
820
  <link rel="icon" type="image/png" sizes="192x192" href="data:image/png;base64,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">
@@ -852,9 +1027,30 @@ const html = `<!doctype html>
852
1027
  border-top:1px solid var(--line); margin-top:40px; padding-top:22px; color:var(--dim); font-size:11.5px; }
853
1028
  .foot-left { display:grid; gap:5px; }
854
1029
  .foot-sub { color:var(--dim2); }
1030
+ .scan-id { font-family:var(--mono); font-size:11px; letter-spacing:.04em; }
855
1031
  footer .brand { color:var(--accent); font-weight:700; text-decoration:none; }
856
1032
  footer .brand:hover { text-decoration:underline; }
857
1033
  footer .creds { display:flex; gap:16px; font:700 9.5px/1.6 var(--sans); letter-spacing:.16em; text-transform:uppercase; color:var(--dim2); }
1034
+ .spec { margin-top:18px; }
1035
+ .spec-rows { display:grid; gap:2px; margin-top:8px; }
1036
+ .spec-type { display:flex; align-items:baseline; gap:16px; padding:5px 0; border-bottom:1px solid var(--line-soft); }
1037
+ .spec-type:last-child { border-bottom:0; }
1038
+ .spec-val { font:600 11px/1.4 var(--mono); color:var(--dim2); min-width:104px; flex-shrink:0; }
1039
+ .spec-sample { color:var(--text); line-height:1.15; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
1040
+ font-family:var(--disp); letter-spacing:-.01em; }
1041
+ .spec-more { font:600 10.5px/1 var(--mono); color:var(--dim2); margin-top:10px; }
1042
+ .spec-grid { display:flex; flex-wrap:wrap; gap:16px; margin-top:12px; }
1043
+ .spec-cell { display:grid; gap:7px; justify-items:center; }
1044
+ .spec-box { width:46px; height:46px; background:var(--deep); box-shadow:inset 0 0 0 1px var(--cell-ring); }
1045
+ .spec-shadows { background:#f2f2f5; border-radius:14px; padding:20px 18px 14px; gap:22px; }
1046
+ .spec-card { width:84px; height:50px; border-radius:10px; background:#ffffff; }
1047
+ .spec-shadows .spec-cap { color:#71718a; max-width:104px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
1048
+ .spec-cap { font:500 10.5px/1 var(--mono); color:var(--dim2); }
1049
+ .start-head { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }
1050
+ .delta { font:700 11px/1 var(--sans); letter-spacing:.04em; padding:5px 9px; border-radius:99px;
1051
+ background:var(--ok-soft); color:var(--ok); white-space:nowrap; }
1052
+ .delta-target { background:var(--amber-soft); color:var(--amber); font-weight:600; }
1053
+ .proj { color:var(--accent); font-weight:700; }
858
1054
  .ndot { display:inline-block; width:9px; height:9px; border-radius:3px; margin-right:4px; vertical-align:-1px; box-shadow:inset 0 0 0 1px var(--cell-ring); }
859
1055
  .nsim { color:var(--dim2); font-weight:400; padding:0 2px; }
860
1056
  .gift-sec { margin-top:16px; }
@@ -896,7 +1092,9 @@ const html = `<!doctype html>
896
1092
  .gk-mark { display:inline-block; width:15px; height:15px; margin-right:5px; vertical-align:-3px;
897
1093
  background:currentColor; -webkit-mask:url(${GK_MASK}) center/contain no-repeat; mask:url(${GK_MASK}) center/contain no-repeat; }
898
1094
  .author:hover { text-decoration:underline; }
899
- </style></head><body>
1095
+ </style>
1096
+ <!-- rmds-schema: ${NS_TAG} -->
1097
+ </head><body>
900
1098
  <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){}})()">
901
1099
  <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>
902
1100
  <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>
@@ -943,8 +1141,8 @@ ${componentsSection()}
943
1141
 
944
1142
  <footer>
945
1143
  <div class="foot-left">
946
- <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>
947
- <div class="foot-sub"><span class="ver">ver. ${VERSION}</span> · built and designed by <a class="author" href="https://gregkozakiewicz.com"><span class="gk-mark"></span>Greg Kozakiewicz</a></div>
1144
+ <div>Generated by <a class="brand" href="https://github.com/pencilrebel/roast-my-design-system">roast-my-design-system</a> <span class="ver">ver. ${VERSION}</span>, free on npm and as a Claude Code + Codex skill</div>
1145
+ <div class="foot-sub">Designed and built by <a class="author" href="https://gregkozakiewicz.com"><span class="gk-mark"></span>Greg Kozakiewicz</a> · <span class="scan-id" title="scan id">${scanId}</span></div>
948
1146
  </div>
949
1147
  <span class="creds"><span>Non-destructive scan</span><span>Read-only</span><span>Paths are real</span></span>
950
1148
  </footer>