roast-my-design-system 3.7.1 → 3.9.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,6 +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
+ - **Per-package scores for monorepos.** One blended number hides which package is the problem: `packages/ui` scores 80 while `apps/web` scores 40, and now you can see it.
10
11
  - **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
12
  - **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
13
  - **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.
@@ -24,15 +25,15 @@ Your AI agent (Claude, Cursor, Copilot) builds UI by imitating what's already in
24
25
 
25
26
  The full report for vercel/ai-chatbot, top to bottom:
26
27
 
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)
28
+ ![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.9.0)
28
29
 
29
30
  The same report in light mode (one file, built-in toggle):
30
31
 
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)
32
+ ![The diagnosis report in light mode](https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/report-light-hero.png?v=3.9.0)
32
33
 
33
34
  ## What makes the numbers trustworthy
34
35
 
35
- - **Deterministic scanner, not AI sampling.** A zero-dependency Node script reads *every* file (a 5,000-file monorepo takes ~1.5s) and returns the same numbers every run. Claude narrates; it never counts.
36
+ - **Deterministic scanner, not AI sampling.** A zero-dependency Node script reads *every* file (about a second on a normal repo, a few on a large monorepo) and returns the same numbers every run. Claude narrates; it never counts.
36
37
  - **Read-only.** Nothing in your repo is modified. The only outputs are a temp JSON and the HTML report.
37
38
  - **No network, no telemetry.** Everything runs locally. Nothing about your code leaves your machine.
38
39
  - **Honest exclusions.** Test files, Storybook stories, docs sites, example apps, SVG artwork, and email templates (which *must* inline styles) are excluded, so you can't discredit the numbers on a technicality.
@@ -132,4 +133,4 @@ MIT. The code is yours to fork, modify and redistribute; the copyright notice tr
132
133
 
133
134
  **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
 
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>.
136
+ 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.9.0"><img src="https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/gk-mark.png?v=3.9.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.9.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.9.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
@@ -280,14 +280,16 @@ const WARN_TOLERANCE = { exactDuplicates: 2, inlineStyles: 10, nearPairs: 2, imp
280
280
  // Absence rule also skipped for arbitrary values: zero brackets is discipline
281
281
  // (nine of ten reputable systems sit at 0, including Tailwind-native shadcn/ui).
282
282
  const NO_ABSENCE_RULE = new Set(['colors', 'greys', 'arbitrary']);
283
- function healthOf(metric, value) {
283
+ function healthOf(metric, value, opts = {}) {
284
284
  const iv = ideal(metric);
285
285
  if (iv === null) return 'info';
286
286
  if (ZERO_IDEAL.has(metric)) {
287
287
  if (value === 0) return 'good';
288
288
  return value <= WARN_TOLERANCE[metric] ? 'warn' : 'bad';
289
289
  }
290
- if (!NO_ABSENCE_RULE.has(metric) && value < Math.max(1, iv * 0.05)) return 'bad';
290
+ // "almost nothing found" reads as a missing design system for a whole repo,
291
+ // but a single package with no spacing values is just a small package.
292
+ if (!opts.noAbsence && !NO_ABSENCE_RULE.has(metric) && value < Math.max(1, iv * 0.05)) return 'bad';
291
293
  if (value <= iv) return 'good';
292
294
  const mv = median(metric);
293
295
  const warnCap = mv && mv > iv ? mv : iv * 1.5;
@@ -321,7 +323,7 @@ function tile(value, label, metric, fallbackTarget, healthValue = value) {
321
323
  const av = displayAvg(metric);
322
324
  if (av !== null) rows.push(row('Avg Design System', `${n(av)}${avgNote}`, av, value));
323
325
  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 };
326
+ return { num: n(value), label, health, rows, metric, healthValue };
325
327
  }
326
328
  const bigStats = [
327
329
  tile(colors.length, 'distinct colours', 'colors', 'a system needs ~24', tokenLed ? colorStrays : colors.length),
@@ -342,6 +344,42 @@ const healthScore = scoredTiles.length
342
344
  ? Math.round(scoredTiles.reduce((sum, s) => sum + SCORE_OF[s.health], 0) / scoredTiles.length)
343
345
  : null;
344
346
 
347
+ // ---------- what a fix is worth ----------
348
+ // The score is the average of the scored tiles, so moving one tile across a
349
+ // band is worth an exact number of points: red to green 10, red to amber 5,
350
+ // amber to green 5 (on nine tiles). Deltas are only claimed when a move
351
+ // actually crosses a band — a cleanup that lands inside the same band is real
352
+ // work worth zero points, and saying otherwise would be a lie.
353
+ const TILE_COUNT = scoredTiles.length || 1;
354
+ const scoreOfHealth = (hh) => SCORE_OF[hh] ?? 0;
355
+ function bandPoints(metric, from, to) {
356
+ const a = healthOf(metric, from), b = healthOf(metric, to);
357
+ if (!(a in SCORE_OF) || !(b in SCORE_OF)) return 0;
358
+ return Math.round((scoreOfHealth(b) - scoreOfHealth(a)) / TILE_COUNT);
359
+ }
360
+ // The nearest number that moves this metric up a band, and what it pays.
361
+ function nextBand(metric, value) {
362
+ const iv = ideal(metric);
363
+ if (iv === null) return null;
364
+ const cur = healthOf(metric, value);
365
+ if (cur === 'good') return null;
366
+ if (cur === 'warn') {
367
+ const target = ZERO_IDEAL.has(metric) ? 0 : iv;
368
+ return { target, gain: bandPoints(metric, value, target) };
369
+ }
370
+ const warnTarget = ZERO_IDEAL.has(metric) ? WARN_TOLERANCE[metric] : Math.min(median(metric) ?? iv * 1.5, iv * 1.5);
371
+ return { target: Math.round(warnTarget), gain: bandPoints(metric, value, Math.round(warnTarget)) };
372
+ }
373
+ // The score this repo would have with a set of metrics at new values.
374
+ function projectedScore(applied) {
375
+ const scored = bigStats.filter((st) => st.health in SCORE_OF);
376
+ if (!scored.length) return healthScore;
377
+ return Math.round(scored.reduce((sum, st) => {
378
+ const v = applied.has(st.metric) ? applied.get(st.metric) : st.healthValue;
379
+ return sum + scoreOfHealth(healthOf(st.metric, v));
380
+ }, 0) / scored.length);
381
+ }
382
+
345
383
  // A repo with essentially no colour/spacing signal most likely has no design
346
384
  // system in it at all; say that up front instead of quietly scoring zeros.
347
385
  const noSystemLikely = colors.length === 0 || (colors.length < 3 && spacingTotal === 0);
@@ -459,6 +497,20 @@ function duplicatesSection() {
459
497
  <div class="fams">${iconCard}${dupeCards}${famCards}</div></div>`;
460
498
  }
461
499
 
500
+ // Scanned values get injected into style attributes below (a radius, a shadow,
501
+ // a font size from someone else's repo). Allow only a conservative CSS charset
502
+ // so a malformed or hostile value cannot break out of the attribute.
503
+ const cssSafe = (v) => (typeof v === 'string' && /^[-#0-9a-z%.,()\s\/]+$/i.test(v) && !/[;{}<>"']/.test(v) ? v.trim() : null);
504
+ // px equivalent of a length, for sorting and for rendering type at real size.
505
+ function toPx(v) {
506
+ const m = /^(-?\d*\.?\d+)\s*(px|rem|em|pt)?$/i.exec(String(v).trim());
507
+ if (!m) return null;
508
+ const num = parseFloat(m[1]);
509
+ const unit = (m[2] ?? 'px').toLowerCase();
510
+ if (!Number.isFinite(num) || num <= 0) return null;
511
+ return unit === 'px' ? num : unit === 'pt' ? num * 1.333 : num * 16;
512
+ }
513
+
462
514
  function typographySection() {
463
515
  if (!fontFamilies.length && !fontSizeTotal && !radiiTotal) return '';
464
516
  const mini = [
@@ -475,13 +527,59 @@ function typographySection() {
475
527
  .sort((a, b) => b.count - a.count).slice(0, 14);
476
528
  const receipts = (title, chips) => chips.length < 2 ? '' :
477
529
  `<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>`;
530
+
531
+ // ---------- specimens: the values rendered, not counted ----------
532
+ // Counting says "47 font sizes". Rendering them says it better: the eye
533
+ // finds the near-duplicates on its own, the way the grey ramp works.
534
+ const typeSpecimens = (() => {
535
+ const rows = (h.tokens.fontSizes ?? [])
536
+ .map((f) => ({ label: f.value, count: f.count, px: toPx(f.value) }))
537
+ .filter((f) => f.px && f.px >= 6 && f.px <= 200)
538
+ .sort((a, b) => b.px - a.px);
539
+ if (rows.length < 4) return '';
540
+ const shown = rows.slice(0, 16);
541
+ const capped = shown.some((r) => r.px > 52);
542
+ 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?`)}
543
+ <div class="spec-rows">${shown.map((r) => `
544
+ <div class="spec-type">
545
+ <span class="spec-val">${esc(r.label)} ×${r.count}</span>
546
+ <span class="spec-sample" style="font-size:${Math.min(r.px, 52)}px">Almost but not quite</span>
547
+ </div>`).join('')}</div>
548
+ ${rows.length > 16 ? `<div class="spec-more">and ${rows.length - 16} more</div>` : ''}</div>`;
549
+ })();
550
+
551
+ const radiiSpecimens = (() => {
552
+ const cells = (h.tokens.radii ?? [])
553
+ .map((r) => ({ label: r.value, count: r.count, css: cssSafe(r.value) }))
554
+ .filter((r) => r.css && !/^0$|^0px$/.test(r.css) && !/var\(|calc\(/i.test(r.css))
555
+ .slice(0, 14);
556
+ if (cells.length < 3) return '';
557
+ return `<div class="spec">${eyebrow(`the corners, drawn · ${n(radiiTotal)} radii in one interface`)}
558
+ <div class="spec-grid">${cells.map((r) => `
559
+ <div class="spec-cell"><div class="spec-box" style="border-radius:${r.css}"></div>
560
+ <span class="spec-cap">${esc(r.label)} ×${r.count}</span></div>`).join('')}</div></div>`;
561
+ })();
562
+
563
+ const shadowSpecimens = (() => {
564
+ const cells = (h.tokens.shadows ?? [])
565
+ .map((sh) => ({ label: sh.value, count: sh.count, css: cssSafe(sh.value) }))
566
+ .filter((sh) => sh.css && !/^(none|initial|inherit|unset|revert)$/i.test(sh.css) && !/var\(/i.test(sh.css))
567
+ .slice(0, 10);
568
+ if (cells.length < 2) return '';
569
+ const short = (v) => (v.length > 20 ? `${v.slice(0, 19)}…` : v);
570
+ 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`)}
571
+ <div class="spec-grid spec-shadows">${cells.map((sh) => `
572
+ <div class="spec-cell"><div class="spec-card" style="box-shadow:${sh.css}"></div>
573
+ <span class="spec-cap" title="${esc(sh.label)}">${esc(short(sh.label))} ×${sh.count}</span></div>`).join('')}</div></div>`;
574
+ })();
478
575
  const fams = fontFamilies.slice(0, 8).map((f) =>
479
576
  `<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
577
  return `<section>
481
578
  ${sectionHead('Typography &amp; shape', '')}
482
579
  <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)}
580
+ ${typeSpecimens || receipts('the font sizes, by use', sizeChips)}
581
+ ${radiiSpecimens || receipts('the radii, by use', radiiChips)}
582
+ ${shadowSpecimens}
485
583
  ${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
584
  </section>`;
487
585
  }
@@ -572,7 +670,7 @@ function giftSection() {
572
670
 
573
671
  function whereToStartSection() {
574
672
  const c = [];
575
- if (agentFiles.length === 0) c.push({ score: 60, title: 'Write the agent rules file',
673
+ if (agentFiles.length === 0) c.push({ score: 60, metric: null, title: 'Write the agent rules file',
576
674
  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
675
  if (hardDupes.length > 0) {
578
676
  // Pick the pair with the strongest copy-paste evidence: sibling files in
@@ -588,10 +686,11 @@ function whereToStartSection() {
588
686
  return sc;
589
687
  };
590
688
  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;`,
689
+ c.push({ score: 25 + hardDupes.length * 4, metric: 'exactDuplicates', after: hardDupes.length - 1,
690
+ title: `Crown the canonical &lt;${esc(d.name)}&gt;`,
592
691
  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
692
  }
594
- if (iconCollisions.length >= 5) c.push({ score: 20 + iconCollisions.length * 3, title: 'Merge the two icon sets',
693
+ if (iconCollisions.length >= 5) c.push({ score: 20 + iconCollisions.length * 3, metric: null, title: 'Merge the two icon sets',
595
694
  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
695
  const strayOffender = offenders.find((o) => o.strayColors > 0);
597
696
  if (colorStrays > 12) {
@@ -601,13 +700,14 @@ function whereToStartSection() {
601
700
  const besideTokens = strayOffender && tf && (strayOffender.file === tf
602
701
  || strayOffender.file.split('/').slice(0, -1).join('/') === tf.split('/').slice(0, -1).join('/')
603
702
  || /variables|tokens|theme/i.test(strayOffender.file));
604
- c.push({ score: colorStrays, title: `Tokenise the ${n(colorStrays)} stray colours`,
703
+ c.push({ score: colorStrays, metric: 'colors', after: 0, title: `Tokenise the ${n(colorStrays)} stray colours`,
605
704
  sub: `They are hardcoded where no token names them${strayOffender ? (besideTokens
606
705
  ? `, including ${strayOffender.strayColors} in ${esc(strayOffender.file)}, sitting right next to the token definitions. Those are the cheapest wins on this page`
607
706
  : `; ${esc(strayOffender.file)} alone carries ${strayOffender.strayColors}`) : ''}. Every one is a value your agent will happily copy.` });
608
707
  }
609
708
  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`,
709
+ c.push({ score: inline.count / 2, metric: 'inlineStyles', after: 0,
710
+ title: `Fold ${n(inline.count)} inline style blocks back into the system`,
611
711
  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
712
  if (arbitraryCount >= 20) {
613
713
  // Repeated brackets read as decisions; singletons are worth a pass. Small
@@ -618,7 +718,10 @@ function whereToStartSection() {
618
718
  const singles = arbitrary.filter((a) => a.count === 1).length;
619
719
  const top = arbitrary[0];
620
720
  const tokenFile = h.tokens.tokenFile;
621
- c.push({ score: arbitraryCount / 3, title: `Give your repeated bracket values names`,
721
+ // naming the repeats removes their counts; the one-offs are left alone on purpose
722
+ const afterNaming = arbitraryCount - repeats.reduce((sum, a) => sum + a.count, 0);
723
+ c.push({ score: arbitraryCount / 3, metric: 'arbitrary', after: afterNaming,
724
+ title: `Give your repeated bracket values names`,
622
725
  sub: repeats.length
623
726
  ? `${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
727
  : `${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,24 +731,124 @@ function whereToStartSection() {
628
731
  // the first move is to define it, next to the tokens the repo already has.
629
732
  const hasScale = twSpacing.length >= 5;
630
733
  const tf = h.tokens.tokenFile;
631
- c.push({ score: spacingTotal / 2, title: hasScale
734
+ c.push({ score: spacingTotal / 2, metric: 'spacing', title: hasScale
632
735
  ? `Fold ${n(spacingTotal)} off-scale spacing values back to the scale`
633
736
  : `Define a spacing scale, then fold ${n(spacingTotal)} values into it`,
634
737
  sub: hasScale
635
738
  ? `${n(spacingTotal)} values sit outside the scale you already use. Keep the deliberate exceptions and round the accidents to a neighbouring step.`
636
739
  : `${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
740
  }
638
- const top3 = c.sort((a, b) => b.score - a.score).slice(0, 3);
741
+ if (nearPairs.length >= 3) {
742
+ const p0 = nearPairs[0];
743
+ c.push({ score: 30 + nearPairs.length * 2, metric: 'nearPairs', after: 0,
744
+ title: `Collapse the ${n(nearPairs.length)} near-identical colours`,
745
+ 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.` });
746
+ }
747
+ if (important.count >= 10) {
748
+ const worst = important.files[0];
749
+ c.push({ score: 20 + important.count, metric: 'important', after: 0,
750
+ title: `Unwind the ${n(important.count)} !important declarations`,
751
+ 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.` });
752
+ }
753
+ if (neverImported.length >= 3) {
754
+ c.push({ score: 15 + neverImported.length, metric: 'neverImported', after: 0,
755
+ title: `Decide about the ${n(neverImported.length)} components nobody imports`,
756
+ 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.` });
757
+ }
758
+
759
+ // What each move is actually worth, then rank by payoff for real.
760
+ for (const item of c) {
761
+ item.delta = 0; item.target = null;
762
+ if (!item.metric) continue;
763
+ const now = bigStats.find((st) => st.metric === item.metric)?.healthValue;
764
+ if (now === undefined) continue;
765
+ if (item.after !== undefined) {
766
+ const gain = bandPoints(item.metric, now, item.after);
767
+ if (gain > 0) { item.delta = gain; continue; }
768
+ }
769
+ const nb = nextBand(item.metric, now);
770
+ if (nb && nb.gain > 0) item.target = nb;
771
+ }
772
+ const top3 = c.sort((a, b) => (b.delta - a.delta) || (b.score - a.score)).slice(0, 3);
639
773
  if (!top3.length) return '';
774
+ // The headline is the score with every move applied together, not the sum of
775
+ // the parts: two moves on the same tile must not be counted twice.
776
+ const applied = new Map();
777
+ for (const item of top3) if (item.metric && item.after !== undefined && item.delta > 0) applied.set(item.metric, item.after);
778
+ const after = projectedScore(applied);
779
+ const words = ['One tweak', 'Two tweaks', 'Three tweaks'][top3.length - 1];
780
+ const head = healthScore !== null && after > healthScore
781
+ ? `${words} · <b class="proj">${healthScore} &rarr; ${after}</b>`
782
+ : `${words} to increase your score.`;
783
+ const chip = (item) => item.delta > 0
784
+ ? `<span class="delta">+${item.delta}</span>`
785
+ : item.target
786
+ ? `<span class="delta delta-target">${item.target.target === 0 ? 'clear them all' : `under ${n(item.target.target)}`} · +${item.target.gain}</span>`
787
+ : '';
640
788
  return `<section class="glass pad">
641
- ${sectionHead('Where to start', `${['One tweak', 'Two tweaks', 'Three tweaks'][top3.length - 1]} to increase your score.`)}
789
+ ${sectionHead('Where to start', head)}
642
790
  <div class="ledger">${top3.map((item, i) => `
643
791
  <div class="ledger-row start-row">
644
792
  <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>
793
+ <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
794
  </div>`).join('')}</div></section>`;
647
795
  }
648
796
 
797
+ // ---------- package by package ----------
798
+ // One number for a monorepo hides which package is the problem. Each package
799
+ // is judged on the same nine tiles, with usage counted repo-wide, so a shared
800
+ // component another package imports is adopted rather than dead.
801
+ const PKG_LABELS = {
802
+ colors: 'stray colours', greys: 'greys', spacing: 'off-scale spacing values',
803
+ exactDuplicates: 'duplicated components', inlineStyles: 'inline style blocks',
804
+ nearPairs: 'near-identical colour pairs', important: '!important declarations',
805
+ neverImported: 'components nobody imports', arbitrary: 'arbitrary bracket values',
806
+ };
807
+ function scorePackage(m) {
808
+ const tokenLed = m.colorTokens >= m.colorStrays && m.colorTokens > 0;
809
+ const vals = {
810
+ colors: tokenLed ? m.colorStrays : m.colors,
811
+ greys: tokenLed ? m.greyStrays : m.greys,
812
+ spacing: m.spacing,
813
+ exactDuplicates: m.exactDuplicates,
814
+ inlineStyles: m.inlineStyles,
815
+ nearPairs: m.nearPairs,
816
+ important: m.important,
817
+ neverImported: m.neverImported,
818
+ arbitrary: m.arbitrary,
819
+ };
820
+ const rows = Object.entries(vals).map(([metric, v]) => ({ metric, value: v, health: healthOf(metric, v, { noAbsence: true }) }));
821
+ const scored = rows.filter((r) => r.health in SCORE_OF);
822
+ if (!scored.length) return null;
823
+ const score = Math.round(scored.reduce((sum, r) => sum + SCORE_OF[r.health], 0) / scored.length);
824
+ // the worst finding: furthest past its ideal among the failing tiles
825
+ const over = (r) => { const iv = ideal(r.metric); return iv ? r.value / Math.max(iv, 1) : r.value; };
826
+ const worst = scored.filter((r) => r.health === 'bad').sort((a, b) => over(b) - over(a))[0]
827
+ ?? scored.filter((r) => r.health === 'warn').sort((a, b) => over(b) - over(a))[0];
828
+ return { score, worst };
829
+ }
830
+ function packagesSection() {
831
+ const pkgs = (h.packages ?? []).filter((p) => p.scored && p.metrics);
832
+ if (pkgs.length < 2) return '';
833
+ const rated = pkgs.map((p) => ({ ...p, ...scorePackage(p.metrics) })).filter((p) => p.score !== undefined && p.score !== null);
834
+ if (rated.length < 2) return '';
835
+ rated.sort((a, b) => a.score - b.score);
836
+ const shown = rated.slice(0, 10);
837
+ const band = (sc) => (sc >= 85 ? 'good' : sc >= 55 ? 'warn' : 'bad');
838
+ const skipped = (h.packages ?? []).length - rated.length;
839
+ const rows = shown.map((p) => `
840
+ <tr><td class="mono strong">${esc(p.dir)}</td>
841
+ <td><span class="pkg-score pkg-${band(p.score)}">${p.score}</span></td>
842
+ <td>${p.worst ? `${n(p.worst.value)} ${esc(PKG_LABELS[p.worst.metric] ?? p.worst.metric)}` : '<span class="dim">nothing over the line</span>'}</td>
843
+ <td class="dim">${n(p.codeFiles)} files</td></tr>`).join('');
844
+ return `<section class="glass pad">
845
+ ${sectionHead('Package by package', `${rated.length} package${rated.length === 1 ? '' : 's'} with enough UI to judge · the repo score above is the whole thing blended${skipped > 0 ? `, and ${skipped} package${skipped === 1 ? ' was' : 's were'} too small or too backend to score` : ''}`)}
846
+ <div class="tbl-wrap"><table><thead><tr><th>package</th><th>score</th><th>worst finding</th><th>size</th></tr></thead><tbody>${rows}</tbody></table></div>
847
+ ${rated.length > 10 ? `<div class="spec-more">and ${rated.length - 10} more</div>` : ''}
848
+ ${healthScore !== null && rated.length && Math.min(...rated.map((p) => p.score)) > healthScore
849
+ ? `<p class="sub" style="margin-top:12px">A repo scores below its own packages by arithmetic, not by accident: distinct values add up across packages, so the whole always carries more than any part. Read the package scores for where each team stands, and the repo score for what your agent sees when it looks at everything at once.</p>` : ''}</section>`;
850
+ }
851
+
649
852
  function agentSection() {
650
853
  const have = agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)} · ${esc(c.tool ?? c.kind)}</span>`).join('');
651
854
  const msg = agentFiles.length
@@ -885,6 +1088,31 @@ const html = `<!doctype html>
885
1088
  footer .brand { color:var(--accent); font-weight:700; text-decoration:none; }
886
1089
  footer .brand:hover { text-decoration:underline; }
887
1090
  footer .creds { display:flex; gap:16px; font:700 9.5px/1.6 var(--sans); letter-spacing:.16em; text-transform:uppercase; color:var(--dim2); }
1091
+ .pkg-score { display:inline-block; min-width:38px; text-align:center; font:700 12.5px/1 var(--sans);
1092
+ padding:6px 8px; border-radius:8px; }
1093
+ .pkg-good { background:var(--ok-soft); color:var(--ok); }
1094
+ .pkg-warn { background:var(--amber-soft); color:var(--amber); }
1095
+ .pkg-bad { background:var(--coral-soft); color:var(--coral); }
1096
+ .spec { margin-top:18px; }
1097
+ .spec-rows { display:grid; gap:2px; margin-top:8px; }
1098
+ .spec-type { display:flex; align-items:baseline; gap:16px; padding:5px 0; border-bottom:1px solid var(--line-soft); }
1099
+ .spec-type:last-child { border-bottom:0; }
1100
+ .spec-val { font:600 11px/1.4 var(--mono); color:var(--dim2); min-width:104px; flex-shrink:0; }
1101
+ .spec-sample { color:var(--text); line-height:1.15; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
1102
+ font-family:var(--disp); letter-spacing:-.01em; }
1103
+ .spec-more { font:600 10.5px/1 var(--mono); color:var(--dim2); margin-top:10px; }
1104
+ .spec-grid { display:flex; flex-wrap:wrap; gap:16px; margin-top:12px; }
1105
+ .spec-cell { display:grid; gap:7px; justify-items:center; }
1106
+ .spec-box { width:46px; height:46px; background:var(--deep); box-shadow:inset 0 0 0 1px var(--cell-ring); }
1107
+ .spec-shadows { background:#f2f2f5; border-radius:14px; padding:20px 18px 14px; gap:22px; }
1108
+ .spec-card { width:84px; height:50px; border-radius:10px; background:#ffffff; }
1109
+ .spec-shadows .spec-cap { color:#71718a; max-width:104px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
1110
+ .spec-cap { font:500 10.5px/1 var(--mono); color:var(--dim2); }
1111
+ .start-head { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }
1112
+ .delta { font:700 11px/1 var(--sans); letter-spacing:.04em; padding:5px 9px; border-radius:99px;
1113
+ background:var(--ok-soft); color:var(--ok); white-space:nowrap; }
1114
+ .delta-target { background:var(--amber-soft); color:var(--amber); font-weight:600; }
1115
+ .proj { color:var(--accent); font-weight:700; }
888
1116
  .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
1117
  .nsim { color:var(--dim2); font-weight:400; padding:0 2px; }
890
1118
  .gift-sec { margin-top:16px; }
@@ -960,6 +1188,8 @@ ${giftSection()}
960
1188
 
961
1189
  <div class="stats">${bigStats.map((s) => statTile(s)).join('')}</div>
962
1190
 
1191
+ ${packagesSection()}
1192
+
963
1193
  ${agentSection()}
964
1194
 
965
1195
  <section style="margin-top:16px">${paletteSection()}</section>
@@ -975,8 +1205,8 @@ ${componentsSection()}
975
1205
 
976
1206
  <footer>
977
1207
  <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>
1208
+ <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>
1209
+ <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
1210
  </div>
981
1211
  <span class="creds"><span>Non-destructive scan</span><span>Read-only</span><span>Paths are real</span></span>
982
1212
  </footer>
@@ -1036,6 +1266,10 @@ if (summaryPath) {
1036
1266
  noSystemLikely,
1037
1267
  verdict,
1038
1268
  tiles: bigStats.map((s) => ({ label: s.label, value: s.num, health: s.health })),
1269
+ packages: (h.packages ?? []).filter((p) => p.scored && p.metrics)
1270
+ .map((p) => ({ dir: p.dir, name: p.name, ...(scorePackage(p.metrics) ?? {}) }))
1271
+ .filter((p) => p.score !== undefined)
1272
+ .sort((a, b) => a.score - b.score),
1039
1273
  report: outPath,
1040
1274
  }, null, 2));
1041
1275
  }
@@ -14,6 +14,9 @@ import { harvestComponents } from './components.mjs';
14
14
  import { harvestTokens } from './tokens.mjs';
15
15
  import { findDuplicates } from './duplicates.mjs';
16
16
  import { harvestContext } from './context.mjs';
17
+ import { resolveWorkspaces } from '../lib/workspaces.mjs';
18
+ import { nearColorPairs } from '../lib/nearpairs.mjs';
19
+ import { neverImportedComponents } from '../lib/neverimported.mjs';
17
20
 
18
21
  function arg(name, fallback) {
19
22
  const i = process.argv.indexOf(`--${name}`);
@@ -35,6 +38,70 @@ const tokens = harvestTokens(target, files.styles, files.code);
35
38
  const duplicates = findDuplicates(components, profile.uiDir, target);
36
39
  const context = harvestContext(target);
37
40
 
41
+ // ---------- per-package pass (monorepos) ----------
42
+ // Styling is measured inside each package, but usage is counted repo-wide: a
43
+ // component another package imports is adopted, not dead. Files are already
44
+ // walked, so this re-reads each package's own files once, not the whole repo
45
+ // per package. Packages with too little UI to judge are listed, never scored.
46
+ const workspaces = resolveWorkspaces(target);
47
+ const packages = [];
48
+ if (workspaces.length > 1) {
49
+ const inDir = (f, dir) => f === dir || f.startsWith(`${dir}/`);
50
+ // deepest-first so apps/web/sub is attributed to itself, not to apps/web
51
+ const byDepth = [...workspaces].sort((a, b) => b.dir.split('/').length - a.dir.split('/').length);
52
+ const claimed = new Map();
53
+ const claim = (f) => byDepth.find((w) => inDir(f, w.dir))?.dir ?? null;
54
+ for (const f of files.code) { const d = claim(f); if (d) (claimed.get(d) ?? claimed.set(d, { code: [], styles: [] }).get(d)).code.push(f); }
55
+ for (const f of files.styles) { const d = claim(f); if (d) (claimed.get(d) ?? claimed.set(d, { code: [], styles: [] }).get(d)).styles.push(f); }
56
+
57
+ // Only packages that actually contain UI are worth reading twice: a backend
58
+ // or config package has no styling to judge, and scanning it would cost time
59
+ // to produce a meaningless perfect score. Biggest UI packages first, capped,
60
+ // so a 100-package monorepo cannot blow the scan budget.
61
+ const UI_RE = /\.(tsx|jsx|vue|svelte)$/;
62
+ const uiCount = (w) => {
63
+ const own = claimed.get(w.dir);
64
+ return own ? own.code.filter((f) => UI_RE.test(f)).length + own.styles.length : 0;
65
+ };
66
+ const candidates = workspaces
67
+ .map((w) => ({ w, ui: uiCount(w) }))
68
+ .sort((a, b) => b.ui - a.ui);
69
+ const scanning = new Set(candidates.filter((c) => c.ui >= 8).slice(0, 30).map((c) => c.w.dir));
70
+
71
+ for (const w of workspaces) {
72
+ const own = claimed.get(w.dir);
73
+ if (!own) continue;
74
+ const codeCount = own.code.length, styleCount = own.styles.length;
75
+ const entry = { name: w.name, dir: w.dir, codeFiles: codeCount, styleFiles: styleCount, scored: false };
76
+ if (!scanning.has(w.dir)) { packages.push(entry); continue; }
77
+ const t = harvestTokens(target, own.styles, own.code);
78
+ const comps = components.filter((c) => inDir(c.file, w.dir));
79
+ const dupes = duplicates.exactDuplicates.filter((d) => !d.wrapped
80
+ && d.files.every((f) => inDir(typeof f === 'string' ? f : f.file, w.dir)));
81
+ const colorTokens = t.colors.filter((c) => c.isToken).length;
82
+ const signal = t.colors.length + t.spacing.length + t.inlineStyles.count
83
+ + (t.tailwind.colors.length + t.tailwind.spacing.length);
84
+ if (signal < 5) { packages.push(entry); continue; } // nothing to judge, so no verdict
85
+ entry.scored = true;
86
+ entry.metrics = {
87
+ colors: t.colors.length,
88
+ colorTokens,
89
+ colorStrays: t.colors.length - colorTokens,
90
+ greys: t.greyCount,
91
+ greyStrays: t.colors.filter((c) => !c.isToken && c.value.startsWith('#')).length,
92
+ spacing: t.spacing.length + t.tailwind.spacing.filter((v) => v.value.startsWith('[')).length,
93
+ exactDuplicates: dupes.length,
94
+ inlineStyles: t.inlineStyles.count,
95
+ nearPairs: nearColorPairs(t.colors).length,
96
+ important: t.important?.count ?? 0,
97
+ neverImported: neverImportedComponents(comps, null).length,
98
+ arbitrary: (t.tailwind.arbitrary ?? []).reduce((sum, a) => sum + a.count, 0),
99
+ components: comps.filter((c) => !c.isPage).length,
100
+ };
101
+ packages.push(entry);
102
+ }
103
+ }
104
+
38
105
  const harvest = {
39
106
  repo: target,
40
107
  harvestedAt: new Date().toISOString(),
@@ -49,6 +116,7 @@ const harvest = {
49
116
  tokens,
50
117
  duplicates,
51
118
  context,
119
+ packages,
52
120
  };
53
121
  harvest.tookMs = Date.now() - t0;
54
122
 
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Workspace resolution. A monorepo declares its packages in one of a few
3
+ * places and always as globs, never as a fixed folder shape — guessing from
4
+ * folder depth misses most of them (cal.com keeps 23 packages that a naive
5
+ * two-level scan reports as zero). Read the declaration instead.
6
+ *
7
+ * Supports: package.json `workspaces` (array or { packages: [] }),
8
+ * pnpm-workspace.yaml, and negation patterns.
9
+ */
10
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
14
+
15
+ /** Expand one glob ('packages/*', 'apps/**', 'tools/x') to existing directories. */
16
+ function expand(root, pattern) {
17
+ if (pattern === '.' || pattern === './') return [];
18
+ const parts = pattern.replace(/\/+$/, '').split('/').filter(Boolean);
19
+ let dirs = [''];
20
+ for (const part of parts) {
21
+ const next = [];
22
+ for (const d of dirs) {
23
+ const abs = join(root, d);
24
+ if (part === '*' || part === '**') {
25
+ let entries;
26
+ try { entries = readdirSync(abs, { withFileTypes: true }); } catch { continue; }
27
+ for (const e of entries) {
28
+ if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules') continue;
29
+ const rel = d ? `${d}/${e.name}` : e.name;
30
+ next.push(rel);
31
+ // '**' also matches deeper levels
32
+ if (part === '**') dirs.push(rel);
33
+ }
34
+ } else {
35
+ const rel = d ? `${d}/${part}` : part;
36
+ if (existsSync(join(root, rel))) next.push(rel);
37
+ }
38
+ }
39
+ dirs = next;
40
+ }
41
+ return dirs.filter((d) => { try { return statSync(join(root, d)).isDirectory(); } catch { return false; } });
42
+ }
43
+
44
+ /**
45
+ * Resolve a repo's workspace packages.
46
+ * Returns [{ name, dir }] with dir relative to the repo root, or [] when the
47
+ * repo is not a workspace monorepo.
48
+ */
49
+ export function resolveWorkspaces(root) {
50
+ const patterns = [];
51
+ const pkg = readJSON(join(root, 'package.json'));
52
+ const ws = pkg?.workspaces;
53
+ if (Array.isArray(ws)) patterns.push(...ws);
54
+ else if (Array.isArray(ws?.packages)) patterns.push(...ws.packages);
55
+
56
+ // pnpm-workspace.yaml: the "packages:" block, read line by line. Comments and
57
+ // blank lines are legal inside it (langfuse opens with one), and sibling
58
+ // top-level keys such as "catalog:" end it.
59
+ try {
60
+ const lines = readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8').split('\n');
61
+ let inBlock = false;
62
+ for (const line of lines) {
63
+ if (/^packages:\s*$/.test(line)) { inBlock = true; continue; }
64
+ if (!inBlock) continue;
65
+ if (/^\s*(#.*)?$/.test(line)) continue; // comment or blank
66
+ if (/^\S/.test(line)) break; // next top-level key
67
+ const m = /^\s*-\s*["']?([^"'#]+?)["']?\s*$/.exec(line);
68
+ if (m) patterns.push(m[1].trim());
69
+ }
70
+ } catch { /* not a pnpm workspace */ }
71
+
72
+ if (!patterns.length) return [];
73
+
74
+ const negations = patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1).replace(/\/+$/, ''));
75
+ const isNegated = (dir) => negations.some((neg) => {
76
+ if (neg.includes('*')) return expand(root, neg).includes(dir);
77
+ return dir === neg || dir.startsWith(`${neg}/`);
78
+ });
79
+
80
+ const seen = new Map();
81
+ for (const pattern of patterns) {
82
+ if (pattern.startsWith('!')) continue;
83
+ for (const dir of expand(root, pattern)) {
84
+ if (!dir || dir === '.' || isNegated(dir) || seen.has(dir)) continue;
85
+ // a workspace package is one that declares itself
86
+ const own = readJSON(join(root, dir, 'package.json'));
87
+ if (!own) continue;
88
+ seen.set(dir, { name: own.name || dir, dir });
89
+ }
90
+ }
91
+ return [...seen.values()].sort((a, b) => a.dir.localeCompare(b.dir));
92
+ }