roast-my-design-system 6.0.1 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/README.md +8 -5
  2. package/bin/roast.mjs +5 -1
  3. package/package.json +1 -1
  4. package/skills/roast-my-design-system/scripts/diagnose/index.mjs +67 -146
  5. package/skills/roast-my-design-system/scripts/diagnose/score.mjs +215 -0
  6. package/skills/roast-my-design-system/scripts/harvest/components.mjs +48 -32
  7. package/skills/roast-my-design-system/scripts/harvest/duplicates.mjs +7 -3
  8. package/skills/roast-my-design-system/scripts/harvest/index.mjs +10 -33
  9. package/skills/roast-my-design-system/scripts/harvest/tokens.mjs +69 -21
  10. package/skills/roast-my-design-system/scripts/harvest/walk.mjs +19 -2
  11. package/skills/roast-my-design-system/scripts/lib/color.mjs +1 -1
  12. package/skills/roast-my-design-system/scripts/lib/guard-api.mjs +3 -3
  13. package/skills/roast-my-design-system/scripts/lib/version.mjs +5 -1
  14. package/skills/roast-my-design-system/scripts/lib/workspaces.mjs +21 -1
  15. package/skills/roast-my-design-system/scripts/mcp/engine.mjs +5 -2
  16. package/skills/roast-my-design-system/scripts/mcp/knowledge.mjs +6 -2
  17. package/skills/roast-my-design-system/scripts/mcp/server.mjs +3 -0
  18. package/skills/roast-my-design-system/scripts/mcp/tools.mjs +2 -0
  19. package/skills/roast-my-design-system/scripts/profiles/index.mjs +90 -0
  20. package/skills/roast-my-design-system/scripts/profiles/library.mjs +29 -0
  21. package/skills/roast-my-design-system/scripts/profiles/product.mjs +21 -0
  22. package/skills/roast-my-design-system/scripts/rules/build.mjs +8 -5
  23. package/skills/roast-my-design-system/scripts/sarif/index.mjs +3 -2
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- <img src="https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/roaster_logo_300px.png?v=6.0.1" width="72" alt="roast-my-design-system">
1
+ <img src="https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/roaster_logo_300px.png?v=7.1.0" width="72" alt="roast-my-design-system">
2
2
 
3
3
  # roast-my-design-system
4
4
 
@@ -10,6 +10,8 @@
10
10
 
11
11
  A free CLI tool (and Claude Code skill) that roasts your repo's design system with real data, then generates the rules that keep your AI agent on-system.
12
12
 
13
+ > **New in 7.0.** The scoring code is now a separate file that other tools can import, so a CI check gets the same score as the report. Every `summary.json` now records its schema version and which benchmark it was scored against. Five counting bugs are fixed: a bracket spacing class like `p-[13px]` was counted twice, a hex colour inside a CSS comment was counted, and three smaller ones. Most repos lose a few counts. shadcn/ui goes from 325 colours to 323 and keeps its score of 65. Large monorepos scan about ten times faster. If you use the score as a CI threshold, run a fresh scan before comparing. If you read the JSON, a tile's `value` is now a number and the printed text is in `display`.
14
+
13
15
  > **New in 6.0: a dark mode is not sprawl, and the ruler has been rebuilt.** A system with a dark theme states most of its colours twice. One with a density switch states its spacing twice again. Every restatement was counted, so the systems doing the most work scored the worst. Now only a token's first statement counts. A factory-fresh shadcn install goes from 20 colours to 16, and Shoelace from 420 to 219. Two related faults went with it. `border-radius: var(--radius)` was counted as a radius value, so Polaris showed 49 radii of which 36 were references to its own tokens, and Telekom showed 52 font sizes of which 48 were. A new finding names one token holding two different colours in two packages of a monorepo, which fires 8 times across 19 real repos. The benchmark was rebuilt with all of it, so every score is measured against a ruler that counts the same way: cal.com 15 to 20, Cloudscape 35 to 40, MUI 50 to 55. If you have a score from 5.x, take a fresh one before you compare.
14
16
 
15
17
  Run it on your codebase and get, in about a second:
@@ -43,7 +45,7 @@ Type these in a terminal and you get a result.
43
45
  | `... --sarif` | `design-system-roast.sarif` for GitHub code scanning: upload it in CI and findings appear in the Security tab, annotated on files |
44
46
  | `... --check` | The working tree's changed files checked against the design system, in the terminal. Exits 1 on findings, so it slots into scripts |
45
47
  | <code>...&nbsp;--exclude&nbsp;lab/</code> | Leave a folder out of the scan (repeat the flag or comma-separate). Or list folders in a `.roastignore` file at the repo root. Either way the report says so in the header; see [Scoping the scan](#scoping-the-scan) |
46
- | `... --json` | The scan summary as JSON on stdout, for scripts and pipelines |
48
+ | `... --json` | The scan summary as JSON on stdout, for scripts and pipelines. Includes `schemaVersion`, the benchmark used, and every metric as a number, so two scans can be compared |
47
49
  | <code>...&nbsp;--by&nbsp;"Dwayne&nbsp;Hicks"</code> | Puts a name in the report header, for when you ran it for someone else |
48
50
  | <code>...&nbsp;--theme&nbsp;light</code>&nbsp;/ <code>--out&nbsp;&lt;file&gt;</code>&nbsp;/ <code>--no-open</code>&nbsp;/ <code>--open</code> | Light report, custom report path, never open the browser, always open it |
49
51
 
@@ -71,11 +73,11 @@ Not to be confused with each other: **"Why this matters"** is generic, ships wit
71
73
 
72
74
  The full report for vercel/ai-chatbot, top to bottom, including "What the numbers mean", Claude's read of the scan, embedded right under the verdict:
73
75
 
74
- ![The full diagnosis report for vercel/ai-chatbot in dark mode: health score, the What the numbers mean analysis written by Claude, priced Where to start moves each with its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, 3-yardstick tiles, the adoption map treemap, palette forensics, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-full-dark.png?v=6.0.1)
76
+ ![The full diagnosis report for vercel/ai-chatbot in dark mode: health score, the What the numbers mean analysis written by Claude, priced Where to start moves each with its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, 3-yardstick tiles, the adoption map treemap, palette forensics, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-full-dark.png?v=7.1.0)
75
77
 
76
78
  The same report in light mode (one file, built-in toggle):
77
79
 
78
- ![The diagnosis report in light mode](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-light-hero.png?v=6.0.1)
80
+ ![The diagnosis report in light mode](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-light-hero.png?v=7.1.0)
79
81
 
80
82
  ## What makes the numbers trustworthy
81
83
 
@@ -86,7 +88,8 @@ The same report in light mode (one file, built-in toggle):
86
88
  - **Honest gaps.** When a repo's components register in a pattern the scan can not read, the component tiles say "not measured" and drop out of the score. A zero the scanner never earned is presented as blindness, not discipline.
87
89
  - **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. Your own exclusions (`.roastignore`, `--exclude`) are printed in the report header with file counts, so a scoped scan can never pass itself off as the whole repo.
88
90
  - **Intent-aware counting (v3).** Runtime-computed inline styles, compound-component APIs and wrapper components are not crimes and are not counted as ones. Token-led repos are judged on their hardcoded strays, not their token architecture. Repeated arbitrary values are read as decisions without names, not drift.
89
- - **A real benchmark.** The "Avg Design System" yardstick comes from scanning 34 public React repos (cal.com, excalidraw, supabase, grafana, twenty, dub, langfuse…). Median: 130 colours, 17 greys, 20 duplicated components, 49 inline style blocks, 70 arbitrary Tailwind values.
91
+ - **The scoring code can be imported.** `scoreHarvest(harvest)` in `diagnose/score.mjs` returns the score, the nine tile results and the metrics as plain data. The report uses the same function. A CI check gets the same numbers as the page, and every `summary.json` records its schema version and the benchmark used.
92
+ - **A real benchmark.** The "Avg Design System" yardstick comes from scanning 34 public React repos (cal.com, excalidraw, supabase, grafana, twenty, dub, langfuse…). Median: 130 colours, 17 greys, 20 duplicated components, 49 inline style blocks, 70 arbitrary Tailwind values. The builder and the repo list are in `tools/benchmark/`, so the ruler can be checked, not just quoted.
90
93
  - **A second yardstick: reputable systems.** Curated, scoped scans of 10 well-known design systems (shadcn/ui, Primer, Polaris, Carbon, Material UI, Chakra, Ant Design, GOV.UK, Spectrum, Cloudscape) show what disciplined looks like at scale.
91
94
 
92
95
  ## Scoping the scan
package/bin/roast.mjs CHANGED
@@ -171,12 +171,16 @@ for (const [flagName, file] of [
171
171
  process.exit(1);
172
172
  }
173
173
 
174
+ // Every value-taking flag is consumed before the path is looked for, so
175
+ // `--out report.html .` no longer reads report.html as the repo (it did,
176
+ // until 6.0.1: flags in the "wrong" order died with "Not a directory").
177
+ const outOpt = opt('out', null);
174
178
  const target = resolve(argv.find((a) => !a.startsWith('--')) || process.cwd());
175
179
  if (!existsSync(target) || !statSync(target).isDirectory()) {
176
180
  console.error(`Not a directory: ${target}`);
177
181
  process.exit(1);
178
182
  }
179
- const outPath = resolve(opt('out', join(target, 'design-system-roast.html')));
183
+ const outPath = resolve(outOpt ?? join(target, 'design-system-roast.html'));
180
184
 
181
185
  const tmp = mkdtempSync(join(tmpdir(), 'roast-'));
182
186
  const harvestPath = join(tmp, 'harvest.json');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "6.0.1",
3
+ "version": "7.1.0",
4
4
  "mcpName": "io.github.gregkozakiewicz/roast-my-design-system",
5
5
  "description": "Your AI can write the UI. This makes sure it writes your UI. A deterministic scanner scores your design system 0-100 against 34 public repos, reads React and web components (Stencil, Lit), hands you a copy-paste fix prompt for each top finding, writes rules for Claude, Cursor, Copilot and Windsurf with --apply, and runs as a local MCP server with --mcp.",
6
6
  "keywords": [
@@ -31,11 +31,15 @@ import { feedbackUrl, FEEDBACK_ASK, FEEDBACK_CTA } from '../lib/feedback.mjs';
31
31
  import { fixPrompt } from '../lib/fixprompt.mjs';
32
32
  import { WHY } from './why.mjs';
33
33
  import { parseColor, luminance, isGrey } from '../lib/color.mjs';
34
+ import { loadBenchmark, benchHelpers, makeHealthOf, coreMetrics, tileHealths, scoreOfTiles, scorePackage as scorePackageOf, ZERO_IDEAL, WARN_TOLERANCE, SCORE_OF, SCHEMA_VERSION } from './score.mjs';
35
+ import { profileOf } from '../profiles/index.mjs';
34
36
 
35
- // The benchmark (Ideal-2026 norms + scanned-repo stats) ships next to the
36
- // code so the page works offline; degrade gracefully when absent.
37
- const BENCH_PATH = join(dirname(fileURLToPath(import.meta.url)), '../benchmark/benchmark.json');
38
- const bench = existsSync(BENCH_PATH) ? JSON.parse(readFileSync(BENCH_PATH, 'utf8')) : null;
37
+ // The benchmark and every judgement made against it live in score.mjs; this
38
+ // file only draws. The same numbers reach summary.json through scoreHarvest.
39
+ const bench = loadBenchmark();
40
+ const B = benchHelpers(bench);
41
+ const { percentile, cleanerPct, ideal, median, displayAvg, refMedian } = B;
42
+ const healthOf = makeHealthOf(B);
39
43
 
40
44
  function arg(name, fallback) {
41
45
  const i = process.argv.indexOf(`--${name}`);
@@ -46,6 +50,7 @@ if (!inPath) { console.error('Usage: node src/diagnose/index.mjs <harvest.json>
46
50
  const outPath = resolve(arg('out', 'diagnosis.html'));
47
51
 
48
52
  const h = JSON.parse(readFileSync(inPath, 'utf8'));
53
+ const M = coreMetrics(h);
49
54
 
50
55
  // The GK mark (32px favicon, also gregkozakiewicz.com). Reused in the footer
51
56
  // as a CSS mask filled with currentColor so it works in both themes.
@@ -201,9 +206,8 @@ const nonGreys = colors.filter((c) => !isGrey(c.value));
201
206
  const twColorUtils = (h.tokens.tailwind?.colors ?? []).length;
202
207
  const spacing = h.tokens.spacing ?? [];
203
208
  const twSpacing = h.tokens.tailwind?.spacing ?? [];
204
- // Off-scale spacing only: CSS-declared values plus arbitrary brackets. Using
205
- // many steps of the sanctioned Tailwind scale is health, not sprawl.
206
- const spacingTotal = spacing.length + twSpacing.filter((v) => v.value.startsWith('[')).length;
209
+ // Off-scale spacing only (formula in score.mjs, shared with the packages pass).
210
+ const spacingTotal = M.spacing;
207
211
  const exactDupes = h.duplicates.exactDuplicates ?? [];
208
212
  // A wrapped pair (one file imports the name from the other) is composition,
209
213
  // not competition — listed with a badge, but not counted as a duplicate.
@@ -224,14 +228,11 @@ const typefaces = distinctTypefaces(fontFamilies);
224
228
  // Token-led repos (shadcn/Tailwind semantic setups) hold most of their colours
225
229
  // as deliberate CSS-variable tokens; judging them on the total punishes the
226
230
  // exact architecture the ideal recommends. Health rides on the strays instead.
227
- const colorTokens = colors.filter((c) => c.isToken).length;
228
- const colorStrays = colors.length - colorTokens;
229
- const tokenLed = colorTokens >= colorStrays && colorTokens > 0;
230
- const greyStrays = greys.filter((c) => !c.isToken).length;
231
+ const { colorTokens, colorStrays, tokenLed, greyStrays } = M;
231
232
 
232
233
  // Arbitrary bracket values (p-[13px], text-[10px]) — scale erosion, counted.
233
234
  const arbitrary = h.tokens.tailwind?.arbitrary ?? [];
234
- const arbitraryCount = arbitrary.reduce((sum, a) => sum + a.count, 0);
235
+ const arbitraryCount = M.arbitrary;
235
236
 
236
237
  // !important declarations: the cascade admitting defeat.
237
238
  const important = h.tokens.important ?? { count: 0, files: [] };
@@ -243,52 +244,21 @@ const nearPairs = nearColorPairs(colors);
243
244
  // rather than wallpaper. Theme variants are not in here; those are the system.
244
245
  const collisions = h.tokens.tokenCollisions ?? [];
245
246
 
246
- const neverImported = neverImportedComponents(h.components, h.profile?.uiDir);
247
+ const P = profileOf(h);
248
+ const neverImported = neverImportedComponents(h.components, P.uiDir);
247
249
 
248
- // Measurability and role, decided by the harvest (telekom/scale, 2026-09-01):
250
+ // Measurability and kind, decided once in profiles/ (telekom/scale, 2026-09-01):
249
251
  // when the component detector could not read this repo's stack, the component
250
252
  // metrics say so and take no score credit; when the repo is a published
251
253
  // library, usage means composition (how the system uses itself), never
252
254
  // adoption, and every accusation about orphans is softened to match.
253
- const componentsMeasured = h.profile?.componentDetection?.measured !== false;
254
- const notMeasuredReason = h.profile?.componentDetection?.reason ?? 'an unrecognised component pattern';
255
- const isLibrary = h.profile?.role === 'library';
255
+ const { componentsMeasured, isLibrary, vendoredUi } = M;
256
+ const notMeasuredReason = P.notMeasuredReason;
256
257
  // A shadcn ui folder is a vendored catalogue: `shadcn add` copies the source
257
258
  // in, people take the whole set at once, and an unused component there is
258
259
  // stock on a shelf rather than something a team built and abandoned. It is
259
260
  // also protective — the agent reaches for it instead of writing its own worse
260
261
  // version. So it is counted and shown, never scored and never a fix to make.
261
- const vendoredUi = h.profile?.vendoredUi === true;
262
-
263
- // Benchmark helpers: for a metric value, where does this repo sit among the
264
- // scanned fleet? ("more colours than 90% of scanned repos")
265
- function percentile(metric, value) {
266
- const vals = bench?.stats?.[metric]?.values;
267
- if (!vals?.length) return null;
268
- const below = vals.filter((v) => v < value).length;
269
- return Math.round((below / vals.length) * 100);
270
- }
271
- // The flattering twin: what share of the scanned fleet is messier than you.
272
- function cleanerPct(metric, value) {
273
- const vals = bench?.stats?.[metric]?.values;
274
- if (!vals?.length) return null;
275
- const above = vals.filter((v) => v > value).length;
276
- return Math.round((above / vals.length) * 100);
277
- }
278
- const ideal = (metric) => bench?.ideal2026?.[metric]?.value ?? null;
279
- const median = (metric) => bench?.stats?.[metric]?.median ?? null;
280
- // A zero median reads as broken data ("Avg: 0 typefaces"), when it really
281
- // means "the median repo declares none". Fall back to the fleet mean there;
282
- // it stays an honest "Avg" and only zeroes out if literally every repo does.
283
- function displayAvg(metric) {
284
- const mv = median(metric);
285
- if (mv === null) return null;
286
- if (mv !== 0) return mv;
287
- const vals = bench?.stats?.[metric]?.values ?? [];
288
- const mean = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
289
- return mean >= 0.5 ? Math.round(mean) : null;
290
- }
291
- const refMedian = (metric) => bench?.referenceSystems?.stats?.[metric]?.median ?? null;
292
262
 
293
263
  // Clickable file paths — vscode:// opens the file straight in the editor.
294
264
  const fileLink = (f) => `<a class="path" href="vscode://file/${encodeURI(`${h.repo}/${f}`)}">${esc(f)}</a>`;
@@ -336,36 +306,8 @@ if (bench && findings.length) {
336
306
  }
337
307
 
338
308
  // ---------- health scoring ----------
339
- // Each tile is a mini scorecard: your number vs the Ideal Design System
340
- // target, the scanned-fleet average (the low bar) and the reputable-systems
341
- // median. Health drives the animated icon and colours:
342
- // good = at or under the ideal → green checkmark (draws in)
343
- // warn = over the ideal but better than the 27-repo median: "over the
344
- // target, better than the average repo" → amber circle (pulses)
345
- // bad = worse than the median (and the median is already a mess)
346
- // → coral cross (shakes)
347
- // Where the median is missing or sits at/below ideal, warn caps at 1.5x ideal.
348
- // Zero-ideal metrics (duplicates, inline styles): 0 is good; a small tolerance
349
- // is warn; beyond that bad.
350
- // There used to be an absence rule here (basically nothing found = bad, on
351
- // the theory that an empty count means no design system). The noSystemLikely
352
- // guard now owns that judgement at report level, and for a repo with a real
353
- // system a zero is discipline, not absence — all spacing on tokens scored red.
354
- const ZERO_IDEAL = new Set(['exactDuplicates', 'inlineStyles', 'nearPairs', 'important', 'neverImported']);
355
- const WARN_TOLERANCE = { exactDuplicates: 2, inlineStyles: 10, nearPairs: 2, important: 5, neverImported: 2 };
356
- function healthOf(metric, value) {
357
- const iv = ideal(metric);
358
- if (iv === null) return 'info';
359
- if (ZERO_IDEAL.has(metric)) {
360
- if (value === 0) return 'good';
361
- return value <= WARN_TOLERANCE[metric] ? 'warn' : 'bad';
362
- }
363
- if (value <= iv) return 'good';
364
- const mv = median(metric);
365
- const warnCap = mv && mv > iv ? mv : iv * 1.5;
366
- if (value <= warnCap) return 'warn';
367
- return 'bad';
368
- }
309
+ // The bands, the tolerances and healthOf itself live in score.mjs (imported
310
+ // above), so a guard or a CI check judges with the same ruler as this page.
369
311
  // Our own marks, drawn by hand for this report: a tick that starts at the
370
312
  // left foot, a full-bleed cross, a round exclamation, a quiet dash. Generic
371
313
  // glyphs on purpose, and our own coordinates on purpose: nothing here is
@@ -390,59 +332,49 @@ function row(label, valText, refVal, value) {
390
332
  if (value === refVal) return { label, val: valText, dir: 'eq' };
391
333
  return { label, val: valText, dir: value < refVal ? 'down' : 'up' };
392
334
  }
393
- function tile(value, label, metric, fallbackTarget, healthValue = value) {
394
- const health = healthOf(metric, healthValue);
395
- const iv = ideal(metric), mv = median(metric), rm = refMedian(metric);
335
+ const FALLBACK_TARGET = {
336
+ colors: 'a system needs ~24', greys: 'a scale has up to 13', spacing: 'a dozen deliberate exceptions',
337
+ exactDuplicates: 'should be 0', inlineStyles: 'invisible to any system', nearPairs: 'copy-paste, not decisions',
338
+ important: 'the cascade admitting defeat', neverImported: 'the system nobody found', arbitrary: 'a handful of deliberate exceptions',
339
+ };
340
+ // Dress one judged tile for the page: formatted number, comparison rows.
341
+ // Accepts a judged tile from score.mjs, or the positional form the
342
+ // typography section uses for its unscored tiles: (value, label, metric, fallback).
343
+ function tile(t, pLabel, pMetric, pFallback) {
344
+ if (typeof t === 'number') {
345
+ return tile({ metric: pMetric, label: pLabel, value: t, healthValue: t, health: healthOf(pMetric, t), fallbackTarget: pFallback });
346
+ }
347
+ const { metric, label, value, healthValue, health } = t;
348
+ if (health === 'na') {
349
+ return { num: '—', label, health, metric, healthValue: null,
350
+ rows: [{ label: `not measured: ${notMeasuredReason}`, val: '', dir: '' }] };
351
+ }
352
+ const iv = ideal(metric), rm = refMedian(metric);
396
353
  const pct = percentile(metric, value);
397
354
  const rows = [];
398
355
  if (iv !== null) rows.push(row('Ideal Design System', ZERO_IDEAL.has(metric) ? String(iv) : `~${n(iv)}`, iv, value));
399
- else rows.push({ label: fallbackTarget, val: '', dir: '' });
356
+ else rows.push({ label: t.fallbackTarget ?? FALLBACK_TARGET[metric], val: '', dir: '' });
400
357
  const clean = cleanerPct(metric, value);
401
358
  const avgNote = pct !== null && pct >= 60 && health !== 'good' ? ` · messier than ${pct}%`
402
359
  : clean !== null && clean >= 60 ? ` · cleaner than ${clean}%` : '';
403
360
  const av = displayAvg(metric);
404
361
  if (av !== null) rows.push(row('Avg Design System', `${n(av)}${avgNote}`, av, value));
405
362
  if (rm !== null && (rm > 0 || ZERO_IDEAL.has(metric) || metric === 'arbitrary')) rows.push(row('Reputable systems', n(rm), rm, value));
406
- return { num: n(value), label, health, rows, metric, healthValue };
407
- }
408
- const bigStats = [
409
- tile(colors.length, 'distinct colours', 'colors', 'a system needs ~24', tokenLed ? colorStrays : colors.length),
410
- tile(greys.length, 'shades of grey', 'greys', 'a scale has up to 13', tokenLed ? greyStrays : greys.length),
411
- tile(spacingTotal, 'off-scale spacing values', 'spacing', 'a dozen deliberate exceptions'),
412
- tile(hardDupes.length, 'duplicated components', 'exactDuplicates', 'should be 0'),
413
- tile(inline.count, 'inline style blocks', 'inlineStyles', 'invisible to any system'),
414
- tile(nearPairs.length, 'near-identical colour pairs', 'nearPairs', 'copy-paste, not decisions'),
415
- tile(important.count, '!important declarations', 'important', 'the cascade admitting defeat'),
416
- tile(neverImported.length, 'components never imported', 'neverImported', 'the system nobody found'),
417
- tile(arbitraryCount, 'arbitrary bracket values', 'arbitrary', 'a handful of deliberate exceptions'),
418
- ];
419
-
420
- // Component metrics the detector could not measure render as such and drop
421
- // out of the score ('na' is not a scored health); a library's never-imported
422
- // count stays visible but unscored, with the reason on the tile.
423
- if (!componentsMeasured) {
424
- for (const m of ['exactDuplicates', 'neverImported']) {
425
- const i = bigStats.findIndex((st) => st.metric === m);
426
- bigStats[i] = { num: '—', label: bigStats[i].label, health: 'na', metric: m, healthValue: null,
427
- rows: [{ label: `not measured: ${notMeasuredReason}`, val: '', dir: '' }] };
363
+ // A vendored catalogue is never judged for what it left on the shelf, and a
364
+ // library's internal use says nothing about adoption: shown, not scored.
365
+ if (health === 'info' && metric === 'neverImported') {
366
+ rows.push({ label: isLibrary
367
+ ? 'library: internal use only, downstream consumers invisible'
368
+ : 'catalogue stock: installed by the shadcn CLI, not used yet', val: '', dir: '' });
428
369
  }
429
- // A vendored catalogue is never judged for what it has left on the shelf —
430
- // but a repo that used everything it installed keeps the credit it earned, so
431
- // the tile only drops out of the score when it would otherwise accuse.
432
- } else if (isLibrary || (vendoredUi && neverImported.length > 0)) {
433
- const t = bigStats.find((st) => st.metric === 'neverImported');
434
- t.health = 'info';
435
- t.rows.push({ label: isLibrary
436
- ? 'library: internal use only, downstream consumers invisible'
437
- : 'catalogue stock: installed by the shadcn CLI, not used yet', val: '', dir: '' });
370
+ return { num: n(value), label, health, rows, metric, healthValue };
438
371
  }
372
+ const judgedTiles = tileHealths(M, healthOf);
373
+ const bigStats = judgedTiles.map(tile);
439
374
 
440
375
  // Health score for the hero: the scored tiles averaged (good 100 / warn 55 / bad 10).
441
- const SCORE_OF = { good: 100, warn: 55, bad: 10 };
442
376
  const scoredTiles = bigStats.filter((s) => s.health in SCORE_OF);
443
- const healthScore = scoredTiles.length
444
- ? Math.round(scoredTiles.reduce((sum, s) => sum + SCORE_OF[s.health], 0) / scoredTiles.length)
445
- : null;
377
+ const healthScore = scoreOfTiles(judgedTiles);
446
378
 
447
379
  // ---------- what a fix is worth ----------
448
380
  // The score is the average of the scored tiles, so moving one tile across a
@@ -522,12 +454,12 @@ function paletteSection() {
522
454
  const rest = sorted.slice(60);
523
455
  const cells = shown.map((c) => {
524
456
  const src = c.files?.[0] ? ` in ${c.files[0].file}` : '';
525
- return `<div class="uc${c.isToken ? '' : ' stray'}" style="background:${esc(c.value)};flex-grow:${Math.max(c.count, 1)}" title="${esc(c.value)} ×${c.count}${esc(src)}">${c.isToken ? '' : '<i></i>'}</div>`;
457
+ return `<div class="uc${c.isToken ? '' : ' stray'}" style="background:${swatch(c.value)};flex-grow:${Math.max(c.count, 1)}" title="${esc(c.value)} ×${c.count}${esc(src)}">${c.isToken ? '' : '<i></i>'}</div>`;
526
458
  }).join('');
527
459
  const restCell = rest.length ? `<div class="uc uc-rest" style="flex-grow:${Math.max(rest.reduce((s, c) => s + c.count, 0), 1)}" title="${rest.length} more colours">+${rest.length}</div>` : '';
528
460
 
529
461
  const rampFor = (list) => `
530
- <div class="grey-strip">${list.map((c) => `<div class="grey-cell" style="background:${esc(c.value)}" title="${esc(c.value)} ×${c.count}"></div>`).join('')}</div>
462
+ <div class="grey-strip">${list.map((c) => `<div class="grey-cell" style="background:${swatch(c.value)}" title="${esc(c.value)} ×${c.count}"></div>`).join('')}</div>
531
463
  ${list.length <= 8 ? `<div class="ramp-labels">${list.map((c) => `<span>${esc(c.value)}</span>`).join('')}</div>` : ''}`;
532
464
  const ramp = greys.length >= 4 ? `
533
465
  <div class="ramp-head">${eyebrow('Grey ramp · sorted dark → light · how many can you tell apart?')}</div>
@@ -557,7 +489,7 @@ function paletteSection() {
557
489
  ${ramp}
558
490
  ${nearPairs.length ? `
559
491
  <div class="receipts">${eyebrow(`${nearPairs.length} nearly identical pair${nearPairs.length === 1 ? '' : 's'} · copy-paste, not decisions`)}
560
- <div class="chips-row">${nearPairs.slice(0, 8).map((pr) => `<span class="vchip" title="every channel within ${pr.d} of its twin"><i class="ndot" style="background:${esc(pr.a.value)}"></i>${esc(pr.a.value)} ×${pr.a.count} <b class="nsim">≈</b> <i class="ndot" style="background:${esc(pr.b.value)}"></i>${esc(pr.b.value)} ×${pr.b.count}</span>`).join('')}${nearPairs.length > 8 ? `<span class="vchip dim">+${nearPairs.length - 8} more</span>` : ''}</div>
492
+ <div class="chips-row">${nearPairs.slice(0, 8).map((pr) => `<span class="vchip" title="every channel within ${pr.d} of its twin"><i class="ndot" style="background:${swatch(pr.a.value)}"></i>${esc(pr.a.value)} ×${pr.a.count} <b class="nsim">≈</b> <i class="ndot" style="background:${swatch(pr.b.value)}"></i>${esc(pr.b.value)} ×${pr.b.count}</span>`).join('')}${nearPairs.length > 8 ? `<span class="vchip dim">+${nearPairs.length - 8} more</span>` : ''}</div>
561
493
  ${whyToggle('nearPairs')}</div>` : ''}
562
494
  </div>
563
495
  </section>`;
@@ -676,6 +608,9 @@ function duplicatesSection() {
676
608
  // a font size from someone else's repo). Allow only a conservative CSS charset
677
609
  // so a malformed or hostile value cannot break out of the attribute.
678
610
  const cssSafe = (v) => (typeof v === 'string' && /^[-#0-9a-z%.,()\s\/]+$/i.test(v) && !/[;{}<>"']/.test(v) ? v.trim() : null);
611
+ // A colour swatch is painted with the value itself, so the value must look
612
+ // like a colour and nothing else; anything odd paints grey rather than the page.
613
+ const swatch = (v) => cssSafe(v) ?? '#888';
679
614
  // px equivalent of a length, for sorting and for rendering type at real size.
680
615
  function toPx(v) {
681
616
  const m = /^(-?\d*\.?\d+)\s*(px|rem|em|pt)?$/i.exec(String(v).trim());
@@ -1151,29 +1086,7 @@ const PKG_LABELS = {
1151
1086
  nearPairs: 'near-identical colour pairs', important: '!important declarations',
1152
1087
  neverImported: 'components nobody imports', arbitrary: 'arbitrary bracket values',
1153
1088
  };
1154
- function scorePackage(m) {
1155
- const tokenLed = m.colorTokens >= m.colorStrays && m.colorTokens > 0;
1156
- const vals = {
1157
- colors: tokenLed ? m.colorStrays : m.colors,
1158
- greys: tokenLed ? m.greyStrays : m.greys,
1159
- spacing: m.spacing,
1160
- exactDuplicates: m.exactDuplicates,
1161
- inlineStyles: m.inlineStyles,
1162
- nearPairs: m.nearPairs,
1163
- important: m.important,
1164
- neverImported: m.neverImported,
1165
- arbitrary: m.arbitrary,
1166
- };
1167
- const rows = Object.entries(vals).map(([metric, v]) => ({ metric, value: v, health: healthOf(metric, v) }));
1168
- const scored = rows.filter((r) => r.health in SCORE_OF);
1169
- if (!scored.length) return null;
1170
- const score = Math.round(scored.reduce((sum, r) => sum + SCORE_OF[r.health], 0) / scored.length);
1171
- // the worst finding: furthest past its ideal among the failing tiles
1172
- const over = (r) => { const iv = ideal(r.metric); return iv ? r.value / Math.max(iv, 1) : r.value; };
1173
- const worst = scored.filter((r) => r.health === 'bad').sort((a, b) => over(b) - over(a))[0]
1174
- ?? scored.filter((r) => r.health === 'warn').sort((a, b) => over(b) - over(a))[0];
1175
- return { score, worst };
1176
- }
1089
+ const scorePackage = (m) => scorePackageOf(m, healthOf, B);
1177
1090
  function packagesSection() {
1178
1091
  const pkgs = (h.packages ?? []).filter((p) => p.scored && p.metrics);
1179
1092
  const rated = pkgs.map((p) => ({ ...p, ...scorePackage(p.metrics) })).filter((p) => p.score !== undefined && p.score !== null);
@@ -1737,7 +1650,7 @@ ${componentsSection()}
1737
1650
 
1738
1651
  <footer>
1739
1652
  <div class="foot-left">
1740
- <div>Generated by <a class="brand" href="https://github.com/gregkozakiewicz/roast-my-design-system">roast-my-design-system</a> <span class="ver">ver. ${VERSION}</span> · free to run: <a class="brand" href="https://www.npmjs.com/package/roast-my-design-system"><span class="mono" id="npx-cmd">npx roast-my-design-system</span></a> <button type="button" class="copy-pill" id="npx-copy">copy</button> · also a Claude Code + Codex skill and a local MCP server</div>
1653
+ <div>Generated by <a class="brand" href="https://github.com/gregkozakiewicz/roast-my-design-system">roast-my-design-system</a> <span class="ver">ver. ${VERSION}</span> · free to run: <span class="mono" id="npx-cmd">npx roast-my-design-system@latest</span> <button type="button" class="copy-pill" id="npx-copy">copy</button> · also a Claude Code + Codex skill and a local MCP server</div>
1741
1654
  <div class="foot-sub">Designed and built by <a class="author" href="https://gregkozakiewicz.com/?utm_source=roast-report"><span class="gk-mark"></span>Greg Kozakiewicz</a> · <span class="scan-id" title="scan id">${scanId}</span> · ${FEEDBACK_ASK} <a class="feedback" href="${feedbackUrl(VERSION).replace(/&/g, '&amp;')}">${PULSE_ICON}${FEEDBACK_CTA}</a></div>
1742
1655
  </div>
1743
1656
  <span class="creds"><span>Non-destructive scan</span><span>Read-only</span><span>Paths are real</span></span>
@@ -1845,6 +1758,10 @@ if (summaryPath) {
1845
1758
  writeFileSync(resolve(summaryPath), JSON.stringify({
1846
1759
  repo: repoName,
1847
1760
  version: VERSION,
1761
+ // The contract two scans are compared by: schemaVersion for the
1762
+ // shape, benchmark for the ruler each scan was measured against.
1763
+ schemaVersion: SCHEMA_VERSION,
1764
+ benchmark: bench ? { builtAt: bench.builtAt, repoCount: bench.repoCount, referenceSystems: bench.referenceSystems?.count ?? 0 } : null,
1848
1765
  ...(commissionedBy ? { commissionedBy } : {}),
1849
1766
  ...(notesText ? { notesEmbedded: true } : {}),
1850
1767
  ...(extraSections.length ? { sectionsEmbedded: extraSections.map((s) => s.title) } : {}),
@@ -1852,9 +1769,13 @@ if (summaryPath) {
1852
1769
  score: healthScore,
1853
1770
  noSystemLikely,
1854
1771
  verdict,
1855
- role: h.profile?.role ?? 'product',
1772
+ role: P.kind,
1856
1773
  componentsMeasured,
1857
- tiles: bigStats.map((s) => ({ label: s.label, value: s.num, health: s.health })),
1774
+ metrics: (({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }) =>
1775
+ ({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }))(M),
1776
+ // value is the number as counted (null when not measured); display is
1777
+ // what the page prints. Until 7.0.0 value was the formatted string.
1778
+ tiles: judgedTiles.map((t, i) => ({ metric: t.metric, label: t.label, value: t.value, display: bigStats[i].num, health: t.health })),
1858
1779
  ...(startMoves.length ? { moves: startMoves } : {}),
1859
1780
  packages: (h.packages ?? []).filter((p) => p.scored && p.metrics)
1860
1781
  .map((p) => ({ dir: p.dir, name: p.name, ...(scorePackage(p.metrics) ?? {}) }))