roast-my-design-system 7.5.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.5.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,13 +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.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.
14
-
15
- > **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.
16
-
17
- > **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.
18
-
19
- > **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.
20
14
 
21
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`.
22
16
 
@@ -81,11 +75,11 @@ Not to be confused with each other: **"Why this matters"** is generic, ships wit
81
75
 
82
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:
83
77
 
84
- ![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.5.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)
85
79
 
86
80
  The same report in light mode (one file, built-in toggle):
87
81
 
88
- ![The diagnosis report in light mode](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-light-hero.png?v=7.5.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)
89
83
 
90
84
  ## What makes the numbers trustworthy
91
85
 
@@ -235,12 +229,13 @@ After the roast, the skill also offers to write `design-system-rules.md` to disk
235
229
 
236
230
  ## Live examples
237
231
 
238
- 5 real roasts of public repos, hosted as-is (the same self-contained HTML the skill generates), spanning React, Stencil and Lit:
232
+ 7 real roasts of public repos, hosted as-is (the same self-contained HTML the skill generates), spanning React, Stencil and Lit:
239
233
 
240
234
  - **[telekom/scale](https://gregkozakiewicz.github.io/roast-my-design-system/examples/telekom-scale.html)**: Stencil, 95 components read by tag, with Claude's notes embedded
241
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
242
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
243
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
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
244
239
  - **[excalidraw/excalidraw](https://gregkozakiewicz.github.io/roast-my-design-system/examples/excalidraw-excalidraw.html)**
245
240
  - **[dubinc/dub](https://gregkozakiewicz.github.io/roast-my-design-system/examples/dubinc-dub.html)**
246
241
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "7.5.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
+ }
@@ -33,6 +33,7 @@ import { WHY } from './why.mjs';
33
33
  import { parseColor, luminance, isGrey } from '../lib/color.mjs';
34
34
  import { loadBenchmark, benchHelpers, makeHealthOf, coreMetrics, tileHealths, scoreOfTiles, scoreBreakdown, scorePackage as scorePackageOf, ZERO_IDEAL, WARN_TOLERANCE, SCORE_OF, SCHEMA_VERSION } from './score.mjs';
35
35
  import { ownSpacing, profileOf, installedDirs, splitArbitrary } from '../profiles/index.mjs';
36
+ import { publishesLine } from '../profiles/registry.mjs';
36
37
 
37
38
  // The benchmark and every judgement made against it live in score.mjs; this
38
39
  // file only draws. The same numbers reach summary.json through scoreHarvest.
@@ -285,7 +286,12 @@ const offenders = h.tokens.offenders ?? [];
285
286
  const candidates = [];
286
287
  const effColors = tokenLed ? colorStrays : colors.length;
287
288
  const effGreys = tokenLed ? greyStrays : greys.length;
288
- if (typefaces.length > 3) candidates.push({ ratio: typefaces.length / 3, text: `${typefaces.length} typefaces. Most products use 2 or 3` });
289
+ // Every typeface declared in one file is a picker (a theme editor's font
290
+ // list, shadcn's docs site): one choice offered, one in use at a time.
291
+ const fontFiles = new Set(fontFamilies.flatMap((f) => (f.files ?? []).map((x) => x.file)));
292
+ const fontPicker = typefaces.length > 3 && fontFiles.size === 1 ? [...fontFiles][0] : null;
293
+ if (fontPicker) candidates.push({ ratio: 1.05, text: `${typefaces.length} typefaces offered by a picker in one file (${basename(fontPicker)}), one in use at a time` });
294
+ else if (typefaces.length > 3) candidates.push({ ratio: typefaces.length / 3, text: `${typefaces.length} typefaces. Most products use 2 or 3` });
289
295
  else if (typefaces.length && fontFamilies.length > 6) candidates.push({ ratio: fontFamilies.length / 6, text: `${typefaces.length} typeface${typefaces.length > 1 ? 's' : ''} declared ${fontFamilies.length} different ways` });
290
296
  if (effColors > 24 * 1.25) candidates.push({ ratio: effColors / 24, text: tokenLed
291
297
  ? `${n(colorStrays)} hardcoded colours outside the token set, your agent will happily copy them at random`
@@ -362,7 +368,7 @@ function tile(t, pLabel, pMetric, pFallback) {
362
368
  const { metric, label, value, healthValue, health } = t;
363
369
  if (health === 'na') {
364
370
  return { num: '—', label, health, metric, healthValue: null,
365
- rows: [{ label: `not measured: ${notMeasuredReason}`, val: '', dir: '' }] };
371
+ rows: [{ label: t.naReason ? t.naReason : `not measured: ${notMeasuredReason}`, val: '', dir: '' }] };
366
372
  }
367
373
  const iv = ideal(metric), rm = refMedian(metric);
368
374
  const pct = percentile(metric, value);
@@ -435,7 +441,7 @@ function projectedScore(applied) {
435
441
  // A utility-class shadcn repo with no hardcoded colours has zero literal
436
442
  // colours and a real system: the palette is bg-zinc-900 and friends.
437
443
  const utilityPalette = ds.kind === 'shadcn' && ds.cssVariables === false && twColorUtils >= 5;
438
- 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));
439
445
 
440
446
  // ---------- section renderers ----------
441
447
  const DIR = {
@@ -607,12 +613,17 @@ function sheetSection() {
607
613
  if (sheet.shadcnMissing?.length) bits.push(`${sheet.shadcnMissing.length} of the ${SHADCN_ROW_COUNT} current shadcn variables not defined (${sheet.shadcnMissing.slice(0, 4).map((r) => `--${esc(r)}`).join(', ')}${sheet.shadcnMissing.length > 4 ? '…' : ''})${sheet.hslEra ? ', normal for an install from before the chart and sidebar rows existed' : ''}`);
608
614
  if (sheet.missingDark?.length) bits.push(`${sheet.missingDark.length} variable${sheet.missingDark.length === 1 ? '' : 's'} with no dark value (${sheet.missingDark.slice(0, 4).map((r) => `--${esc(r)}`).join(', ')})`);
609
615
  if (sheet.custom?.length) bits.push(`${sheet.custom.length} custom variable${sheet.custom.length === 1 ? '' : 's'} of your own (${sheet.custom.slice(0, 5).map((r) => `--${esc(r)}`).join(', ')}${sheet.custom.length > 5 ? '…' : ''})${sheet.customMissingDark?.length ? `, ${sheet.customMissingDark.length} of them light only` : ''}${sheet.customUnregistered?.length ? `, ${sheet.customUnregistered.length} never mapped in @theme inline` : ''}`);
610
- if (sheet.tweakcnPresent >= 10) bits.push('tweakcn variables present: shadows and letter-spacing are themed');
616
+ if (sheet.tweakcnPresent >= 10) bits.push(`a tweakcn theme: ${sheet.tweakcnPresent} of the rows tweakcn adds to every theme it exports are here (shadows, letter-spacing, spacing), so the theme came from a theme editor or copied its shape, and updates will come from there too`);
611
617
  if (sheet.spacingChanged) bits.push(`<b>--spacing is ${esc(sheet.spacing)}</b>, not the default 0.25rem: this resizes every gap in the app at once, which shadcn\'s own changelog says never to do`);
612
618
  parts.push(`<div class="receipts">${eyebrow('the theme file, variable by variable')}<p class="sub">${bits.join(' · ')}.</p></div>`);
613
619
  } else if (sheet) {
614
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>`);
615
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
+ }
616
627
  if (paint) {
617
628
  const tinChips = (paint.tin.samples ?? []).slice(0, 8).map((s) => `<span class="vchip bad">${esc(s.value)} ×${s.count}</span>`).join('');
618
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('');
@@ -642,7 +653,7 @@ function duplicatesSection() {
642
653
  </div>` : '';
643
654
  const dupeCards = exactDupes.slice(0, 8).map((d) => `
644
655
  <div class="fam">
645
- ${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`}`)}
646
657
  <div class="fam-rows">
647
658
  ${d.files.slice(0, 6).map((f) => `<div class="mini-card">${fileLink(f)}</div>`).join('')}
648
659
  ${d.files.length > 6 ? `<div class="mini-card dim">…and ${d.files.length - 6} more</div>` : ''}
@@ -1317,6 +1328,16 @@ function shadcnReceipt() {
1317
1328
  const pages = comps.filter((c) => c.isPage && f.ownFiles.includes(c.file)).length;
1318
1329
  return `<div class="excl fresh">It is a fresh shadcn install${k?.style ? ` (style ${esc(k.style)}${k.baseColor ? `, base colour ${esc(k.baseColor)}` : ''}${k.tailwind ? `, Tailwind ${esc(k.tailwind)}` : ''})` : ''}. Nothing of your own yet: ${n(doors)} component${doors === 1 ? '' : 's'} installed, the theme file untouched, ${pages === 1 ? 'one demo page' : `${pages} pages`}. <b>The score is the kit's, not yours.</b> Run this again once you have built a few screens.</div><div class="excl">Evidence: ${receipt}</div>`;
1319
1330
  }
1331
+ if (P.isRegistry && P.registry) {
1332
+ const r = P.registry;
1333
+ const what = r.builtFrom ? `publishes ${r.items} component${r.items === 1 ? '' : 's'}, built from its packages folder` : `publishes ${esc(publishesLine(r))}`;
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>`;
1340
+ }
1320
1341
  return `<div class="excl">Read as a shadcn install (${esc(P.confidence ?? 'medium')} confidence): ${receipt}</div>`;
1321
1342
  }
1322
1343
 
@@ -1350,7 +1371,7 @@ function sidePanel() {
1350
1371
  ? `<div class="bd">Of which <b>${breakdown.installedPoints} point${breakdown.installedPoints === 1 ? '' : 's'}</b> come from installed code you did not write: ${esc(rp.dirs.map((d) => basename(d)).join(', '))} (${n(rp.files)} file${rp.files === 1 ? '' : 's'}, ${n(rp.tinUses)} palette colour${rp.tinUses === 1 ? '' : 's'}). Your own code alone would score <b>${breakdown.ownScore}</b>. Kept in the score because your agent reads those files like everything else; left out of the fixes because they are not yours to edit.</div>` : '';
1351
1372
  const scoreBlock = healthScore !== null
1352
1373
  ? `<div class="score${noSystemLikely ? ' muted' : ''}">${eyebrow('Health score')}<div class="val">${healthScore}<span class="slash">/</span><span class="of">100</span></div>${noSystemLikely ? '<div class="note">little here to score · see the note</div>' : ''}<div class="def">${def}${lift}</div>${bd}</div>` : '';
1353
- const chips = `<div class="chips">${stack.map((c) => `<span class="chip">${esc(c)}</span>`).join('')}${dsUnrecognised ? '<span class="chip chip-dim">design system: unrecognised</span>' : ''}${legacyChip ? `<span class="chip chip-dim">${esc(legacyChip)}</span>` : ''}${agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)}</span>`).join('')}</div>`;
1374
+ const chips = `<div class="chips">${stack.map((c) => `<span class="chip">${esc(c)}</span>`).join('')}${(P.shadcn?.sheet?.tweakcnPresent ?? 0) >= 10 ? '<span class="chip">tweakcn theme</span>' : ''}${dsUnrecognised ? '<span class="chip chip-dim">design system: unrecognised</span>' : ''}${legacyChip ? `<span class="chip chip-dim">${esc(legacyChip)}</span>` : ''}${agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)}</span>`).join('')}</div>`;
1354
1375
  const facts = [
1355
1376
  shadcnReceipt(),
1356
1377
  exclusionsLine(),
@@ -1380,6 +1401,13 @@ function exceptionsBlock() {
1380
1401
  const si = spacingOwn.installed;
1381
1402
  const sv = si.values.length;
1382
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
+ }
1383
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.`);
1384
1412
  const rp = P.shadcn?.registryPaint;
1385
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.`);
@@ -2027,6 +2055,7 @@ if (summaryPath) {
2027
2055
  verdict,
2028
2056
  role: P.role,
2029
2057
  kind: P.kind,
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 } } : {}),
2030
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 } } : {}),
2031
2060
  componentsMeasured,
2032
2061
  metrics: (({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }) =>
@@ -45,7 +45,8 @@ export function loadBenchmark() {
45
45
  * ideals and the reputable-systems line are the same for every kind.
46
46
  */
47
47
  export function benchHelpers(bench, kind = 'product') {
48
- const slice = bench?.slices?.[kind] ?? null;
48
+ // registries are compared with the shadcn repos, labelled as such (Greg, 2026-09-13)
49
+ const slice = bench?.slices?.[kind === 'registry' ? 'shadcn' : kind] ?? null;
49
50
  if (slice?.stats) bench = { ...bench, stats: { ...(bench?.stats ?? {}), ...slice.stats } };
50
51
  const sliceInfo = slice ? { kind, repoCount: slice.repoCount, builtAt: slice.builtAt } : null;
51
52
  // where does this value sit among the scanned fleet? ("more colours than 90%")
@@ -77,8 +78,8 @@ export function benchHelpers(bench, kind = 'product') {
77
78
  return { percentile, cleanerPct, ideal, median, displayAvg, refMedian, sliceInfo };
78
79
  }
79
80
 
80
- export const ZERO_IDEAL = new Set(['exactDuplicates', 'inlineStyles', 'nearPairs', 'important', 'neverImported']);
81
- 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 };
82
83
  export const SCORE_OF = { good: 100, warn: 55, bad: 10 };
83
84
 
84
85
  /** healthOf(metric, value) for one benchmark: 'good' | 'warn' | 'bad' | 'info'. */
@@ -105,6 +106,8 @@ export const PROFILE_TILES = {
105
106
  ['doorOverrides', 'components recoloured from outside per 100 files'],
106
107
  ],
107
108
  };
109
+ // a registry measures what a shadcn repo measures (release (a), 2026-09-13)
110
+ PROFILE_TILES.registry = [...PROFILE_TILES.shadcn, ['themesIncomplete', 'published themes incomplete']];
108
111
  export const tilesFor = (kind) => [...TILES, ...(PROFILE_TILES[kind] ?? [])];
109
112
 
110
113
  /** The tiles, in report order: metric key, the label the report prints. */
@@ -173,6 +176,11 @@ export function coreMetrics(h, opts = {}) {
173
176
  utilityPalette: profileOf(h).designSystem?.cssVariables === false,
174
177
  paintTin: paint?.tin?.per100 ?? 0,
175
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),
176
184
  };
177
185
  }
178
186
 
@@ -200,12 +208,14 @@ export function tileHealths(m, healthOf) {
200
208
  colors: m.colors, greys: m.greys, spacing: m.spacing, exactDuplicates: m.exactDuplicates,
201
209
  inlineStyles: m.inlineStyles, nearPairs: m.nearPairs, important: m.important,
202
210
  neverImported: m.neverImported, arbitrary: m.arbitrary,
203
- paintTin: m.paintTin ?? 0, doorOverrides: m.doorOverrides ?? 0,
211
+ paintTin: m.paintTin ?? 0, doorOverrides: m.doorOverrides ?? 0, themesIncomplete: m.themesIncomplete ?? 0,
204
212
  };
205
213
  const judged = { ...shown, colors: m.tokenLed ? m.colorStrays : m.colors, greys: m.tokenLed ? m.greyStrays : m.greys };
206
214
  return tilesFor(m.kind ?? 'product').map(([metric, label]) => {
207
215
  let health = healthOf(metric, judged[metric]);
208
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; }
209
219
  if (!m.componentsMeasured && (metric === 'exactDuplicates' || metric === 'neverImported')) {
210
220
  health = 'na'; value = null; healthValue = null;
211
221
  } else if (metric === 'neverImported' && (m.isLibrary || (m.vendoredUi && m.neverImported > 0))) {
@@ -213,7 +223,10 @@ export function tileHealths(m, healthOf) {
213
223
  } else if (metric === 'paintTin' && m.utilityPalette) {
214
224
  health = 'info';
215
225
  }
216
- 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 } : {}) };
217
230
  });
218
231
  }
219
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.5.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.
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
2
+ import { join, basename } from 'node:path';
3
3
  /**
4
4
  * Profiles — what kind of repo is this, decided once and read everywhere.
5
5
  *
@@ -22,6 +22,8 @@ import { join } 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, scopeFiles } from './registry.mjs';
26
+ export { scopeFiles };
25
27
  import library from './library.mjs';
26
28
  import product from './product.mjs';
27
29
 
@@ -60,6 +62,26 @@ export function decideProfile(profile, components, files, root = null) {
60
62
  profile.kind = picked.kind;
61
63
  profile.kindConfidence = decision.confidence;
62
64
  profile.kindEvidence = decision.evidence;
65
+ // A shadcn repo that PUBLISHES a registry is a registry: the fourth kind.
66
+ // Every count still reads the shadcn facts (release (a): zero score change);
67
+ // the kind, the receipt and the header line say what it is.
68
+ if (root) {
69
+ const reg = readRegistry(root, files);
70
+ if (reg) {
71
+ if (!reg.variants.length) reg.variants = variantsFromDirs(reg, profile.uiDirs, files);
72
+ delete reg.itemNames;
73
+ profile.kind = 'registry';
74
+ // consumers of what it publishes live in other repos: library semantics
75
+ profile.role = 'library';
76
+ profile.registry = reg;
77
+ profile.kindEvidence = [
78
+ reg.builtFrom ? `registry built from ${reg.items} packages by ${reg.source}` : `${reg.source} publishes ${publishesLine(reg)}`,
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`] : []),
81
+ ...decision.evidence,
82
+ ];
83
+ }
84
+ }
63
85
 
64
86
  // Measurability. The gate fires on the situation, never a list of known
65
87
  // frameworks: a repo with real code volume where the detector found almost
@@ -90,7 +112,10 @@ export function profileOf(h) {
90
112
  confidence: p.kindConfidence ?? null,
91
113
  evidence: p.kindEvidence ?? [],
92
114
  isLibrary: role === 'library',
93
- isShadcn: kind === 'shadcn',
115
+ // a registry is read with the shadcn facts, plus what it publishes
116
+ isShadcn: kind === 'shadcn' || kind === 'registry',
117
+ isRegistry: kind === 'registry',
118
+ registry: p.registry ?? null,
94
119
  shadcn: p.shadcn ?? null,
95
120
  uiDirs: p.uiDirs ?? (p.uiDir ? [p.uiDir] : []),
96
121
  // A vendored shadcn catalogue is stock on a shelf, not abandonment.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Registry: a project that publishes components or themes for other repos
3
+ * to install with the shadcn CLI (shadcn's own source, magicui, kibo-ui,
4
+ * tweakcn). Read as an ordinary shadcn install, the scanner counts its
5
+ * product range as sprawl (shadcn's source: 132 duplicates, one per base
6
+ * library; 29 typefaces from a font picker) and files what it publishes
7
+ * under "installed, not yours". Both wrong: what a registry publishes ships
8
+ * into every repo that installs it.
9
+ *
10
+ * Release (a), 2026-09-13: recognition and the header line only. Every count
11
+ * still reads the shadcn profile, so no score moves. Release (b) brings the
12
+ * counting rules (variants once, published code as own code, the theme
13
+ * check, the demo zone out). Brief: DOCs/proffer-2-notes/docs/registry-profile-brief-2026-09-13.md
14
+ *
15
+ * Recognised by either:
16
+ * 1. a registry.json whose items carry registry:* types (the source file,
17
+ * never the built copies under public/ nor test fixtures or templates)
18
+ * 2. a route that builds the registry from a packages folder
19
+ * (app/r/registry.json/route.ts, kibo-ui)
20
+ */
21
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
22
+ import { join, dirname, basename, relative } from 'node:path';
23
+ import { SHADCN_ROWS } from './shadcn-data.mjs';
24
+
25
+ const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
26
+
27
+ // registry item types → the words the report uses
28
+ const KINDS = [
29
+ ['components', /^registry:(ui|component)$/],
30
+ ['blocks', /^registry:block$/],
31
+ ['styles', /^registry:(style|theme)$/],
32
+ ['demos', /^registry:example$/],
33
+ ['other', /^registry:(lib|hook|file|page|internal)$/],
34
+ ];
35
+
36
+ function tally(items) {
37
+ const out = { components: 0, blocks: 0, styles: 0, demos: 0, other: 0 };
38
+ for (const it of items) {
39
+ const t = String(it?.type ?? '');
40
+ const k = KINDS.find(([, re]) => re.test(t));
41
+ if (k) out[k[0]] += 1;
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** Sibling folders holding the same published component names: the variants of one range. */
47
+ function variantDirs(items, root) {
48
+ const byName = new Map();
49
+ for (const it of items) {
50
+ if (!/^registry:(ui|component)$/.test(String(it?.type ?? ''))) continue;
51
+ for (const f of it.files ?? []) {
52
+ const p = typeof f === 'string' ? f : f?.path;
53
+ if (!p) continue;
54
+ const dir = dirname(p);
55
+ if (!byName.has(it.name)) byName.set(it.name, new Set());
56
+ byName.get(it.name).add(dir);
57
+ }
58
+ }
59
+ const dirs = new Map();
60
+ for (const set of byName.values()) if (set.size > 1) for (const d of set) dirs.set(d, (dirs.get(d) ?? 0) + 1);
61
+ return [...dirs.entries()].filter(([, n]) => n >= 5).map(([d]) => d).sort();
62
+ }
63
+
64
+ /**
65
+ * The facts a registry publishes, or null when the repo publishes nothing.
66
+ * @returns {{ source: string, builtFrom: string|null, items: number, publishes, variants: string[] } | null}
67
+ */
68
+ // The main walk skips docs/, public/ and test folders on purpose (they are
69
+ // not the product's design language). A registry's paperwork lives exactly
70
+ // there (kibo's route under apps/docs, tweakcn's built JSON under public), so
71
+ // recognition takes its own small look: depth-limited, noise skipped.
72
+ const LOOK_SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', 'coverage', '.turbo', 'test', 'tests', '__tests__', 'fixtures', 'templates']);
73
+ function lookFor(root, maxDepth = 6) {
74
+ const found = { registries: [], routes: [] };
75
+ const walk = (dir, rel, depth) => {
76
+ if (depth > maxDepth) return;
77
+ let es; try { es = readdirSync(dir, { withFileTypes: true }); } catch { return; }
78
+ for (const e of es) {
79
+ const r = rel ? `${rel}/${e.name}` : e.name;
80
+ if (e.isDirectory()) {
81
+ if (LOOK_SKIP.has(e.name) || (e.name.startsWith('.') && e.name !== '.')) continue;
82
+ if (e.name === 'registry.json') {
83
+ const route = ['route.ts', 'route.js', 'route.tsx', 'route.mjs'].find((n) => existsSync(join(dir, e.name, n)));
84
+ if (route && basename(dir) === 'r') found.routes.push(`${r}/${route}`);
85
+ continue;
86
+ }
87
+ walk(join(dir, e.name), r, depth + 1);
88
+ } else if (e.name === 'registry.json') found.registries.push(r);
89
+ }
90
+ };
91
+ walk(root, '', 0);
92
+ return found;
93
+ }
94
+
95
+ export function readRegistry(root, files) {
96
+ const look = lookFor(root);
97
+ // 1. a registry.json with typed items: the source file first (the biggest
98
+ // wins), else the built copy under public/ (tweakcn generates its registry
99
+ // from a TypeScript file and only the built JSON exists)
100
+ let best = null;
101
+ const consider = (f) => {
102
+ const j = readJSON(join(root, f));
103
+ const items = Array.isArray(j?.items) ? j.items : null;
104
+ if (!items || !items.some((it) => /^registry:/.test(String(it?.type ?? '')))) return;
105
+ if (!best || items.length > best.items.length) best = { source: f, items };
106
+ };
107
+ const candidates = look.registries;
108
+ for (const f of candidates.filter((f) => !/(^|\/)public\//.test(f))) consider(f);
109
+ if (!best) for (const f of candidates.filter((f) => /(^|\/)public\//.test(f))) consider(f);
110
+ if (best) {
111
+ const publishes = tally(best.items);
112
+ const itemNames = best.items.filter((it) => /^registry:(ui|component)$/.test(String(it?.type ?? ''))).map((it) => it.name).filter(Boolean);
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
+ };
125
+ }
126
+ // 2. a route that builds the registry from packages/* (kibo-ui)
127
+ const route = look.routes[0] ?? null;
128
+ if (route) {
129
+ const src = readFileSync(join(root, route), 'utf8');
130
+ if (/packages/.test(src)) {
131
+ // the packages folder nearest the route's app, walking up
132
+ let dir = dirname(route);
133
+ let pkgs = null;
134
+ while (dir && dir !== '.') {
135
+ const cand = join(root, dir, 'packages');
136
+ if (existsSync(cand)) { pkgs = cand; break; }
137
+ dir = dirname(dir);
138
+ }
139
+ if (!pkgs && existsSync(join(root, 'packages'))) pkgs = join(root, 'packages');
140
+ if (pkgs) {
141
+ const names = readdirSync(pkgs, { withFileTypes: true }).filter((e) => e.isDirectory() && existsSync(join(pkgs, e.name, 'index.tsx'))).map((e) => e.name);
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: [] } };
145
+ }
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /**
152
+ * Variants found on disk: catalogue folders (the shadcn profile's uiDirs)
153
+ * that each hold most of the published component names. shadcn's own
154
+ * registry.json lists one file per component (new-york-v4), while the same
155
+ * components sit again under bases/aria, bases/base and bases/radix.
156
+ */
157
+ export function variantsFromDirs(reg, _uiDirs, files) {
158
+ const names = new Set();
159
+ // names come from the registry items when it is a file, else from packages
160
+ if (reg.itemNames) for (const nm of reg.itemNames) names.add(nm);
161
+ if (names.size < 5) return [];
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
+ }
171
+ const hits = [];
172
+ for (const [d, have] of byDir) {
173
+ const shared = [...names].filter((nm) => have.has(nm)).length;
174
+ if (shared >= Math.max(5, Math.ceil(names.size * 0.5))) hits.push(d);
175
+ }
176
+ return hits.length > 1 ? hits.sort() : [];
177
+ }
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
+
228
+ /** The header sentence: what it publishes, in words. */
229
+ export function publishesLine(r) {
230
+ const parts = [];
231
+ const n = (k, word) => { const v = r.publishes[k]; if (v) parts.push(`${v} ${word}${v === 1 ? '' : 's'}`); };
232
+ n('components', 'component'); n('blocks', 'block'); n('styles', 'style'); n('demos', 'demo');
233
+ return parts.length ? parts.join(', ').replace(/, ([^,]*)$/, ' and $1') : `${r.items} items`;
234
+ }
@@ -303,6 +303,9 @@ export default {
303
303
  }
304
304
  evidence.push(contract ? `${contract} of shadcn's ${CONTRACT_ROWS.length} theme variables defined` : 'no shadcn theme variables found in any stylesheet');
305
305
  if (kit?.style) evidence.push(`style ${kit.style}${kit.baseColor ? `, base colour ${kit.baseColor}` : ''}${kit.tailwind ? `, Tailwind ${kit.tailwind}` : ''}`);
306
+ // ten or more of the rows tweakcn adds to every theme it exports: a
307
+ // fingerprint of a theme editor, not a signature, so "tweakcn-style"
308
+ if ((sheet?.tweakcnPresent ?? 0) >= 10) evidence.push(`tweakcn theme (${sheet.tweakcnPresent} of its rows present)`);
306
309
 
307
310
  // 4. write the facts every consumer reads
308
311
  profile.uiDirs = installs.map((i) => i.uiDir).filter(Boolean);
@@ -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.`);