roast-my-design-system 3.7.1 → 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
@@ -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.1)
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.1)
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
 
@@ -132,4 +132,4 @@ MIT. The code is yours to fork, modify and redistribute; the copyright notice tr
132
132
 
133
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
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.7.1"><img src="https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/gk-mark.png?v=3.7.1" height="15" alt="GK mark"></picture> Greg Kozakiewicz</a>.
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,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "3.7.1",
3
+ "version": "3.8.0",
4
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",
@@ -36,7 +36,7 @@ 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.1';
39
+ const VERSION = '3.8.0';
40
40
 
41
41
  // Report identity. The scan id is a stable digest of this scan's shape, salted
42
42
  // with the report namespace, so the same repo scanned twice reads the same and
@@ -321,7 +321,7 @@ function tile(value, label, metric, fallbackTarget, healthValue = value) {
321
321
  const av = displayAvg(metric);
322
322
  if (av !== null) rows.push(row('Avg Design System', `${n(av)}${avgNote}`, av, value));
323
323
  if (rm !== null && (rm > 0 || ZERO_IDEAL.has(metric) || metric === 'arbitrary')) rows.push(row('Reputable systems', n(rm), rm, value));
324
- return { num: n(value), label, health, rows };
324
+ return { num: n(value), label, health, rows, metric, healthValue };
325
325
  }
326
326
  const bigStats = [
327
327
  tile(colors.length, 'distinct colours', 'colors', 'a system needs ~24', tokenLed ? colorStrays : colors.length),
@@ -342,6 +342,42 @@ const healthScore = scoredTiles.length
342
342
  ? Math.round(scoredTiles.reduce((sum, s) => sum + SCORE_OF[s.health], 0) / scoredTiles.length)
343
343
  : null;
344
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
+
345
381
  // A repo with essentially no colour/spacing signal most likely has no design
346
382
  // system in it at all; say that up front instead of quietly scoring zeros.
347
383
  const noSystemLikely = colors.length === 0 || (colors.length < 3 && spacingTotal === 0);
@@ -459,6 +495,20 @@ function duplicatesSection() {
459
495
  <div class="fams">${iconCard}${dupeCards}${famCards}</div></div>`;
460
496
  }
461
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
+
462
512
  function typographySection() {
463
513
  if (!fontFamilies.length && !fontSizeTotal && !radiiTotal) return '';
464
514
  const mini = [
@@ -475,13 +525,59 @@ function typographySection() {
475
525
  .sort((a, b) => b.count - a.count).slice(0, 14);
476
526
  const receipts = (title, chips) => chips.length < 2 ? '' :
477
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
+ })();
478
573
  const fams = fontFamilies.slice(0, 8).map((f) =>
479
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('');
480
575
  return `<section>
481
576
  ${sectionHead('Typography &amp; shape', '')}
482
577
  <div class="stats minis">${mini.map((s) => statTile(s)).join('')}</div>
483
- ${receipts('the font sizes, by use', sizeChips)}
484
- ${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}
485
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>` : ''}
486
582
  </section>`;
487
583
  }
@@ -572,7 +668,7 @@ function giftSection() {
572
668
 
573
669
  function whereToStartSection() {
574
670
  const c = [];
575
- 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',
576
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.` });
577
673
  if (hardDupes.length > 0) {
578
674
  // Pick the pair with the strongest copy-paste evidence: sibling files in
@@ -588,10 +684,11 @@ function whereToStartSection() {
588
684
  return sc;
589
685
  };
590
686
  const d = [...hardDupes].sort((a, b) => strength(b) - strength(a))[0];
591
- 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;`,
592
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.` });
593
690
  }
594
- 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',
595
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])}.` });
596
693
  const strayOffender = offenders.find((o) => o.strayColors > 0);
597
694
  if (colorStrays > 12) {
@@ -601,13 +698,14 @@ function whereToStartSection() {
601
698
  const besideTokens = strayOffender && tf && (strayOffender.file === tf
602
699
  || strayOffender.file.split('/').slice(0, -1).join('/') === tf.split('/').slice(0, -1).join('/')
603
700
  || /variables|tokens|theme/i.test(strayOffender.file));
604
- 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`,
605
702
  sub: `They are hardcoded where no token names them${strayOffender ? (besideTokens
606
703
  ? `, including ${strayOffender.strayColors} in ${esc(strayOffender.file)}, sitting right next to the token definitions. Those are the cheapest wins on this page`
607
704
  : `; ${esc(strayOffender.file)} alone carries ${strayOffender.strayColors}`) : ''}. Every one is a value your agent will happily copy.` });
608
705
  }
609
706
  if (inline.count > 10) { const worst = inline.files[0];
610
- 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`,
611
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.` }); }
612
710
  if (arbitraryCount >= 20) {
613
711
  // Repeated brackets read as decisions; singletons are worth a pass. Small
@@ -618,7 +716,10 @@ function whereToStartSection() {
618
716
  const singles = arbitrary.filter((a) => a.count === 1).length;
619
717
  const top = arbitrary[0];
620
718
  const tokenFile = h.tokens.tokenFile;
621
- 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`,
622
723
  sub: repeats.length
623
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.`
624
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.` });
@@ -628,21 +729,66 @@ function whereToStartSection() {
628
729
  // the first move is to define it, next to the tokens the repo already has.
629
730
  const hasScale = twSpacing.length >= 5;
630
731
  const tf = h.tokens.tokenFile;
631
- c.push({ score: spacingTotal / 2, title: hasScale
732
+ c.push({ score: spacingTotal / 2, metric: 'spacing', title: hasScale
632
733
  ? `Fold ${n(spacingTotal)} off-scale spacing values back to the scale`
633
734
  : `Define a spacing scale, then fold ${n(spacingTotal)} values into it`,
634
735
  sub: hasScale
635
736
  ? `${n(spacingTotal)} values sit outside the scale you already use. Keep the deliberate exceptions and round the accidents to a neighbouring step.`
636
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.` });
637
738
  }
638
- 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);
639
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
+ : '';
640
786
  return `<section class="glass pad">
641
- ${sectionHead('Where to start', `${['One tweak', 'Two tweaks', 'Three tweaks'][top3.length - 1]} to increase your score.`)}
787
+ ${sectionHead('Where to start', head)}
642
788
  <div class="ledger">${top3.map((item, i) => `
643
789
  <div class="ledger-row start-row">
644
790
  <span class="ledger-idx">${String(i + 1).padStart(2, '0')}</span>
645
- <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>
646
792
  </div>`).join('')}</div></section>`;
647
793
  }
648
794
 
@@ -885,6 +1031,26 @@ const html = `<!doctype html>
885
1031
  footer .brand { color:var(--accent); font-weight:700; text-decoration:none; }
886
1032
  footer .brand:hover { text-decoration:underline; }
887
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; }
888
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); }
889
1055
  .nsim { color:var(--dim2); font-weight:400; padding:0 2px; }
890
1056
  .gift-sec { margin-top:16px; }
@@ -975,8 +1141,8 @@ ${componentsSection()}
975
1141
 
976
1142
  <footer>
977
1143
  <div class="foot-left">
978
- <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>
979
- <div class="foot-sub"><span class="ver">ver. ${VERSION}</span> · <span class="scan-id" title="scan id">${scanId}</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>
980
1146
  </div>
981
1147
  <span class="creds"><span>Non-destructive scan</span><span>Read-only</span><span>Paths are real</span></span>
982
1148
  </footer>