roast-my-design-system 7.5.0 → 7.6.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.6.0" width="72" alt="roast-my-design-system">
2
2
 
3
3
  # roast-my-design-system
4
4
 
@@ -10,6 +10,8 @@
10
10
 
11
11
  A free CLI tool (and Claude Code skill) that roasts your repo's design system with real data, then generates the rules that keep your AI agent on-system.
12
12
 
13
+ > **New in 7.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
+
13
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.
14
16
 
15
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.
@@ -81,11 +83,11 @@ Not to be confused with each other: **"Why this matters"** is generic, ships wit
81
83
 
82
84
  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
85
 
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)
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)
85
87
 
86
88
  The same report in light mode (one file, built-in toggle):
87
89
 
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)
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)
89
91
 
90
92
  ## What makes the numbers trustworthy
91
93
 
@@ -235,12 +237,13 @@ After the roast, the skill also offers to write `design-system-rules.md` to disk
235
237
 
236
238
  ## Live examples
237
239
 
238
- 5 real roasts of public repos, hosted as-is (the same self-contained HTML the skill generates), spanning React, Stencil and Lit:
240
+ 7 real roasts of public repos, hosted as-is (the same self-contained HTML the skill generates), spanning React, Stencil and Lit:
239
241
 
240
242
  - **[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
243
  - **[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
244
  - **[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
245
  - **[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
244
247
  - **[excalidraw/excalidraw](https://gregkozakiewicz.github.io/roast-my-design-system/examples/excalidraw-excalidraw.html)**
245
248
  - **[dubinc/dub](https://gregkozakiewicz.github.io/roast-my-design-system/examples/dubinc-dub.html)**
246
249
 
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.6.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": [
@@ -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`
@@ -607,7 +613,7 @@ 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) {
@@ -1317,6 +1323,12 @@ function shadcnReceipt() {
1317
1323
  const pages = comps.filter((c) => c.isPage && f.ownFiles.includes(c.file)).length;
1318
1324
  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
1325
  }
1326
+ if (P.isRegistry && P.registry) {
1327
+ const r = P.registry;
1328
+ 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>`;
1331
+ }
1320
1332
  return `<div class="excl">Read as a shadcn install (${esc(P.confidence ?? 'medium')} confidence): ${receipt}</div>`;
1321
1333
  }
1322
1334
 
@@ -1350,7 +1362,7 @@ function sidePanel() {
1350
1362
  ? `<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
1363
  const scoreBlock = healthScore !== null
1352
1364
  ? `<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>`;
1365
+ 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
1366
  const facts = [
1355
1367
  shadcnReceipt(),
1356
1368
  exclusionsLine(),
@@ -2027,6 +2039,7 @@ if (summaryPath) {
2027
2039
  verdict,
2028
2040
  role: P.role,
2029
2041
  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 } } : {}),
2030
2043
  ...(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
2044
  componentsMeasured,
2032
2045
  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%")
@@ -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;
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. */
@@ -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.6.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,7 @@ 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 } from './registry.mjs';
25
26
  import library from './library.mjs';
26
27
  import product from './product.mjs';
27
28
 
@@ -60,6 +61,23 @@ export function decideProfile(profile, components, files, root = null) {
60
61
  profile.kind = picked.kind;
61
62
  profile.kindConfidence = decision.confidence;
62
63
  profile.kindEvidence = decision.evidence;
64
+ // A shadcn repo that PUBLISHES a registry is a registry: the fourth kind.
65
+ // Every count still reads the shadcn facts (release (a): zero score change);
66
+ // the kind, the receipt and the header line say what it is.
67
+ if (picked === shadcn && root) {
68
+ const reg = readRegistry(root, files);
69
+ if (reg) {
70
+ if (!reg.variants.length) reg.variants = variantsFromDirs(reg, profile.uiDirs, files);
71
+ delete reg.itemNames;
72
+ profile.kind = 'registry';
73
+ profile.registry = reg;
74
+ profile.kindEvidence = [
75
+ reg.builtFrom ? `registry built from ${reg.items} packages by ${reg.source}` : `${reg.source} publishes ${publishesLine(reg)}`,
76
+ ...(reg.variants.length ? [`the same components kept in ${reg.variants.length} variants (${reg.variants.map((d) => basename(d)).join(', ')})`] : []),
77
+ ...decision.evidence,
78
+ ];
79
+ }
80
+ }
63
81
 
64
82
  // Measurability. The gate fires on the situation, never a list of known
65
83
  // frameworks: a repo with real code volume where the detector found almost
@@ -90,7 +108,10 @@ export function profileOf(h) {
90
108
  confidence: p.kindConfidence ?? null,
91
109
  evidence: p.kindEvidence ?? [],
92
110
  isLibrary: role === 'library',
93
- isShadcn: kind === 'shadcn',
111
+ // a registry is read with the shadcn facts, plus what it publishes
112
+ isShadcn: kind === 'shadcn' || kind === 'registry',
113
+ isRegistry: kind === 'registry',
114
+ registry: p.registry ?? null,
94
115
  shadcn: p.shadcn ?? null,
95
116
  uiDirs: p.uiDirs ?? (p.uiDir ? [p.uiDir] : []),
96
117
  // A vendored shadcn catalogue is stock on a shelf, not abandonment.
@@ -0,0 +1,164 @@
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 } from 'node:path';
23
+
24
+ const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
25
+
26
+ // registry item types → the words the report uses
27
+ const KINDS = [
28
+ ['components', /^registry:(ui|component)$/],
29
+ ['blocks', /^registry:block$/],
30
+ ['styles', /^registry:(style|theme)$/],
31
+ ['demos', /^registry:example$/],
32
+ ['other', /^registry:(lib|hook|file|page|internal)$/],
33
+ ];
34
+
35
+ function tally(items) {
36
+ const out = { components: 0, blocks: 0, styles: 0, demos: 0, other: 0 };
37
+ for (const it of items) {
38
+ const t = String(it?.type ?? '');
39
+ const k = KINDS.find(([, re]) => re.test(t));
40
+ if (k) out[k[0]] += 1;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /** Sibling folders holding the same published component names: the variants of one range. */
46
+ function variantDirs(items, root) {
47
+ const byName = new Map();
48
+ for (const it of items) {
49
+ if (!/^registry:(ui|component)$/.test(String(it?.type ?? ''))) continue;
50
+ for (const f of it.files ?? []) {
51
+ const p = typeof f === 'string' ? f : f?.path;
52
+ if (!p) continue;
53
+ const dir = dirname(p);
54
+ if (!byName.has(it.name)) byName.set(it.name, new Set());
55
+ byName.get(it.name).add(dir);
56
+ }
57
+ }
58
+ const dirs = new Map();
59
+ for (const set of byName.values()) if (set.size > 1) for (const d of set) dirs.set(d, (dirs.get(d) ?? 0) + 1);
60
+ return [...dirs.entries()].filter(([, n]) => n >= 5).map(([d]) => d).sort();
61
+ }
62
+
63
+ /**
64
+ * The facts a registry publishes, or null when the repo publishes nothing.
65
+ * @returns {{ source: string, builtFrom: string|null, items: number, publishes, variants: string[] } | null}
66
+ */
67
+ // The main walk skips docs/, public/ and test folders on purpose (they are
68
+ // not the product's design language). A registry's paperwork lives exactly
69
+ // there (kibo's route under apps/docs, tweakcn's built JSON under public), so
70
+ // recognition takes its own small look: depth-limited, noise skipped.
71
+ const LOOK_SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', 'coverage', '.turbo', 'test', 'tests', '__tests__', 'fixtures', 'templates']);
72
+ function lookFor(root, maxDepth = 6) {
73
+ const found = { registries: [], routes: [] };
74
+ const walk = (dir, rel, depth) => {
75
+ if (depth > maxDepth) return;
76
+ let es; try { es = readdirSync(dir, { withFileTypes: true }); } catch { return; }
77
+ for (const e of es) {
78
+ const r = rel ? `${rel}/${e.name}` : e.name;
79
+ if (e.isDirectory()) {
80
+ if (LOOK_SKIP.has(e.name) || (e.name.startsWith('.') && e.name !== '.')) continue;
81
+ if (e.name === 'registry.json') {
82
+ const route = ['route.ts', 'route.js', 'route.tsx', 'route.mjs'].find((n) => existsSync(join(dir, e.name, n)));
83
+ if (route && basename(dir) === 'r') found.routes.push(`${r}/${route}`);
84
+ continue;
85
+ }
86
+ walk(join(dir, e.name), r, depth + 1);
87
+ } else if (e.name === 'registry.json') found.registries.push(r);
88
+ }
89
+ };
90
+ walk(root, '', 0);
91
+ return found;
92
+ }
93
+
94
+ export function readRegistry(root, files) {
95
+ const look = lookFor(root);
96
+ // 1. a registry.json with typed items: the source file first (the biggest
97
+ // wins), else the built copy under public/ (tweakcn generates its registry
98
+ // from a TypeScript file and only the built JSON exists)
99
+ let best = null;
100
+ const consider = (f) => {
101
+ const j = readJSON(join(root, f));
102
+ const items = Array.isArray(j?.items) ? j.items : null;
103
+ if (!items || !items.some((it) => /^registry:/.test(String(it?.type ?? '')))) return;
104
+ if (!best || items.length > best.items.length) best = { source: f, items };
105
+ };
106
+ const candidates = look.registries;
107
+ for (const f of candidates.filter((f) => !/(^|\/)public\//.test(f))) consider(f);
108
+ if (!best) for (const f of candidates.filter((f) => /(^|\/)public\//.test(f))) consider(f);
109
+ if (best) {
110
+ const publishes = tally(best.items);
111
+ 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
+ }
114
+ // 2. a route that builds the registry from packages/* (kibo-ui)
115
+ const route = look.routes[0] ?? null;
116
+ if (route) {
117
+ const src = readFileSync(join(root, route), 'utf8');
118
+ if (/packages/.test(src)) {
119
+ // the packages folder nearest the route's app, walking up
120
+ let dir = dirname(route);
121
+ let pkgs = null;
122
+ while (dir && dir !== '.') {
123
+ const cand = join(root, dir, 'packages');
124
+ if (existsSync(cand)) { pkgs = cand; break; }
125
+ dir = dirname(dir);
126
+ }
127
+ if (!pkgs && existsSync(join(root, 'packages'))) pkgs = join(root, 'packages');
128
+ if (pkgs) {
129
+ 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: [] };
131
+ }
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * Variants found on disk: catalogue folders (the shadcn profile's uiDirs)
139
+ * that each hold most of the published component names. shadcn's own
140
+ * registry.json lists one file per component (new-york-v4), while the same
141
+ * components sit again under bases/aria, bases/base and bases/radix.
142
+ */
143
+ export function variantsFromDirs(reg, uiDirs, files) {
144
+ const names = new Set();
145
+ // names come from the registry items when it is a file, else from packages
146
+ if (reg.itemNames) for (const nm of reg.itemNames) names.add(nm);
147
+ if (names.size < 5) return [];
148
+ const stem = (f) => { const b = basename(f).replace(/\.[cm]?[jt]sx?$/, ''); return b === 'index' ? basename(dirname(f)) : b; };
149
+ 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));
152
+ const shared = [...names].filter((nm) => have.has(nm)).length;
153
+ if (shared >= Math.max(5, Math.ceil(names.size * 0.5))) hits.push(d);
154
+ }
155
+ return hits.length > 1 ? hits.sort() : [];
156
+ }
157
+
158
+ /** The header sentence: what it publishes, in words. */
159
+ export function publishesLine(r) {
160
+ const parts = [];
161
+ const n = (k, word) => { const v = r.publishes[k]; if (v) parts.push(`${v} ${word}${v === 1 ? '' : 's'}`); };
162
+ n('components', 'component'); n('blocks', 'block'); n('styles', 'style'); n('demos', 'demo');
163
+ return parts.length ? parts.join(', ').replace(/, ([^,]*)$/, ' and $1') : `${r.items} items`;
164
+ }
@@ -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);