roast-my-design-system 7.6.0 → 7.7.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
@@ -1,4 +1,4 @@
1
- <img src="https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/roaster_logo_300px.png?v=7.6.0" 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.7.0" width="72" alt="roast-my-design-system">
2
2
 
3
3
  # roast-my-design-system
4
4
 
@@ -10,15 +10,7 @@
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.6: a fourth repo profile, `registry`.** A repo that publishes a shadcn registry is read as a registry, not as an app that installed shadcn. Recognition: a `registry.json` with typed items (`registry:ui`, `registry:style`, ...) or a route that builds one from a packages folder. Covers shadcn's own source, magicui, kibo-ui and tweakcn. The header states what the repo publishes (components, blocks, styles, demos) and how many variants of the same components it keeps. Scoring is unchanged in this release: a registry is measured with the shadcn tiles against the shadcn slice, and the registry counting rules follow in the next one. `summary.json` gains `kind: "registry"` and a `registry` block. Also in 7.6: the theme file is checked for the variables tweakcn adds to every export, shown as a `tweakcn theme` pill on a match; typefaces declared in a single file are reported as a picker rather than as N typefaces.
14
-
15
- > **New in 7.5: the kit is read as it is built, and every file left out of a count is named.** A fresh `npx shadcn create` install is told apart from a repo built on one: "The score is the kit's, not yours." A rules file a framework wrote for itself is named as such, and so is the shadcn skill when it is installed. The theme file is found where it is, even when `components.json` points at a file that moved; `button/index.tsx` is a Button. The repaint advice checks whether the theme file holds the variables before telling you to swap to them. And the side panel now lists everything a check leaves out, with the reason and one honest line: your agent reads it anyway. New on that list: the Next.js crash page, an embedded widget's `!important`, and `!important` aimed at a library's own class names.
16
-
17
- > **New in 7.4: the score says what it measures, and the report has a new shape.** Under the number: "How safely an AI agent can build on this repo without going off-system." A fixed side panel holds the score, the stack, how the repo was read, an index of every section, and what is not yours and not counted. Installed code splits by one test, does it teach an agent a wrong lesson: shadcn's own bracket values are out of the count and named; palette colours inside an installed registry stay in the score and are never prompted; catalogue stock is not a trap. A factory install scores 100. Leftover theme variables become a fix.
18
-
19
- > **New in 7.3: shadcn repos are compared with shadcn repos.** The benchmark carries a shadcn slice, 17 of the 34 fleet repos, and on a shadcn repo every fleet line reads against it: "Avg shadcn repo", "cleaner than 60% of shadcn repos". Ideals and the reputable-systems line stay the same for everyone.
20
-
21
- > **New in 7.2: shadcn repos are read as shadcn repos.** The scanner finds the installed catalogue where it actually lives, reads the theme file `components.json` names, and prints how it decided under the report header. 2 new tiles appear on shadcn repos only, both from shadcn's own rules for agents: palette colours in your own code where a theme row exists, and kit components repainted through `className`. Counted per 100 of your own files, never inside the catalogue. The rules file gains a shadcn section. Scores on other repos do not move.
13
+ > **New in 7.7: four repo profiles, and the fourth is `registry`.** The scanner decides once what kind of repo it is reading, `product`, `library`, `shadcn` or `registry`, and every count, ideal and benchmark line reads that decision. `registry` is new: a repo that publishes a shadcn registry (a `registry.json` with typed items, or a route that builds one from a packages folder) is counted on what it publishes and nothing else. The docs site, demos and examples are kept out and named in the header with file counts; the same components kept in several variants count once; a name repeated only inside published blocks is the range, not a duplicate; published components are the project's own work, with no installed-code exemption. Published themes get their own tile: every shadcn colour variable present for light and for dark, ideal 0. For a registry that publishes themes and no code, that check is the score. Covers shadcn's own source (84), magicui (69), kibo-ui (78) and tweakcn (100, 36 themes, all complete). No other kind of repo moves. `summary.json`: `kind: "registry"` with a `registry` block (`publishes`, `counted`, `showcase`, `variantsDropped`, `themes`); tiles gain `themesIncomplete` on registries.
22
14
 
23
15
  > **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`.
24
16
 
@@ -83,11 +75,11 @@ Not to be confused with each other: **"Why this matters"** is generic, ships wit
83
75
 
84
76
  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:
85
77
 
86
- ![The full diagnosis report for vercel/ai-chatbot in dark mode: a fixed side panel with the health score and what it measures, the stack, how the repo was read as a shadcn install, an index of every section and what is not the team's and not counted; then the summary, 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 including the 2 shadcn tiles, the adoption map treemap, palette forensics, the shadcn theme variable by variable, 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.6.0)
78
+ ![The full diagnosis report for vercel/ai-chatbot in dark mode: a fixed side panel with the health score and what it measures, the stack, how the repo was read as a shadcn install, an index of every section and what is not the team's and not counted; then the summary, 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 including the 2 shadcn tiles, the adoption map treemap, palette forensics, the shadcn theme variable by variable, 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.7.0)
87
79
 
88
80
  The same report in light mode (one file, built-in toggle):
89
81
 
90
- ![The diagnosis report in light mode](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-light-hero.png?v=7.6.0)
82
+ ![The diagnosis report in light mode](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-light-hero.png?v=7.7.0)
91
83
 
92
84
  ## What makes the numbers trustworthy
93
85
 
@@ -243,7 +235,7 @@ After the roast, the skill also offers to write `design-system-rules.md` to disk
243
235
  - **[adobe/spectrum-web-components](https://gregkozakiewicz.github.io/roast-my-design-system/examples/adobe-spectrum.html)**: Lit, the `--spectrum-*` namespace named in the header
244
236
  - **[npx shadcn create, fresh](https://gregkozakiewicz.github.io/roast-my-design-system/examples/shadcn-create-fresh.html)**: a factory install with all 61 components, scanned as is and read as fresh
245
237
  - **[vercel/ai-chatbot](https://gregkozakiewicz.github.io/roast-my-design-system/examples/vercel-ai-chatbot.html)**: React on shadcn, with Claude's notes embedded
246
- - **[magicuidesign/magicui](https://gregkozakiewicz.github.io/roast-my-design-system/examples/magicui.html)**: read as a shadcn registry, what it publishes named under the score
238
+ - **[magicuidesign/magicui](https://gregkozakiewicz.github.io/roast-my-design-system/examples/magicui.html)**: read as a shadcn registry, counted on the 78 components it publishes
247
239
  - **[excalidraw/excalidraw](https://gregkozakiewicz.github.io/roast-my-design-system/examples/excalidraw-excalidraw.html)**
248
240
  - **[dubinc/dub](https://gregkozakiewicz.github.io/roast-my-design-system/examples/dubinc-dub.html)**
249
241
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "7.6.0",
3
+ "version": "7.7.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": [
@@ -93,6 +93,10 @@
93
93
  "doorOverrides": {
94
94
  "value": 2,
95
95
  "note": "shadcn components given a colour through className, per 100 files; the tidiest half of 16 shadcn repos sit under this (2026-09-13 audit)"
96
+ },
97
+ "themesIncomplete": {
98
+ "value": 0,
99
+ "note": "published themes missing a shadcn colour variable in light or dark; a gap here ships into every repo that installs the theme"
96
100
  }
97
101
  },
98
102
  "stats": {
@@ -2677,4 +2681,4 @@
2677
2681
  ]
2678
2682
  }
2679
2683
  }
2680
- }
2684
+ }
@@ -368,7 +368,7 @@ function tile(t, pLabel, pMetric, pFallback) {
368
368
  const { metric, label, value, healthValue, health } = t;
369
369
  if (health === 'na') {
370
370
  return { num: '—', label, health, metric, healthValue: null,
371
- rows: [{ label: `not measured: ${notMeasuredReason}`, val: '', dir: '' }] };
371
+ rows: [{ label: t.naReason ? t.naReason : `not measured: ${notMeasuredReason}`, val: '', dir: '' }] };
372
372
  }
373
373
  const iv = ideal(metric), rm = refMedian(metric);
374
374
  const pct = percentile(metric, value);
@@ -441,7 +441,7 @@ function projectedScore(applied) {
441
441
  // A utility-class shadcn repo with no hardcoded colours has zero literal
442
442
  // colours and a real system: the palette is bg-zinc-900 and friends.
443
443
  const utilityPalette = ds.kind === 'shadcn' && ds.cssVariables === false && twColorUtils >= 5;
444
- const noSystemLikely = !utilityPalette && (colors.length === 0 || (colors.length < 3 && spacingTotal === 0));
444
+ const noSystemLikely = !utilityPalette && !(P.isRegistry && P.registry?.themes?.total) && (colors.length === 0 || (colors.length < 3 && spacingTotal === 0));
445
445
 
446
446
  // ---------- section renderers ----------
447
447
  const DIR = {
@@ -619,6 +619,11 @@ function sheetSection() {
619
619
  } else if (sheet) {
620
620
  parts.push(`<div class="receipts">${eyebrow('the theme')}<p class="sub">components.json names ${esc(sheet.file)} as the theme file, and it was not found.</p></div>`);
621
621
  }
622
+ if (P.isRegistry && P.registry?.themes?.total) {
623
+ const t = P.registry.themes;
624
+ const rows = t.worst.map((w) => `<div class="ledger-row"><span class="mono strong">${esc(w.name)}</span><span class="dim">${w.missingLight.length ? `light missing ${w.missingLight.slice(0, 4).map((r) => `--${esc(r)}`).join(', ')}${w.missingLight.length > 4 ? ` and ${w.missingLight.length - 4} more` : ''}` : ''}${w.missingLight.length && w.missingDark.length ? ' · ' : ''}${w.missingDark.length ? `dark missing ${w.missingDark.slice(0, 4).map((r) => `--${esc(r)}`).join(', ')}${w.missingDark.length > 4 ? ` and ${w.missingDark.length - 4} more` : ''}` : ''}</span></div>`).join('');
625
+ parts.push(`<div class="receipts">${eyebrow(`${n(t.total)} published theme${t.total === 1 ? '' : 's'} · ${n(t.incomplete)} incomplete`)}<p class="sub">${t.incomplete ? `A theme missing a variable ships that gap into every repo that installs it. ${t.incomplete === 1 ? 'The one' : `The ${t.incomplete}`} below ${t.incomplete === 1 ? 'is' : 'are'} short of the full set in at least one mode.` : 'Every published theme carries every shadcn colour variable, for light and for dark.'}</p>${rows ? `<div class="ledger">${rows}</div>` : ''}${whyToggle('themesIncomplete')}</div>`);
626
+ }
622
627
  if (paint) {
623
628
  const tinChips = (paint.tin.samples ?? []).slice(0, 8).map((s) => `<span class="vchip bad">${esc(s.value)} ×${s.count}</span>`).join('');
624
629
  const tinFiles = (paint.tin.top ?? []).slice(0, 5).map((f) => `<span class="vchip" title="${esc(f.file)}">${esc(basename(f.file))} ×${f.count}</span>`).join('');
@@ -648,7 +653,7 @@ function duplicatesSection() {
648
653
  </div>` : '';
649
654
  const dupeCards = exactDupes.slice(0, 8).map((d) => `
650
655
  <div class="fam">
651
- ${eyebrow(`&lt;${esc(d.name)}&gt; · ${d.upstream ? 'two shadcn components define it: upstream\'s overlap, not counted' : d.wrapped ? 'defined twice, one wraps the other' : `${d.files.length} implementations`}`)}
656
+ ${eyebrow(`&lt;${esc(d.name)}&gt; · ${d.blocks ? `defined in ${d.files.length} blocks that each install alone: the range, not counted` : d.upstream ? 'two shadcn components define it: upstream\'s overlap, not counted' : d.wrapped ? 'defined twice, one wraps the other' : `${d.files.length} implementations`}`)}
652
657
  <div class="fam-rows">
653
658
  ${d.files.slice(0, 6).map((f) => `<div class="mini-card">${fileLink(f)}</div>`).join('')}
654
659
  ${d.files.length > 6 ? `<div class="mini-card dim">…and ${d.files.length - 6} more</div>` : ''}
@@ -1326,8 +1331,12 @@ function shadcnReceipt() {
1326
1331
  if (P.isRegistry && P.registry) {
1327
1332
  const r = P.registry;
1328
1333
  const what = r.builtFrom ? `publishes ${r.items} component${r.items === 1 ? '' : 's'}, built from its packages folder` : `publishes ${esc(publishesLine(r))}`;
1329
- const pages = comps.filter((c) => c.isPage).length;
1330
- return `<div class="excl fresh">Read as a shadcn registry: ${what}${r.variants.length ? `, the same components kept in ${r.variants.length} variants` : ''}.${pages ? ` Its own site: ${n(pages)} page${pages === 1 ? '' : 's'}.` : ''} Scored as a shadcn install for now; the registry rules come next.</div><div class="excl">Evidence: ${receipt}</div>`;
1334
+ const kept = (r.showcase ?? []).reduce((a, e) => a + e.files, 0);
1335
+ const th = r.themes?.total ? ` ${n(r.themes.total)} published theme${r.themes.total === 1 ? '' : 's'} checked, ${n(r.themes.incomplete)} incomplete.` : '';
1336
+ const counted = (r.counted?.code ?? 0) === 0 && r.themes?.total
1337
+ ? `<b>It publishes no code, so the themes check is the score</b>: the ${n(kept)} files of the app that makes them are not counted.`
1338
+ : `<b>Only what it publishes is counted</b>: ${n(r.counted?.code ?? 0)} code file${(r.counted?.code ?? 0) === 1 ? '' : 's'}${kept ? `, with ${n(kept)} more kept out as its site, demos and examples` : ''}.`;
1339
+ return `<div class="excl fresh">Read as a shadcn registry: ${what}${r.variants.length ? `, the same components kept in ${r.variants.length} variants` : ''}. ${counted}${th}</div><div class="excl">Evidence: ${receipt}</div>`;
1331
1340
  }
1332
1341
  return `<div class="excl">Read as a shadcn install (${esc(P.confidence ?? 'medium')} confidence): ${receipt}</div>`;
1333
1342
  }
@@ -1392,6 +1401,13 @@ function exceptionsBlock() {
1392
1401
  const si = spacingOwn.installed;
1393
1402
  const sv = si.values.length;
1394
1403
  if (sv) lines.push(`${n(sv)} off-scale spacing value${sv === 1 ? '' : 's'} inside ${esc(P.uiDirs.map((d) => basename(d)).join(', '))} ${sv === 1 ? 'is' : 'are'} shadcn's own (${si.values.slice(0, 3).map((v) => esc(v.value)).join(', ')}) and ${sv === 1 ? 'is' : 'are'} not counted, for the same reason as the bracket values.`);
1404
+ if (P.isRegistry && P.registry) {
1405
+ const r = P.registry;
1406
+ const sc = r.showcase ?? [];
1407
+ if (sc.length) lines.push(`${n(sc.reduce((a, e) => a + e.files, 0))} files outside what the registry publishes (${sc.slice(0, 4).map((e) => `${esc(e.dir)} ${n(e.files)}`).join(', ')}${sc.length > 4 ? ` and ${sc.length - 4} more folders` : ''}) are not counted: the docs site, demos and examples nobody installs. Your agent reads them like everything else.`);
1408
+ const vd = r.variantsDropped ?? [];
1409
+ if (vd.length) lines.push(`${vd.length} variant${vd.length === 1 ? '' : 's'} of the same components (${vd.map((e) => esc(basename(dirname(e.dir)) === 'bases' ? basename(dirname(e.dir)) + '/' + basename(e.dir) : e.dir)).join(', ')}, ${n(vd.reduce((a, e) => a + e.files, 0))} files) counted once, through the variant the registry file names.`);
1410
+ }
1395
1411
  if (vendoredUi && neverImported.length) lines.push(`${n(neverImported.length)} catalogue component${neverImported.length === 1 ? '' : 's'} not used yet: stock on the shelf, not scored.`);
1396
1412
  const rp = P.shadcn?.registryPaint;
1397
1413
  if (rp) lines.push(`Installed registr${rp.dirs.length === 1 ? 'y' : 'ies'} ${esc(rp.dirs.map((d) => basename(d)).join(', '))}: ${n(rp.files)} file${rp.files === 1 ? '' : 's'}${rp.tinUses ? `, ${n(rp.tinUses)} palette colour${rp.tinUses === 1 ? '' : 's'}` : ''}. Kept in the score, left out of the fixes. Your agent reads them like everything else.`);
@@ -2039,7 +2055,7 @@ if (summaryPath) {
2039
2055
  verdict,
2040
2056
  role: P.role,
2041
2057
  kind: P.kind,
2042
- ...(P.isRegistry && P.registry ? { registry: { source: P.registry.source, builtFrom: P.registry.builtFrom, items: P.registry.items, publishes: P.registry.publishes, variants: P.registry.variants } } : {}),
2058
+ ...(P.isRegistry && P.registry ? { registry: { source: P.registry.source, builtFrom: P.registry.builtFrom, items: P.registry.items, publishes: P.registry.publishes, variants: P.registry.variants, counted: P.registry.counted ?? null, showcase: P.registry.showcase ?? [], variantsDropped: P.registry.variantsDropped ?? [], themes: P.registry.themes ? { total: P.registry.themes.total, incomplete: P.registry.themes.incomplete } : null } } : {}),
2043
2059
  ...(P.isShadcn && P.shadcn ? { shadcn: { confidence: P.confidence, evidence: P.evidence, style: P.shadcn.kit?.style ?? null, baseColor: P.shadcn.kit?.baseColor ?? null, tailwind: P.shadcn.kit?.tailwind ?? null, catalogues: P.uiDirs, registries: P.shadcn.registryDirs ?? [], ...(P.shadcn.registryPaint ? { registryFiles: P.shadcn.registryPaint.files, registryPaletteColours: P.shadcn.registryPaint.tinUses } : {}), ownFiles: P.shadcn.paint?.ownFiles ?? null, fresh: P.shadcn.fresh?.fresh === true } } : {}),
2044
2060
  componentsMeasured,
2045
2061
  metrics: (({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }) =>
@@ -78,8 +78,8 @@ export function benchHelpers(bench, kind = 'product') {
78
78
  return { percentile, cleanerPct, ideal, median, displayAvg, refMedian, sliceInfo };
79
79
  }
80
80
 
81
- export const ZERO_IDEAL = new Set(['exactDuplicates', 'inlineStyles', 'nearPairs', 'important', 'neverImported']);
82
- export const WARN_TOLERANCE = { exactDuplicates: 2, inlineStyles: 10, nearPairs: 2, important: 5, neverImported: 2 };
81
+ export const ZERO_IDEAL = new Set(['exactDuplicates', 'inlineStyles', 'nearPairs', 'important', 'neverImported', 'themesIncomplete']);
82
+ export const WARN_TOLERANCE = { exactDuplicates: 2, inlineStyles: 10, nearPairs: 2, important: 5, neverImported: 2, themesIncomplete: 1 };
83
83
  export const SCORE_OF = { good: 100, warn: 55, bad: 10 };
84
84
 
85
85
  /** healthOf(metric, value) for one benchmark: 'good' | 'warn' | 'bad' | 'info'. */
@@ -107,7 +107,7 @@ export const PROFILE_TILES = {
107
107
  ],
108
108
  };
109
109
  // a registry measures what a shadcn repo measures (release (a), 2026-09-13)
110
- PROFILE_TILES.registry = PROFILE_TILES.shadcn;
110
+ PROFILE_TILES.registry = [...PROFILE_TILES.shadcn, ['themesIncomplete', 'published themes incomplete']];
111
111
  export const tilesFor = (kind) => [...TILES, ...(PROFILE_TILES[kind] ?? [])];
112
112
 
113
113
  /** The tiles, in report order: metric key, the label the report prints. */
@@ -176,6 +176,11 @@ export function coreMetrics(h, opts = {}) {
176
176
  utilityPalette: profileOf(h).designSystem?.cssVariables === false,
177
177
  paintTin: paint?.tin?.per100 ?? 0,
178
178
  doorOverrides: paint?.doors?.per100 ?? 0,
179
+ // a registry's published themes, checked for every variable in both modes
180
+ themesIncomplete: P.registry?.themes?.incomplete ?? 0,
181
+ themesPublished: P.registry?.themes?.total ?? 0,
182
+ // a registry that publishes themes and no code: the themes check is the score
183
+ stylesOnly: !!(P.isRegistry && P.registry?.themes?.total && (P.registry?.counted?.code ?? 0) === 0),
179
184
  };
180
185
  }
181
186
 
@@ -203,12 +208,14 @@ export function tileHealths(m, healthOf) {
203
208
  colors: m.colors, greys: m.greys, spacing: m.spacing, exactDuplicates: m.exactDuplicates,
204
209
  inlineStyles: m.inlineStyles, nearPairs: m.nearPairs, important: m.important,
205
210
  neverImported: m.neverImported, arbitrary: m.arbitrary,
206
- paintTin: m.paintTin ?? 0, doorOverrides: m.doorOverrides ?? 0,
211
+ paintTin: m.paintTin ?? 0, doorOverrides: m.doorOverrides ?? 0, themesIncomplete: m.themesIncomplete ?? 0,
207
212
  };
208
213
  const judged = { ...shown, colors: m.tokenLed ? m.colorStrays : m.colors, greys: m.tokenLed ? m.greyStrays : m.greys };
209
214
  return tilesFor(m.kind ?? 'product').map(([metric, label]) => {
210
215
  let health = healthOf(metric, judged[metric]);
211
216
  let value = shown[metric], healthValue = judged[metric];
217
+ // a registry that publishes no themes has nothing to check here
218
+ if (metric === 'themesIncomplete' && !m.themesPublished) { health = 'na'; value = null; healthValue = null; }
212
219
  if (!m.componentsMeasured && (metric === 'exactDuplicates' || metric === 'neverImported')) {
213
220
  health = 'na'; value = null; healthValue = null;
214
221
  } else if (metric === 'neverImported' && (m.isLibrary || (m.vendoredUi && m.neverImported > 0))) {
@@ -216,7 +223,10 @@ export function tileHealths(m, healthOf) {
216
223
  } else if (metric === 'paintTin' && m.utilityPalette) {
217
224
  health = 'info';
218
225
  }
219
- return { metric, label, value, healthValue, health };
226
+ // a registry that publishes themes and no code: nothing else applies
227
+ let naReason = null;
228
+ if (m.stylesOnly && metric !== 'themesIncomplete') { health = 'na'; value = null; healthValue = null; naReason = 'this registry publishes themes and no code, so there is nothing here to measure'; }
229
+ return { metric, label, value, healthValue, health, ...(naReason ? { naReason } : {}) };
220
230
  });
221
231
  }
222
232
 
@@ -15,6 +15,11 @@ export const WHY = {
15
15
  'A palette class such as text-gray-500 or bg-blue-100 skips that file. It works on the day. It stops working at the first theme change: the variables move, the palette colour stays, and the page shows 2 designs at once. Dark mode is where it shows first. A variable carries both values; a palette class carries one, so someone adds dark:bg-gray-900 next to it, and now there are 2 places to keep in step. An AI agent reading that file copies the pair.',
16
16
  'The ideal is 25 per 100 files. That is where the tidiest third of 15 shadcn repos in the benchmark sit; the median is 62. shadcn\'s own rules for agents say it in 1 line: use semantic colours, never bg-blue-500.',
17
17
  ],
18
+ themesIncomplete: [
19
+ 'A published theme is a set of CSS variables a repo installs with one command: background, foreground, primary, muted, border and the rest, each with a light value and a dark value. A component in that repo reads the variable; if the theme never set it, the component gets nothing.',
20
+ 'A theme missing --sidebar-border in dark mode looks fine in the editor that made it and breaks in the first repo that uses the sidebar. The gap is invisible until then, and the person who hits it is not the person who can fix it.',
21
+ 'The ideal is 0. Every theme a registry publishes carries every variable, in both modes, or says which component it does not support.',
22
+ ],
18
23
  doorOverrides: [
19
24
  'A shadcn component ships with variants: outline, ghost, destructive, small, large. The variant decides how the component looks, once, in the component file you own. Passing bg-blue-100 through className decides it again at the call site, where the component can not see it.',
20
25
  'Do this in 5 places and 1 button has 6 designs. The next person can not tell which one is intended, and an agent copies whichever it finds first. shadcn\'s guidance gives 3 routes: use a variant that exists, add a variant to the component, or add a variable to the theme file. className is for layout: width, margin, position.',
@@ -27,7 +27,7 @@ import { ruleStaleness } from '../lib/staleness.mjs';
27
27
  import { neverImportedComponents } from '../lib/neverimported.mjs';
28
28
  import { lastTouchedDates } from '../lib/lasttouched.mjs';
29
29
  import { SCHEMA_VERSION } from '../lib/version.mjs';
30
- import { decideProfile, decideFresh, profileOf, installedDirs, splitArbitrary } from '../profiles/index.mjs';
30
+ import { decideProfile, decideFresh, profileOf, installedDirs, splitArbitrary, scopeFiles } from '../profiles/index.mjs';
31
31
  import { countPaint } from './paint.mjs';
32
32
 
33
33
  function arg(name, fallback) {
@@ -55,15 +55,35 @@ const outPath = resolve(arg('out', 'harvest.json'));
55
55
 
56
56
  const t0 = Date.now();
57
57
  const exclusions = loadExclusions(target, argAll('exclude'));
58
- const files = walkRepo(target, 14, exclusions);
58
+ let files = walkRepo(target, 14, exclusions);
59
59
  const profile = profileRepo(target, files);
60
- const { components } = harvestComponents(target, files.code);
61
- const tokens = harvestTokens(target, files.styles, files.code);
60
+ let { components } = harvestComponents(target, files.code);
61
+ let tokens = harvestTokens(target, files.styles, files.code);
62
62
  // Repo kind and measurability, decided once in profiles/ and read everywhere
63
63
  // (a published components package with no app pages is a LIBRARY; a stack the
64
64
  // detector cannot read is NOT MEASURED, never scored as zeros). The decision
65
65
  // lands on the profile with its evidence, so the JSON says why.
66
66
  decideProfile(profile, components, files, target);
67
+ // A registry is counted on what it publishes, one variant of it. The rest
68
+ // (docs site, demos, an installed catalogue for the site) is kept out and
69
+ // named in the header like any exclusion; secondary variants likewise.
70
+ if (profileOf(profile).isRegistry) {
71
+ const scope = scopeFiles(profile.registry, files);
72
+ profile.registry.showcase = scope.showcase;
73
+ profile.registry.variantsDropped = scope.variantsDropped;
74
+ for (const e of scope.showcase) exclusions.patterns.push({ pattern: e.dir, source: 'the registry profile (not published)', files: e.files });
75
+ for (const e of scope.variantsDropped) exclusions.patterns.push({ pattern: e.dir, source: 'the registry profile (a variant counted once)', files: e.files });
76
+ files = scope.files;
77
+ // the theme file the published components read stays in scope: it is the
78
+ // colour system they are judged against, wherever the repo keeps it
79
+ const sheetFile = profile.shadcn?.sheet?.found ? profile.shadcn.sheet.file : null;
80
+ if (sheetFile && !files.styles.includes(sheetFile)) files.styles.push(sheetFile);
81
+ ({ components } = harvestComponents(target, files.code));
82
+ tokens = harvestTokens(target, files.styles, files.code);
83
+ // published components are the project's own work, not installed code
84
+ profile.uiDirs = []; profile.uiDir = null; profile.vendoredUi = false;
85
+ profile.registry.counted = { code: files.code.length, styles: files.styles.length };
86
+ }
67
87
  // A shadcn kitchen gets the 2 paint checks from shadcn's own agent rules,
68
88
  // counted over own code only (never the kit's doors, never exempt files).
69
89
  {
@@ -97,6 +117,17 @@ decideProfile(profile, components, files, target);
97
117
  }
98
118
 
99
119
  const duplicates = findDuplicates(components, profile.uiDir, target, profile.uiDirs ?? null);
120
+ // A registry's blocks are self-contained kits, each installed alone
121
+ // (sidebar-01 to sidebar-16 every one with its own AppSidebar). A name whose
122
+ // every copy sits inside a block is the range, not confusion: listed, never
123
+ // counted, like a wrapped pair.
124
+ {
125
+ const blockDirs = profile.registry?.blockDirs ?? [];
126
+ if (blockDirs.length) {
127
+ const inBlock = (f) => blockDirs.some((d) => f === d || f.startsWith(`${d}/`));
128
+ for (const d of duplicates.exactDuplicates) if (!d.wrapped && d.files.every(inBlock)) { d.wrapped = true; d.blocks = true; }
129
+ }
130
+ }
100
131
  const context = harvestContext(target);
101
132
  const staleRules = ruleStaleness(target, components,
102
133
  new Set(neverImportedComponents(components, profile.uiDir).map((c) => c.name)),
@@ -1,7 +1,7 @@
1
1
  // Single version constant for the engine — imported by diagnose (report
2
2
  // footer) and rules (generated-by line). This is the bump spot that used to
3
3
  // live as a const inside diagnose/index.mjs.
4
- export const VERSION = '7.6.0';
4
+ export const VERSION = '7.7.0';
5
5
  // The shape of harvest.json and summary.json. Bumped only when a field is
6
6
  // renamed, removed or changes meaning; a new field is not a new schema. A
7
7
  // tool comparing two scans compares like with like by this number, not by VERSION.
@@ -22,7 +22,8 @@ import { join, basename } from 'node:path';
22
22
  * fixture's expected output is byte-identical before and after.
23
23
  */
24
24
  import shadcn from './shadcn.mjs';
25
- import { readRegistry, publishesLine, variantsFromDirs } from './registry.mjs';
25
+ import { readRegistry, publishesLine, variantsFromDirs, scopeFiles } from './registry.mjs';
26
+ export { scopeFiles };
26
27
  import library from './library.mjs';
27
28
  import product from './product.mjs';
28
29
 
@@ -64,16 +65,19 @@ export function decideProfile(profile, components, files, root = null) {
64
65
  // A shadcn repo that PUBLISHES a registry is a registry: the fourth kind.
65
66
  // Every count still reads the shadcn facts (release (a): zero score change);
66
67
  // the kind, the receipt and the header line say what it is.
67
- if (picked === shadcn && root) {
68
+ if (root) {
68
69
  const reg = readRegistry(root, files);
69
70
  if (reg) {
70
71
  if (!reg.variants.length) reg.variants = variantsFromDirs(reg, profile.uiDirs, files);
71
72
  delete reg.itemNames;
72
73
  profile.kind = 'registry';
74
+ // consumers of what it publishes live in other repos: library semantics
75
+ profile.role = 'library';
73
76
  profile.registry = reg;
74
77
  profile.kindEvidence = [
75
78
  reg.builtFrom ? `registry built from ${reg.items} packages by ${reg.source}` : `${reg.source} publishes ${publishesLine(reg)}`,
76
79
  ...(reg.variants.length ? [`the same components kept in ${reg.variants.length} variants (${reg.variants.map((d) => basename(d)).join(', ')})`] : []),
80
+ ...(reg.themes?.total ? [`${reg.themes.total} published theme${reg.themes.total === 1 ? '' : 's'} checked, ${reg.themes.incomplete} incomplete`] : []),
77
81
  ...decision.evidence,
78
82
  ];
79
83
  }
@@ -19,7 +19,8 @@
19
19
  * (app/r/registry.json/route.ts, kibo-ui)
20
20
  */
21
21
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
22
- import { join, dirname, basename } from 'node:path';
22
+ import { join, dirname, basename, relative } from 'node:path';
23
+ import { SHADCN_ROWS } from './shadcn-data.mjs';
23
24
 
24
25
  const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
25
26
 
@@ -109,7 +110,18 @@ export function readRegistry(root, files) {
109
110
  if (best) {
110
111
  const publishes = tally(best.items);
111
112
  const itemNames = best.items.filter((it) => /^registry:(ui|component)$/.test(String(it?.type ?? ''))).map((it) => it.name).filter(Boolean);
112
- return { source: best.source, builtFrom: null, items: best.items.length, publishes, variants: variantDirs(best.items, root), itemNames };
113
+ // item file paths are relative to the registry file's folder
114
+ const base = dirname(best.source);
115
+ const rel = (p) => (base === '.' ? p : `${base}/${p}`);
116
+ const dirsOf = (re) => [...new Set(best.items.filter((it) => re.test(String(it?.type ?? '')))
117
+ .flatMap((it) => (it.files ?? []).map((f) => (typeof f === 'string' ? f : f?.path)).filter(Boolean).map((p) => rel(dirname(p)))))].sort();
118
+ return {
119
+ source: best.source, builtFrom: null, items: best.items.length, publishes, variants: variantDirs(best.items, root), itemNames,
120
+ publishedDirs: dirsOf(/^registry:(ui|component|block|lib|hook|style|theme|file|page|internal)$/),
121
+ demoDirs: dirsOf(/^registry:example$/),
122
+ blockDirs: dirsOf(/^registry:block$/),
123
+ themes: themeCheck(best.items),
124
+ };
113
125
  }
114
126
  // 2. a route that builds the registry from packages/* (kibo-ui)
115
127
  const route = look.routes[0] ?? null;
@@ -127,7 +139,9 @@ export function readRegistry(root, files) {
127
139
  if (!pkgs && existsSync(join(root, 'packages'))) pkgs = join(root, 'packages');
128
140
  if (pkgs) {
129
141
  const names = readdirSync(pkgs, { withFileTypes: true }).filter((e) => e.isDirectory() && existsSync(join(pkgs, e.name, 'index.tsx'))).map((e) => e.name);
130
- return { source: route, builtFrom: 'packages', items: names.length, publishes: { components: names.length, blocks: 0, styles: 0, demos: 0, other: 0 }, variants: [] };
142
+ const pkgRel = relative(root, pkgs).replace(/\\/g, '/');
143
+ return { source: route, builtFrom: 'packages', items: names.length, publishes: { components: names.length, blocks: 0, styles: 0, demos: 0, other: 0 }, variants: [], itemNames: names,
144
+ publishedDirs: names.map((nm) => `${pkgRel}/${nm}`), demoDirs: [], blockDirs: [], themes: { total: 0, incomplete: 0, worst: [] } };
131
145
  }
132
146
  }
133
147
  }
@@ -140,21 +154,77 @@ export function readRegistry(root, files) {
140
154
  * registry.json lists one file per component (new-york-v4), while the same
141
155
  * components sit again under bases/aria, bases/base and bases/radix.
142
156
  */
143
- export function variantsFromDirs(reg, uiDirs, files) {
157
+ export function variantsFromDirs(reg, _uiDirs, files) {
144
158
  const names = new Set();
145
159
  // names come from the registry items when it is a file, else from packages
146
160
  if (reg.itemNames) for (const nm of reg.itemNames) names.add(nm);
147
161
  if (names.size < 5) return [];
148
162
  const stem = (f) => { const b = basename(f).replace(/\.[cm]?[jt]sx?$/, ''); return b === 'index' ? basename(dirname(f)) : b; };
163
+ // every folder holding most of the published names, catalogue or not
164
+ const byDir = new Map();
165
+ for (const f of files.code ?? []) {
166
+ if (!/\.[jt]sx$/.test(f)) continue;
167
+ const d = dirname(f);
168
+ if (!byDir.has(d)) byDir.set(d, new Set());
169
+ byDir.get(d).add(stem(f));
170
+ }
149
171
  const hits = [];
150
- for (const d of uiDirs ?? []) {
151
- const have = new Set((files.code ?? []).filter((f) => f.startsWith(`${d}/`) && /\.[jt]sx$/.test(f)).map(stem));
172
+ for (const [d, have] of byDir) {
152
173
  const shared = [...names].filter((nm) => have.has(nm)).length;
153
174
  if (shared >= Math.max(5, Math.ceil(names.size * 0.5))) hits.push(d);
154
175
  }
155
176
  return hits.length > 1 ? hits.sort() : [];
156
177
  }
157
178
 
179
+ /**
180
+ * Published themes, checked: every shadcn colour variable present for light
181
+ * and for dark. --radius is not per mode and is left out. A theme with a
182
+ * missing row ships that gap into every repo that installs it.
183
+ */
184
+ const THEME_ROWS = SHADCN_ROWS.filter((r) => r !== 'radius');
185
+ function themeCheck(items) {
186
+ const worst = [];
187
+ let total = 0, incomplete = 0;
188
+ for (const it of items) {
189
+ if (!/^registry:(style|theme)$/.test(String(it?.type ?? ''))) continue;
190
+ const cv = it.cssVars ?? {};
191
+ if (!cv.light && !cv.dark) continue;
192
+ total += 1;
193
+ const has = (mode) => new Set(Object.keys(cv[mode] ?? {}).map((k) => k.replace(/^--/, '')));
194
+ const light = has('light'), dark = has('dark');
195
+ const missingLight = THEME_ROWS.filter((r) => !light.has(r));
196
+ const missingDark = THEME_ROWS.filter((r) => !dark.has(r));
197
+ if (missingLight.length || missingDark.length) {
198
+ incomplete += 1;
199
+ if (worst.length < 6) worst.push({ name: it.name, missingLight, missingDark });
200
+ }
201
+ }
202
+ return { total, incomplete, worst };
203
+ }
204
+
205
+ /**
206
+ * What is counted on a registry: the code it publishes, one variant of it.
207
+ * Everything else (the docs site, demos, examples, an installed catalogue
208
+ * for the site) is kept out and named, with file counts per folder.
209
+ */
210
+ export function scopeFiles(reg, files) {
211
+ const under = (f, dirs) => dirs.some((d) => f === d || f.startsWith(`${d}/`));
212
+ const published = reg.publishedDirs ?? [];
213
+ const secondary = (reg.variants ?? []).filter((d) => !under(d, published) && !published.some((p) => p === d || p.startsWith(`${d}/`)));
214
+ const top = (f) => { const seg = f.split('/'); return (seg[0] === 'apps' || seg[0] === 'packages') && seg.length > 2 ? seg.slice(0, 2).join('/') : seg.length > 1 ? seg[0] : '(root)'; };
215
+ const out = { code: [], styles: [], other: files.other ?? [] };
216
+ const showcase = new Map(), variants = new Map();
217
+ for (const kind of ['code', 'styles']) {
218
+ for (const f of files[kind] ?? []) {
219
+ if (under(f, secondary)) { const d = secondary.find((v) => f.startsWith(`${v}/`)); variants.set(d, (variants.get(d) ?? 0) + 1); continue; }
220
+ if (under(f, published)) { out[kind].push(f); continue; }
221
+ showcase.set(top(f), (showcase.get(top(f)) ?? 0) + 1);
222
+ }
223
+ }
224
+ const list = (m) => [...m.entries()].sort((a, b) => b[1] - a[1]).map(([dir, n]) => ({ dir, files: n }));
225
+ return { files: out, showcase: list(showcase), variantsDropped: list(variants) };
226
+ }
227
+
158
228
  /** The header sentence: what it publishes, in words. */
159
229
  export function publishesLine(r) {
160
230
  const parts = [];
@@ -121,7 +121,9 @@ const repoName = h.profile?.name ?? 'this repo';
121
121
  const files = d.files.map((f) => (typeof f === 'string' ? f : f.file));
122
122
  const ranked = [...files].sort((a, b) => sharedScore(b) - sharedScore(a));
123
123
  const clear = sharedScore(ranked[0]) > sharedScore(ranked[1]);
124
- if (d.upstream) {
124
+ if (d.blocks) {
125
+ rule(`\`<${d.name}>\` is defined in ${files.length} published blocks, each a self-contained install. Copy the whole block or none of it; never import across blocks.`);
126
+ } else if (d.upstream) {
125
127
  rule(`\`<${d.name}>\` is defined by two shadcn components (${files.map((f) => `\`${f}\``).join(', ')}), upstream's overlap. Import whichever the surrounding code already uses; do not create a third.`);
126
128
  } else if (d.wrapped) {
127
129
  rule(`\`<${d.name}>\` is defined twice and one wraps the other${clear ? `. Import \`${ranked[0]}\`` : ''}; do not create a third.`);
@@ -179,6 +181,7 @@ if (neverImported.length >= 3) {
179
181
  const sheetFile = sc.sheet?.found ? sc.sheet.file : null;
180
182
  const paint = sc.paint ?? null;
181
183
  section('shadcn: the components and the theme');
184
+ if (P.isRegistry) rule(`This repo publishes a shadcn registry${P.registry?.publishes ? ` (${[['components', P.registry.publishes.components], ['blocks', P.registry.publishes.blocks], ['styles', P.registry.publishes.styles]].filter(([, v]) => v).map(([k, v]) => `${v} ${k}`).join(', ')})` : ''}. A palette colour, a bracket value or a hand-written dark: colour written here ships into every repo that installs it. Hold published code to the theme variables and the scale harder than app code, and keep demos and examples out of published files.`);
182
185
  rule(`This is a shadcn install${sc.kit?.style ? ` (style \`${sc.kit.style}\`${sc.kit.baseColor ? `, base colour ${sc.kit.baseColor}` : ''})` : ''}. The theme is a set of CSS variables${sheetFile ? ` in \`${sheetFile}\`` : ''}: background, foreground, primary, muted, border and the rest, each with a light and a dark value. Change a colour there, never in a component.`);
183
186
  if ((sc.sheet?.shadcnPresent ?? 0) >= 5 || sc.kit?.cssVariables === false) rule('Use the semantic classes the theme gives you (`bg-background`, `text-muted-foreground`, `border-border`), never a palette colour like `bg-blue-500` or `text-gray-600`, and never a hand-written `dark:` colour. The variables already carry both modes.');
184
187
  else rule(`${sheetFile ? `\`${sheetFile}\` defines` : 'The theme file defines'} none of shadcn's colour variables, so \`bg-background\` and \`text-muted-foreground\` have nothing behind them here. Until the theme variables are adopted, stay with the palette classes the surrounding file already uses; do not introduce semantic classes with no variable behind them, and do not add a second palette.`);