roast-my-design-system 7.1.0 → 7.2.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.1.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.2.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.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.
14
+
13
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`.
14
16
 
15
17
  > **New in 6.0: a dark mode is not sprawl, and the ruler has been rebuilt.** A system with a dark theme states most of its colours twice. One with a density switch states its spacing twice again. Every restatement was counted, so the systems doing the most work scored the worst. Now only a token's first statement counts. A factory-fresh shadcn install goes from 20 colours to 16, and Shoelace from 420 to 219. Two related faults went with it. `border-radius: var(--radius)` was counted as a radius value, so Polaris showed 49 radii of which 36 were references to its own tokens, and Telekom showed 52 font sizes of which 48 were. A new finding names one token holding two different colours in two packages of a monorepo, which fires 8 times across 19 real repos. The benchmark was rebuilt with all of it, so every score is measured against a ruler that counts the same way: cal.com 15 to 20, Cloudscape 35 to 40, MUI 50 to 55. If you have a score from 5.x, take a fresh one before you compare.
@@ -73,11 +75,11 @@ Not to be confused with each other: **"Why this matters"** is generic, ships wit
73
75
 
74
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:
75
77
 
76
- ![The full diagnosis report for vercel/ai-chatbot in dark mode: health score, the What the numbers mean analysis written by Claude, priced Where to start moves each with its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, 3-yardstick tiles, the adoption map treemap, palette forensics, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-full-dark.png?v=7.1.0)
78
+ ![The full diagnosis report for vercel/ai-chatbot in dark mode: health score, the line saying how the repo was read as a shadcn install, 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.2.0)
77
79
 
78
80
  The same report in light mode (one file, built-in toggle):
79
81
 
80
- ![The diagnosis report in light mode](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-light-hero.png?v=7.1.0)
82
+ ![The diagnosis report in light mode](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-light-hero.png?v=7.2.0)
81
83
 
82
84
  ## What makes the numbers trustworthy
83
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "7.1.0",
3
+ "version": "7.2.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": [
@@ -32,14 +32,11 @@ import { fixPrompt } from '../lib/fixprompt.mjs';
32
32
  import { WHY } from './why.mjs';
33
33
  import { parseColor, luminance, isGrey } from '../lib/color.mjs';
34
34
  import { loadBenchmark, benchHelpers, makeHealthOf, coreMetrics, tileHealths, scoreOfTiles, scorePackage as scorePackageOf, ZERO_IDEAL, WARN_TOLERANCE, SCORE_OF, SCHEMA_VERSION } from './score.mjs';
35
- import { profileOf } from '../profiles/index.mjs';
35
+ import { profileOf, profileYardstick } from '../profiles/index.mjs';
36
36
 
37
37
  // The benchmark and every judgement made against it live in score.mjs; this
38
38
  // file only draws. The same numbers reach summary.json through scoreHarvest.
39
39
  const bench = loadBenchmark();
40
- const B = benchHelpers(bench);
41
- const { percentile, cleanerPct, ideal, median, displayAvg, refMedian } = B;
42
- const healthOf = makeHealthOf(B);
43
40
 
44
41
  function arg(name, fallback) {
45
42
  const i = process.argv.indexOf(`--${name}`);
@@ -50,6 +47,11 @@ if (!inPath) { console.error('Usage: node src/diagnose/index.mjs <harvest.json>
50
47
  const outPath = resolve(arg('out', 'diagnosis.html'));
51
48
 
52
49
  const h = JSON.parse(readFileSync(inPath, 'utf8'));
50
+ // The yardstick: the general benchmark, plus whatever the repo's kind brings
51
+ // for the tiles only it measures (the shadcn card owns 2).
52
+ const B = benchHelpers(bench, profileYardstick(h));
53
+ const { percentile, cleanerPct, ideal, median, displayAvg, refMedian } = B;
54
+ const healthOf = makeHealthOf(B);
53
55
  const M = coreMetrics(h);
54
56
 
55
57
  // The GK mark (32px favicon, also gregkozakiewicz.com). Reused in the footer
@@ -336,6 +338,7 @@ const FALLBACK_TARGET = {
336
338
  colors: 'a system needs ~24', greys: 'a scale has up to 13', spacing: 'a dozen deliberate exceptions',
337
339
  exactDuplicates: 'should be 0', inlineStyles: 'invisible to any system', nearPairs: 'copy-paste, not decisions',
338
340
  important: 'the cascade admitting defeat', neverImported: 'the system nobody found', arbitrary: 'a handful of deliberate exceptions',
341
+ paintTin: 'the theme file already has a variable for it', doorOverrides: 'use a variant instead',
339
342
  };
340
343
  // Dress one judged tile for the page: formatted number, comparison rows.
341
344
  // Accepts a judged tile from score.mjs, or the positional form the
@@ -367,6 +370,9 @@ function tile(t, pLabel, pMetric, pFallback) {
367
370
  ? 'library: internal use only, downstream consumers invisible'
368
371
  : 'catalogue stock: installed by the shadcn CLI, not used yet', val: '', dir: '' });
369
372
  }
373
+ if (health === 'info' && metric === 'paintTin') {
374
+ rows.push({ label: 'utility-class mode: the palette is the theme by design, not counted', val: '', dir: '' });
375
+ }
370
376
  return { num: n(value), label, health, rows, metric, healthValue };
371
377
  }
372
378
  const judgedTiles = tileHealths(M, healthOf);
@@ -574,6 +580,42 @@ function spacingBars() {
574
580
  ${whyToggle('spacing')}${arb}</section>`;
575
581
  }
576
582
 
583
+ // The shadcn sheet and the 2 paint checks: receipts, with the files and the
584
+ // exact classes. Only a shadcn kitchen renders this.
585
+ function sheetSection() {
586
+ if (!P.isShadcn) return '';
587
+ const sc = P.shadcn ?? {};
588
+ const sheet = sc.sheet, paint = sc.paint;
589
+ const parts = [];
590
+ if (sheet?.found) {
591
+ const bits = [`${n(sheet.lightRows)} variables for light, ${n(sheet.darkRows)} for dark in ${esc(sheet.file)}`];
592
+ if (sheet.hslEra) bits.push('written in the Tailwind 3 form (hsl triplets)');
593
+ 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' : ''}`);
594
+ 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(', ')})`);
595
+ 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` : ''}`);
596
+ if (sheet.tweakcnPresent >= 10) bits.push('tweakcn variables present: shadows and letter-spacing are themed');
597
+ 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`);
598
+ parts.push(`<div class="receipts">${eyebrow('the theme file, variable by variable')}<p class="sub">${bits.join(' · ')}.</p></div>`);
599
+ } else if (sheet) {
600
+ 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>`);
601
+ }
602
+ if (paint) {
603
+ const tinChips = (paint.tin.samples ?? []).slice(0, 8).map((s) => `<span class="vchip bad">${esc(s.value)} ×${s.count}</span>`).join('');
604
+ 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('');
605
+ parts.push(`<div class="receipts">${eyebrow(`${n(paint.tin.uses)} colours from outside the theme in ${n(paint.tin.files)} of ${n(paint.ownFiles)} own files · ${n(paint.tin.per100)} per 100 files`)}${paint.tin.uses ? `<div class="chips-row">${tinChips}</div><div class="chips-row">${tinFiles}</div>` : '<p class="sub">Your own code takes every colour from the theme file. This is what shadcn is designed for.</p>'}</div>`);
606
+ parts.push(whyToggle('paintTin'));
607
+ const doorChips = (paint.doors.samples ?? []).slice(0, 6).map((s) => `<span class="vchip bad">${esc(s.value)} ×${s.count}</span>`).join('');
608
+ parts.push(`<div class="receipts">${eyebrow(`${n(paint.doors.uses)} shadcn components restyled through className · ${n(paint.doors.per100)} per 100 files`)}${paint.doors.uses ? `<div class="chips-row">${doorChips}</div>` : '<p class="sub">No shadcn component is given a colour or a font through className. Variants are doing their job.</p>'}</div>`);
609
+ parts.push(whyToggle('doorOverrides'));
610
+ }
611
+ if (!parts.length) return '';
612
+ return `<section class="glass pad" id="shadcn" style="margin-top:16px">
613
+ ${sectionHead('The shadcn theme and the 2 shadcn checks', 'shadcn gives you a theme file of named variables and a set of components with variants. These receipts show where your own code went around both.')}
614
+ ${parts.join('')}
615
+ </section>`;
616
+ }
617
+ const SHADCN_ROW_COUNT = 33;
618
+
577
619
  function duplicatesSection() {
578
620
  if (!exactDupes.length && !families.length && !iconCollisions.length) return '';
579
621
  const iconCard = iconCollisions.length ? `
@@ -1020,6 +1062,22 @@ function whereToStartSection() {
1020
1062
  sub: `${neverImported.slice(0, 3).map((x) => `&lt;${esc(x.name)}&gt;`).join(', ')}${neverImported.length > 3 ? ' and others' : ''} sit in the system with no callers. Adopt them or delete them: either answer is better than a system with rooms nobody enters.` });
1021
1063
  }
1022
1064
 
1065
+ if (P.isShadcn && P.shadcn?.paint) {
1066
+ const pt = P.shadcn.paint;
1067
+ if (pt.tin.uses >= 10) {
1068
+ const s0 = pt.tin.samples[0], f0 = pt.tin.top[0];
1069
+ c.push({ score: 20 + pt.tin.per100 / 4, metric: 'paintTin', after: 0,
1070
+ title: `Repaint the ${n(pt.tin.uses)} colours from outside the theme`,
1071
+ sub: `${esc(s0.value)} appears ${s0.count} times${f0 ? `, ${esc(basename(f0.file))} alone carries ${f0.count}` : ''}. The theme file already has a variable for each (a grey is <code>text-muted-foreground</code>, a status colour is a Badge variant or a variable you add). Swap the class, not the value.` });
1072
+ }
1073
+ if (pt.doors.uses >= 5) {
1074
+ const s0 = pt.doors.samples[0];
1075
+ c.push({ score: 15 + pt.doors.per100 / 4, metric: 'doorOverrides', after: 0,
1076
+ title: `Stop restyling shadcn components through className`,
1077
+ sub: `${n(pt.doors.uses)} shadcn components receive a colour or a font through className, like ${esc(s0.value)}. Use the variant that does it, or add one to the component you own. className on a shadcn component is for layout only.` });
1078
+ }
1079
+ }
1080
+
1023
1081
  // What each move is actually worth, then rank by payoff for real.
1024
1082
  for (const item of c) {
1025
1083
  item.delta = 0; item.target = null;
@@ -1171,7 +1229,7 @@ function agentSection() {
1171
1229
  // quiet unrecognised chip, because not knowing IS a finding here.
1172
1230
  const ns = h.tokens.namespaces ?? null;
1173
1231
  const dsChip =
1174
- ds.kind === 'shadcn' ? (ds.cssVariables === false ? 'shadcn/ui (utility classes, no CSS variables)' : 'shadcn/ui')
1232
+ ds.kind === 'shadcn' ? `shadcn/ui${ds.cssVariables === false ? ' (utility classes, no CSS variables)' : ''}${P.shadcn?.kit?.style ? ` · ${P.shadcn.kit.style}` : ''}`
1175
1233
  : ds.kind === 'library' ? ds.name
1176
1234
  : ns ? `custom design system (--${ns.primary}-*${ns.partner ? ` + --${ns.partner}-*` : ''})`
1177
1235
  // A tokenFile alone is a technicality (Lion's is one drawer style file);
@@ -1190,6 +1248,24 @@ const stack = [
1190
1248
  const dsUnrecognised = !dsChip && !noSystemLikely;
1191
1249
  const legacyChip = ns?.others?.length ? `also present: ${ns.others.map((l) => `--${l}-*`).join(', ')}` : null;
1192
1250
 
1251
+ // The recognition receipt: how the scanner decided this is a shadcn kitchen
1252
+ // and what kit it read. Printed under the chips, never hidden, so a wrong
1253
+ // guess is visible before any number is.
1254
+ function shadcnReceipt() {
1255
+ if (!P.isShadcn) return '';
1256
+ const k = P.shadcn?.kit ?? null;
1257
+ const facts = [
1258
+ k?.theme ? `accent ${k.theme}` : null,
1259
+ k?.chartColor ? `charts ${k.chartColor}` : null,
1260
+ k?.radius ? `radius ${k.radius}` : null,
1261
+ k?.iconLibrary ? `icons ${k.iconLibrary}` : null,
1262
+ k && k.cssVariables === false ? 'utility-class mode: the palette is the theme' : null,
1263
+ ].filter(Boolean);
1264
+ const unmatched = (k?.fellBack ?? []).filter((f) => f === 'theme' || f === 'chartColor');
1265
+ const fell = unmatched.length ? ` · ${unmatched.length === 2 ? 'accent and chart colour' : unmatched[0] === 'theme' ? 'accent' : 'chart colour'} not matched to a named shadcn theme` : '';
1266
+ return `<div class="excl">Read as a shadcn install (${esc(P.confidence ?? 'medium')} confidence): ${esc(P.evidence.join(' · '))}${facts.length ? ` · ${esc(facts.join(', '))}` : ''}${esc(fell)}</div>`;
1267
+ }
1268
+
1193
1269
  // User exclusions are printed in the header, never hidden: a scoped scan must
1194
1270
  // say it is scoped, or the score could be quietly gamed. Grouped by source
1195
1271
  // (.roastignore vs --exclude), with the total number of files kept out.
@@ -1612,6 +1688,7 @@ const html = `<!doctype html>
1612
1688
  ${healthScore !== null ? `<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 below</div>' : ''}</div>` : ''}
1613
1689
  </div>
1614
1690
  <div class="chips">${stack.map((s) => `<span class="chip">${esc(s)}</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>
1691
+ ${shadcnReceipt()}
1615
1692
  ${exclusionsLine()}
1616
1693
  ${noSystemLikely ? `<div class="nods">${ICONS.warn}<span>There is most likely <b>no design system in this repo</b>: almost no colour or spacing values were found. Styling may live outside this codebase (CDN stylesheets, a parent repo, or generated output).</span></div>` : ''}
1617
1694
  <div class="glass verdict-card">
@@ -1638,6 +1715,7 @@ ${trapsBlock()}
1638
1715
  ${packagesSection()}
1639
1716
 
1640
1717
  <section style="margin-top:16px">${paletteSection()}</section>
1718
+ ${sheetSection()}
1641
1719
 
1642
1720
  ${spacingBars()}
1643
1721
  ${typographySection()}
@@ -1769,7 +1847,9 @@ if (summaryPath) {
1769
1847
  score: healthScore,
1770
1848
  noSystemLikely,
1771
1849
  verdict,
1772
- role: P.kind,
1850
+ role: P.role,
1851
+ kind: P.kind,
1852
+ ...(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, ownFiles: P.shadcn.paint?.ownFiles ?? null } } : {}),
1773
1853
  componentsMeasured,
1774
1854
  metrics: (({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }) =>
1775
1855
  ({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }))(M),
@@ -26,7 +26,7 @@ import { isGrey } from '../lib/color.mjs';
26
26
  import { nearColorPairs } from '../lib/nearpairs.mjs';
27
27
  import { neverImportedComponents } from '../lib/neverimported.mjs';
28
28
  import { SCHEMA_VERSION } from '../lib/version.mjs';
29
- import { profileOf } from '../profiles/index.mjs';
29
+ import { profileOf, profileYardstick } from '../profiles/index.mjs';
30
30
 
31
31
  export { SCHEMA_VERSION };
32
32
 
@@ -37,8 +37,20 @@ export function loadBenchmark() {
37
37
  return existsSync(BENCH_PATH) ? JSON.parse(readFileSync(BENCH_PATH, 'utf8')) : null;
38
38
  }
39
39
 
40
- /** The yardstick readers for one benchmark object (null-safe). */
41
- export function benchHelpers(bench) {
40
+ /**
41
+ * The yardstick readers for one benchmark object (null-safe). A profile may
42
+ * bring its own ideals and fleet stats for the metrics only it measures
43
+ * (question 6 of the profile plan); they overlay the general benchmark and
44
+ * never replace an entry the benchmark already has.
45
+ */
46
+ export function benchHelpers(bench, yardstick = null) {
47
+ if (yardstick && (Object.keys(yardstick.ideals ?? {}).length || Object.keys(yardstick.stats ?? {}).length)) {
48
+ bench = {
49
+ ...(bench ?? {}),
50
+ ideal2026: { ...(yardstick.ideals ?? {}), ...(bench?.ideal2026 ?? {}) },
51
+ stats: { ...(yardstick.stats ?? {}), ...(bench?.stats ?? {}) },
52
+ };
53
+ }
42
54
  // where does this value sit among the scanned fleet? ("more colours than 90%")
43
55
  const percentile = (metric, value) => {
44
56
  const vals = bench?.stats?.[metric]?.values;
@@ -89,6 +101,15 @@ export function makeHealthOf(b) {
89
101
  };
90
102
  }
91
103
 
104
+ /** Tiles a kind of repo adds to the nine. Only the shadcn card has any today. */
105
+ export const PROFILE_TILES = {
106
+ shadcn: [
107
+ ['paintTin', 'off-theme colours per 100 files'],
108
+ ['doorOverrides', 'components restyled from outside per 100 files'],
109
+ ],
110
+ };
111
+ export const tilesFor = (kind) => [...TILES, ...(PROFILE_TILES[kind] ?? [])];
112
+
92
113
  /** The tiles, in report order: metric key, the label the report prints. */
93
114
  export const TILES = [
94
115
  ['colors', 'distinct colours'],
@@ -140,6 +161,13 @@ export function coreMetrics(h) {
140
161
  componentsMeasured: profileOf(h).componentsMeasured,
141
162
  isLibrary: profileOf(h).isLibrary,
142
163
  vendoredUi: profileOf(h).vendoredUi,
164
+ // the kind, and the tiles only that kind measures (per 100 own-code files)
165
+ kind: profileOf(h).kind,
166
+ // utility-class mode (components.json cssVariables: false): the palette IS
167
+ // the theme by design, so a palette class is not paint from a tin
168
+ utilityPalette: profileOf(h).designSystem?.cssVariables === false,
169
+ paintTin: profileOf(h).shadcn?.paint?.tin?.per100 ?? 0,
170
+ doorOverrides: profileOf(h).shadcn?.paint?.doors?.per100 ?? 0,
143
171
  };
144
172
  }
145
173
 
@@ -152,15 +180,18 @@ export function tileHealths(m, healthOf) {
152
180
  colors: m.colors, greys: m.greys, spacing: m.spacing, exactDuplicates: m.exactDuplicates,
153
181
  inlineStyles: m.inlineStyles, nearPairs: m.nearPairs, important: m.important,
154
182
  neverImported: m.neverImported, arbitrary: m.arbitrary,
183
+ paintTin: m.paintTin ?? 0, doorOverrides: m.doorOverrides ?? 0,
155
184
  };
156
185
  const judged = { ...shown, colors: m.tokenLed ? m.colorStrays : m.colors, greys: m.tokenLed ? m.greyStrays : m.greys };
157
- return TILES.map(([metric, label]) => {
186
+ return tilesFor(m.kind ?? 'product').map(([metric, label]) => {
158
187
  let health = healthOf(metric, judged[metric]);
159
188
  let value = shown[metric], healthValue = judged[metric];
160
189
  if (!m.componentsMeasured && (metric === 'exactDuplicates' || metric === 'neverImported')) {
161
190
  health = 'na'; value = null; healthValue = null;
162
191
  } else if (metric === 'neverImported' && (m.isLibrary || (m.vendoredUi && m.neverImported > 0))) {
163
192
  health = 'info';
193
+ } else if (metric === 'paintTin' && m.utilityPalette) {
194
+ health = 'info';
164
195
  }
165
196
  return { metric, label, value, healthValue, health };
166
197
  });
@@ -199,7 +230,7 @@ export function scorePackage(m, healthOf, b) {
199
230
 
200
231
  /** The whole judgement of one harvest, as data. */
201
232
  export function scoreHarvest(h, bench = loadBenchmark()) {
202
- const b = benchHelpers(bench);
233
+ const b = benchHelpers(bench, profileYardstick(h));
203
234
  const healthOf = makeHealthOf(b);
204
235
  const metrics = coreMetrics(h);
205
236
  const tiles = tileHealths(metrics, healthOf);
@@ -9,6 +9,17 @@
9
9
  // and must move together with benchmark.json if the norms are ever re-tuned.
10
10
 
11
11
  export const WHY = {
12
+ // DRAFT for Greg's approval (2026-09-12): the 2 shadcn tiles.
13
+ paintTin: [
14
+ 'shadcn gives every project a theme file. It holds one CSS variable per job: background, foreground, muted text, border, ring, and about 30 more. Each has a light value and a dark value. Components use them through classes like bg-background and text-muted-foreground.',
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
+ '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
+ ],
18
+ doorOverrides: [
19
+ '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 or font-bold through className decides it again at the call site, where the component can not see it.',
20
+ '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.',
21
+ 'The ideal is 15 per 100 files, the tidiest third of the same 15 repos; the median is 22.',
22
+ ],
12
23
  colors: [
13
24
  'Look, nobody consciously chooses 100 colours to start with. They were added with each change, and the change after that. No one was checking for the drift, because no one expected it. Then come the multipliers: a rebrand, a dark-mode pass, ad hoc files, each one multiplying the values again.',
14
25
  'When an agent looks for Brand Blue it scans the repo, sees dozens of blues and logically picks the one used most often, which is how the most-used stray outvotes the actual token.',
@@ -27,7 +27,8 @@ 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 } from '../profiles/index.mjs';
30
+ import { decideProfile, profileOf } from '../profiles/index.mjs';
31
+ import { countPaint } from './paint.mjs';
31
32
 
32
33
  function arg(name, fallback) {
33
34
  const i = process.argv.indexOf(`--${name}`);
@@ -57,6 +58,22 @@ const exclusions = loadExclusions(target, argAll('exclude'));
57
58
  const files = walkRepo(target, 14, exclusions);
58
59
  const profile = profileRepo(target, files);
59
60
  const { components } = harvestComponents(target, files.code);
61
+ // Repo kind and measurability, decided once in profiles/ and read everywhere
62
+ // (a published components package with no app pages is a LIBRARY; a stack the
63
+ // detector cannot read is NOT MEASURED, never scored as zeros). The decision
64
+ // lands on the profile with its evidence, so the JSON says why.
65
+ decideProfile(profile, components, files, target);
66
+ // A shadcn kitchen gets the 2 paint checks from shadcn's own agent rules,
67
+ // counted over own code only (never the kit's doors, never exempt files).
68
+ {
69
+ const P = profileOf(profile);
70
+ if (P.isShadcn) {
71
+ const doorFiles = new Set([...P.uiDirs.flatMap((d) => files.code.filter((f) => f.startsWith(`${d}/`))), ...(profile.shadcn.blockFiles ?? [])]);
72
+ const kitNames = new Set(components.filter((c) => doorFiles.has(c.file)).map((c) => c.name));
73
+ profile.shadcn.paint = countPaint(target, files.code, { uiDirs: [...P.uiDirs, ...(profile.shadcn.blockFiles ?? [])], kitNames });
74
+ }
75
+ }
76
+
60
77
  const tokens = harvestTokens(target, files.styles, files.code);
61
78
  const duplicates = findDuplicates(components, profile.uiDir, target);
62
79
  const context = harvestContext(target);
@@ -64,12 +81,6 @@ const staleRules = ruleStaleness(target, components,
64
81
  new Set(neverImportedComponents(components, profile.uiDir).map((c) => c.name)),
65
82
  [...files.code, ...files.styles, ...files.other]);
66
83
 
67
- // Repo kind and measurability, decided once in profiles/ and read everywhere
68
- // (a published components package with no app pages is a LIBRARY; a stack the
69
- // detector cannot read is NOT MEASURED, never scored as zeros). The decision
70
- // lands on the profile with its evidence, so the JSON says why.
71
- decideProfile(profile, components, files);
72
-
73
84
  // Orphans carry a receipt: the last time git saw anyone touch the file.
74
85
  // Only never-imported components are dated (the adoption map's evidence);
75
86
  // outside a git repo the field is simply absent and no claim is made.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Paint — the two shadcn checks shadcn's own agent rules name
3
+ * (skills/shadcn/rules/styling.md in shadcn-ui/ui):
4
+ *
5
+ * 1. Paint from a tin. A Tailwind palette colour in own code where a sheet
6
+ * row exists: `text-gray-500`, `bg-blue-100`, `text-emerald-600` on a
7
+ * status, and the manual evening override `dark:bg-gray-950`. 1 sin, 3
8
+ * spellings. The rule: "Semantic colors. Never `bg-blue-500`."
9
+ * 2. Doors repainted from outside. A colour or typography class passed into
10
+ * a kit component through className (`<Card className="bg-blue-100
11
+ * font-bold">`) where a variant or a sheet row was the intended route.
12
+ * The rule: "className for layout only."
13
+ *
14
+ * Both are counted over OWN code only: never inside the catalogue (those are
15
+ * kit doors, edited on purpose), never in exempt files (email, print,
16
+ * artwork, render-to-image), never under demo folders. Counts are reported
17
+ * per 100 own-code files so a 40-file starter and a 4,000-file product sit on
18
+ * one ruler. Receipts name the files and the exact classes.
19
+ */
20
+ import { join } from 'node:path';
21
+ import { readSource } from './walk.mjs';
22
+ import { exemptReason } from '../lib/exempt.mjs';
23
+ import { PALETTE } from '../profiles/shadcn-data.mjs';
24
+
25
+ // A palette utility with any variant prefix (hover:, md:, dark:, group-hover:)
26
+ // and any opacity suffix. Word boundary at the front stops `text-primary` and
27
+ // `bg-sidebar` from matching; the shade stops `border-gray` (no shade) which
28
+ // is not a Tailwind class.
29
+ const TIN_RE = new RegExp(`(?<![\\w-])(?:[\\w-]+:)*(?:bg|text|border|ring|outline|from|to|via|fill|stroke|divide|decoration|placeholder|caret|accent|shadow)-(?:${PALETTE})-(?:50|[1-9]00|950)(?:/\\d+)?(?![\\w-])`, 'g');
30
+ // Evening overrides painted by hand with white or black (the palette shades
31
+ // are already caught above with their dark: prefix).
32
+ const DARK_WB_RE = /(?<![\w-])dark:(?:bg|text|border)-(?:white|black)(?:\/\d+)?(?![\w-])/g;
33
+ // Colour or typography passed into a kit door: palette colour, black/white,
34
+ // weight, size.
35
+ const DOOR_CLASS_RE = new RegExp(`(?<![\\w-])(?:[\\w-]+:)*(?:(?:bg|text|border)-(?:${PALETTE}|white|black)(?:-(?:50|[1-9]00|950))?(?:/\\d+)?|font-(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black)|text-(?:xs|sm|base|lg|xl|[2-9]xl))(?![\\w-])`, 'g');
36
+
37
+ const DEMO_PATH_RE = /(^|\/)(stories|storybook|__stories__|examples?|demos?|templates?|playground|fixtures?|__tests__|__mocks__|e2e|cypress)\//i;
38
+
39
+ /**
40
+ * @param root repo root
41
+ * @param codeFiles relative code paths from the walk
42
+ * @param opts { uiDirs: string[], kitNames: Set<string> } — catalogue folders
43
+ * and the component names defined in them
44
+ */
45
+ export function countPaint(root, codeFiles, { uiDirs = [], kitNames = new Set() } = {}) {
46
+ const inCatalogue = (f) => uiDirs.some((d) => f === d || f.startsWith(`${d}/`));
47
+ const names = [...kitNames].filter((n) => /^[A-Z][A-Za-z0-9]*$/.test(n));
48
+ const doorOpen = names.length
49
+ ? new RegExp(`<(${names.join('|')})\\b[^>]*?className=(?:"([^"]*)"|\\{cn\\(\\s*["'\`]([^"'\`]*)["'\`])`, 'g')
50
+ : null;
51
+
52
+ let ownFiles = 0;
53
+ const tin = { uses: 0, files: 0, top: [], samples: new Map() };
54
+ const doors = { uses: 0, files: 0, top: [], samples: new Map() };
55
+ const bump = (bucket, file, count, sample) => {
56
+ bucket.uses += count; bucket.files += 1;
57
+ bucket.top.push({ file, count });
58
+ if (sample) bucket.samples.set(sample, (bucket.samples.get(sample) ?? 0) + count);
59
+ };
60
+
61
+ for (const f of codeFiles) {
62
+ if (!/\.(tsx|jsx)$/.test(f) || inCatalogue(f) || DEMO_PATH_RE.test(f)) continue;
63
+ const src = readSource(join(root, f));
64
+ if (src === null || exemptReason(f, src)) continue;
65
+ ownFiles += 1;
66
+
67
+ const tinHits = [...src.matchAll(TIN_RE)].map((m) => m[0]);
68
+ const wbHits = [...src.matchAll(DARK_WB_RE)].map((m) => m[0]);
69
+ const all = [...tinHits, ...wbHits];
70
+ if (all.length) {
71
+ const counts = new Map();
72
+ for (const c of all) counts.set(c, (counts.get(c) ?? 0) + 1);
73
+ const most = [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
74
+ bump(tin, f, all.length, null);
75
+ for (const [c, k] of counts) tin.samples.set(c, (tin.samples.get(c) ?? 0) + k);
76
+ tin.top.at(-1).sample = most;
77
+ }
78
+
79
+ if (doorOpen) {
80
+ let hits = 0; const seen = new Map();
81
+ for (const m of src.matchAll(doorOpen)) {
82
+ const cls = m[2] ?? m[3] ?? '';
83
+ const bad = cls.match(DOOR_CLASS_RE);
84
+ if (!bad) continue;
85
+ hits += 1;
86
+ const key = `<${m[1]} className="${bad.slice(0, 3).join(' ')}">`;
87
+ seen.set(key, (seen.get(key) ?? 0) + 1);
88
+ }
89
+ if (hits) {
90
+ bump(doors, f, hits, null);
91
+ for (const [k, v] of seen) doors.samples.set(k, (doors.samples.get(k) ?? 0) + v);
92
+ doors.top.at(-1).sample = [...seen.entries()].sort((a, b) => b[1] - a[1])[0][0];
93
+ }
94
+ }
95
+ }
96
+
97
+ const finish = (b) => ({
98
+ uses: b.uses,
99
+ files: b.files,
100
+ per100: ownFiles ? Math.round((b.uses / ownFiles) * 100) : 0,
101
+ top: b.top.sort((a, b2) => b2.count - a.count).slice(0, 8),
102
+ samples: [...b.samples.entries()].sort((a, b2) => b2[1] - a[1]).slice(0, 10).map(([value, count]) => ({ value, count })),
103
+ });
104
+ return { ownFiles, tin: finish(tin), doors: finish(doors) };
105
+ }
@@ -3,6 +3,7 @@
3
3
  * widened: the harvester must see EVERYTHING that styles the app — code,
4
4
  * stylesheets of any flavor, and config — not just a happy-path shadcn layout.
5
5
  */
6
+ import { CATALOGUE } from '../profiles/shadcn-data.mjs';
6
7
  import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
7
8
  import { join, relative, extname } from 'node:path';
8
9
 
@@ -204,14 +205,7 @@ export function profileRepo(root, files) {
204
205
 
205
206
  // Catalogue filenames as shipped by `shadcn add --all` (61 as of 2026-09).
206
207
  // Only used to recognise a vendored folder, never to judge one.
207
- const SHADCN_CATALOGUE = new Set(['accordion', 'alert-dialog', 'alert', 'aspect-ratio', 'attachment',
208
- 'avatar', 'badge', 'breadcrumb', 'bubble', 'button-group', 'button', 'calendar', 'card', 'carousel',
209
- 'chart', 'checkbox', 'collapsible', 'combobox', 'command', 'context-menu', 'dialog', 'direction',
210
- 'drawer', 'dropdown-menu', 'empty', 'field', 'hover-card', 'input-group', 'input-otp', 'input',
211
- 'item', 'kbd', 'label', 'marker', 'menubar', 'message-scroller', 'message', 'native-select',
212
- 'navigation-menu', 'pagination', 'popover', 'progress', 'questionnaire', 'radio-group', 'resizable',
213
- 'scroll-area', 'select', 'separator', 'sheet', 'sidebar', 'skeleton', 'slider', 'spinner', 'switch',
214
- 'table', 'tabs', 'textarea', 'toast', 'toggle-group', 'toggle', 'tooltip']);
208
+ const SHADCN_CATALOGUE = CATALOGUE;
215
209
  let catalogueNames = 0;
216
210
  if (uiDir) {
217
211
  try {
@@ -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.1.0';
4
+ export const VERSION = '7.2.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.
@@ -48,7 +48,7 @@ export function loadKnowledge(root) {
48
48
  const { components } = harvestComponents(root, files.code);
49
49
  // The MCP path used to skip the kind decision the CLI harvest makes, so a
50
50
  // library read as a product here. Same call, same answer, both doors.
51
- decideProfile(profile, components, files);
51
+ decideProfile(profile, components, files, root);
52
52
  const tokens = harvestTokens(root, files.styles, files.code);
53
53
  const duplicates = findDuplicates(components, profile.uiDir, root);
54
54
  const context = harvestContext(root);
@@ -19,11 +19,15 @@
19
19
  * Step 2 of the shadcn-profile plan: this move changes no number. Every
20
20
  * fixture's expected output is byte-identical before and after.
21
21
  */
22
+ import shadcn from './shadcn.mjs';
22
23
  import library from './library.mjs';
23
24
  import product from './product.mjs';
24
25
 
25
- // Specific kinds first; product is the fallback and must stay last.
26
- export const PROFILES = [library, product];
26
+ // Specific kinds first; product is the fallback and must stay last. The kind
27
+ // says how to READ the repo (shadcn: a kit over a sheet); the role says what
28
+ // usage MEANS (library: composition, not adoption). A shadcn library keeps
29
+ // both: kind shadcn, role library.
30
+ export const PROFILES = [shadcn, library, product];
27
31
 
28
32
  /**
29
33
  * Decide the repo's kind from the profiler's facts and what the scan found.
@@ -32,7 +36,7 @@ export const PROFILES = [library, product];
32
36
  * `componentDetection` (measurability), and the new `kind`, `kindConfidence`
33
37
  * and `kindEvidence` (the receipt: why this kind, in words).
34
38
  */
35
- export function decideProfile(profile, components, files) {
39
+ export function decideProfile(profile, components, files, root = null) {
36
40
  const reusable = components.filter((c) => !c.isPage);
37
41
  const counts = {
38
42
  reusable: reusable.length,
@@ -43,12 +47,14 @@ export function decideProfile(profile, components, files) {
43
47
  // riding in a library's monorepo (Siemens iX ships two), not a product.
44
48
  counts.fewPages = counts.pages <= counts.reusable * 0.05;
45
49
 
50
+ const ctx = { root, files };
46
51
  let picked = product, decision = null;
47
52
  for (const p of PROFILES) {
48
- const d = p.recognise(profile, counts);
53
+ const d = p.recognise(profile, counts, ctx);
49
54
  if (d) { picked = p; decision = d; break; }
50
55
  }
51
- profile.role = picked.kind;
56
+ // role is usage semantics, decided by the library rule whatever the kind
57
+ profile.role = library.recognise(profile, counts, ctx) ? 'library' : 'product';
52
58
  profile.kind = picked.kind;
53
59
  profile.kindConfidence = decision.confidence;
54
60
  profile.kindEvidence = decision.evidence;
@@ -75,11 +81,16 @@ export function decideProfile(profile, components, files) {
75
81
  export function profileOf(h) {
76
82
  const p = h?.profile ?? h ?? {};
77
83
  const kind = p.kind ?? p.role ?? 'product';
84
+ const role = p.role ?? (kind === 'library' ? 'library' : 'product');
78
85
  return {
79
86
  kind,
87
+ role,
80
88
  confidence: p.kindConfidence ?? null,
81
89
  evidence: p.kindEvidence ?? [],
82
- isLibrary: kind === 'library',
90
+ isLibrary: role === 'library',
91
+ isShadcn: kind === 'shadcn',
92
+ shadcn: p.shadcn ?? null,
93
+ uiDirs: p.uiDirs ?? (p.uiDir ? [p.uiDir] : []),
83
94
  // A vendored shadcn catalogue is stock on a shelf, not abandonment.
84
95
  vendoredUi: p.vendoredUi === true,
85
96
  uiDir: p.uiDir ?? null,
@@ -88,3 +99,14 @@ export function profileOf(h) {
88
99
  designSystem: p.designSystem ?? { kind: 'none' },
89
100
  };
90
101
  }
102
+
103
+ /**
104
+ * Question 6, per kind: ideals and fleet stats a profile owns until the
105
+ * benchmark slice for that kind exists. Overlaid on the general benchmark by
106
+ * score.mjs; the general profile owns none, so nothing moves for it.
107
+ */
108
+ export function profileYardstick(h) {
109
+ const kind = profileOf(h).kind;
110
+ const p = PROFILES.find((x) => x.kind === kind);
111
+ return { ideals: p?.ideals ?? {}, stats: p?.stats ?? {} };
112
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * shadcn facts the profile reads against. Everything here was read out of
3
+ * shadcn/ui's own source and docs (MIT, github.com/shadcn-ui/ui) on
4
+ * 2026-09-11 and out of tweakcn (Apache 2.0, github.com/jnsahaj/tweakcn).
5
+ * Data only: names, rows, values. No judgement lives in this file.
6
+ */
7
+
8
+ // Catalogue filenames as shipped by `shadcn add --all` (61 as of 2026-09),
9
+ // plus the older names still found in installs from before 2025. Used only to
10
+ // recognise a vendored folder, never to judge one.
11
+ export const CATALOGUE = new Set(['accordion', 'alert-dialog', 'alert', 'aspect-ratio', 'attachment',
12
+ 'avatar', 'badge', 'breadcrumb', 'bubble', 'button-group', 'button', 'calendar', 'card', 'carousel',
13
+ 'chart', 'checkbox', 'collapsible', 'combobox', 'command', 'context-menu', 'dialog', 'direction',
14
+ 'drawer', 'dropdown-menu', 'empty', 'field', 'hover-card', 'input-group', 'input-otp', 'input',
15
+ 'item', 'kbd', 'label', 'marker', 'menubar', 'message-scroller', 'message', 'native-select',
16
+ 'navigation-menu', 'pagination', 'popover', 'progress', 'questionnaire', 'radio-group', 'resizable',
17
+ 'scroll-area', 'select', 'separator', 'sheet', 'sidebar', 'skeleton', 'slider', 'spinner', 'switch',
18
+ 'table', 'tabs', 'textarea', 'toast', 'toggle-group', 'toggle', 'tooltip',
19
+ // older installs
20
+ 'form', 'sonner', 'toaster', 'use-toast']);
21
+
22
+ // Component files the block kits install into OWN code (login-form.tsx lands
23
+ // under the components alias, not under ui/). Kit doors stored outside the
24
+ // catalogue, judged like the catalogue.
25
+ export const BLOCK_COMPONENTS = new Set(['app-sidebar', 'calendars', 'chart-area-interactive',
26
+ 'data-table', 'date-picker', 'login-form', 'nav-actions', 'nav-documents', 'nav-favorites',
27
+ 'nav-main', 'nav-projects', 'nav-secondary', 'nav-user', 'nav-workspaces', 'prompt-input',
28
+ 'search-form', 'section-cards', 'settings-dialog', 'sidebar-left', 'sidebar-opt-in-form',
29
+ 'sidebar-right', 'signup-form', 'site-header', 'team-switcher', 'version-switcher']);
30
+
31
+ // The sheet: rows shadcn's theming docs define, each in :root and .dark.
32
+ export const SHADCN_ROWS = ['background', 'foreground', 'card', 'card-foreground', 'popover',
33
+ 'popover-foreground', 'primary', 'primary-foreground', 'secondary', 'secondary-foreground',
34
+ 'muted', 'muted-foreground', 'accent', 'accent-foreground', 'destructive', 'border', 'input',
35
+ 'ring', 'chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5', 'sidebar', 'sidebar-foreground',
36
+ 'sidebar-primary', 'sidebar-primary-foreground', 'sidebar-accent', 'sidebar-accent-foreground',
37
+ 'sidebar-border', 'sidebar-ring', 'radius'];
38
+
39
+ // Rows tweakcn adds to every theme it exports.
40
+ export const TWEAKCN_ROWS = ['destructive-foreground', 'font-sans', 'font-serif', 'font-mono',
41
+ 'shadow-2xs', 'shadow-xs', 'shadow-sm', 'shadow', 'shadow-md', 'shadow-lg', 'shadow-xl', 'shadow-2xl',
42
+ 'shadow-color', 'shadow-opacity', 'shadow-blur', 'shadow-spread', 'shadow-offset-x', 'shadow-offset-y',
43
+ 'tracking-tighter', 'tracking-tight', 'tracking-normal', 'tracking-wide', 'tracking-wider',
44
+ 'tracking-widest', 'spacing'];
45
+
46
+ // Rows the docs or the shipped agent skill mention beyond the two lists.
47
+ export const OTHER_KNOWN_ROWS = ['font-heading', 'surface', 'surface-foreground',
48
+ 'sidebar-background', 'destructive-foreground', 'radius-sm', 'radius-md', 'radius-lg',
49
+ 'radius-xl', 'radius-2xl', 'radius-3xl', 'radius-4xl'];
50
+
51
+ export const KNOWN_ROWS = new Set([...SHADCN_ROWS, ...TWEAKCN_ROWS, ...OTHER_KNOWN_ROWS]);
52
+
53
+ // Rows that only exist once (no evening value expected).
54
+ export const LIGHT_ONLY_ROWS = new Set(['radius', 'spacing', 'font-sans', 'font-serif', 'font-mono',
55
+ 'font-heading', 'tracking-tighter', 'tracking-tight', 'tracking-normal', 'tracking-wide',
56
+ 'tracking-wider', 'tracking-widest', 'radius-sm', 'radius-md', 'radius-lg', 'radius-xl',
57
+ 'radius-2xl', 'radius-3xl', 'radius-4xl', 'shadow-2xs', 'shadow-xs', 'shadow-sm', 'shadow',
58
+ 'shadow-md', 'shadow-lg', 'shadow-xl', 'shadow-2xl', 'shadow-color', 'shadow-opacity',
59
+ 'shadow-blur', 'shadow-spread', 'shadow-offset-x', 'shadow-offset-y']);
60
+
61
+ // The factory value of the gap multiplier. Changing it moves every gap in the
62
+ // kitchen at once; shadcn's own changelog (Rhea, 2026-05) says not to.
63
+ export const FACTORY_SPACING = '0.25rem';
64
+
65
+ // The kit vocabulary, from packages/shadcn/src/preset/preset.ts, with the
66
+ // legacy values still found in the fleet (slate and gray base colours, the
67
+ // pre-create fronts).
68
+ export const FRONTS = ['nova', 'vega', 'maia', 'lyra', 'mira', 'luma', 'sera', 'rhea'];
69
+ export const LEGACY_FRONTS = ['new-york', 'new-york-v4', 'default'];
70
+ export const BASES = ['base', 'radix', 'aria'];
71
+ export const BASE_COLORS = ['neutral', 'stone', 'zinc', 'gray', 'mauve', 'olive', 'mist', 'taupe', 'slate'];
72
+ export const ICON_LIBRARIES = ['lucide', 'hugeicons', 'tabler', 'phosphor', 'remixicon', 'radix'];
73
+ export const RADIUS_MAP = { '0': 'none', '0rem': 'none', '0px': 'none', '0.45rem': 'small',
74
+ '0.5rem': 'legacy default', '0.625rem': 'default', '0.875rem': 'large' };
75
+
76
+ // Light-mode `--primary` and `--chart-2` per named theme, from
77
+ // apps/v4/registry/themes.ts. The accent is matched by primary, the chart
78
+ // colour by chart-2. A sheet in the v3 hsl form never matches, by design.
79
+ export const THEMES = [
80
+ { name: 'neutral', primary: 'oklch(0.205 0 0)', chart2: 'oklch(0.556 0 0)' },
81
+ { name: 'stone', primary: 'oklch(0.216 0.006 56.043)', chart2: 'oklch(0.553 0.013 58.071)' },
82
+ { name: 'zinc', primary: 'oklch(0.21 0.006 285.885)', chart2: 'oklch(0.552 0.016 285.938)' },
83
+ { name: 'mauve', primary: 'oklch(0.212 0.019 322.12)', chart2: 'oklch(0.542 0.034 322.5)' },
84
+ { name: 'olive', primary: 'oklch(0.228 0.013 107.4)', chart2: 'oklch(0.58 0.031 107.3)' },
85
+ { name: 'mist', primary: 'oklch(0.218 0.008 223.9)', chart2: 'oklch(0.56 0.021 213.5)' },
86
+ { name: 'taupe', primary: 'oklch(0.214 0.009 43.1)', chart2: 'oklch(0.547 0.021 43.1)' },
87
+ { name: 'amber', primary: 'oklch(0.555 0.163 48.998)', chart2: 'oklch(0.769 0.188 70.08)' },
88
+ { name: 'blue', primary: 'oklch(0.488 0.243 264.376)', chart2: 'oklch(0.623 0.214 259.815)' },
89
+ { name: 'cyan', primary: 'oklch(0.52 0.105 223.128)', chart2: 'oklch(0.715 0.143 215.221)' },
90
+ { name: 'emerald', primary: 'oklch(0.508 0.118 165.612)', chart2: 'oklch(0.696 0.17 162.48)' },
91
+ { name: 'fuchsia', primary: 'oklch(0.518 0.253 323.949)', chart2: 'oklch(0.667 0.295 322.15)' },
92
+ { name: 'green', primary: 'oklch(0.527 0.154 150.069)', chart2: 'oklch(0.723 0.219 149.579)' },
93
+ { name: 'indigo', primary: 'oklch(0.457 0.24 277.023)', chart2: 'oklch(0.585 0.233 277.117)' },
94
+ { name: 'lime', primary: 'oklch(0.841 0.238 128.85)', chart2: 'oklch(0.768 0.233 130.85)' },
95
+ { name: 'orange', primary: 'oklch(0.553 0.195 38.402)', chart2: 'oklch(0.705 0.213 47.604)' },
96
+ { name: 'pink', primary: 'oklch(0.525 0.223 3.958)', chart2: 'oklch(0.656 0.241 354.308)' },
97
+ { name: 'purple', primary: 'oklch(0.496 0.265 301.924)', chart2: 'oklch(0.627 0.265 303.9)' },
98
+ { name: 'red', primary: 'oklch(0.505 0.213 27.518)', chart2: 'oklch(0.637 0.237 25.331)' },
99
+ { name: 'rose', primary: 'oklch(0.514 0.222 16.935)', chart2: 'oklch(0.645 0.246 16.439)' },
100
+ { name: 'sky', primary: 'oklch(0.5 0.134 242.749)', chart2: 'oklch(0.685 0.169 237.323)' },
101
+ { name: 'teal', primary: 'oklch(0.511 0.096 186.391)', chart2: 'oklch(0.704 0.14 182.503)' },
102
+ { name: 'violet', primary: 'oklch(0.491 0.27 292.581)', chart2: 'oklch(0.606 0.25 292.717)' },
103
+ { name: 'yellow', primary: 'oklch(0.681 0.162 75.834)', chart2: 'oklch(0.795 0.184 86.047)' },
104
+ ];
105
+
106
+ // Tailwind's named palette: paint from a tin when it appears in own code.
107
+ export const PALETTE = 'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose';
@@ -0,0 +1,267 @@
1
+ /**
2
+ * shadcn — a kitchen built from the shadcn kit.
3
+ *
4
+ * The team ordered a catalogue of components they are meant to edit, over a
5
+ * sheet of named finishes they are meant to change. The kit is recognised by
6
+ * its own marker file (components.json, at the root or in any workspace) and
7
+ * by the catalogue's door names; the kit's flavour is read the way shadcn's
8
+ * own `preset resolve` reads it; the sheet is read by row name. Everything
9
+ * this profile decides lands on `profile.shadcn` with its evidence.
10
+ *
11
+ * What it changes, and only this:
12
+ * - the catalogue folder(s) are found through the config's alias, not only
13
+ * at the three usual paths; installed blocks in own code are kit doors too
14
+ * - the sheet is the CSS file the config names, never a file chosen by
15
+ * counting colour literals
16
+ * - 2 checks from shadcn's own agent rules become tiles: paint from a tin,
17
+ * and kit doors repainted from outside (harvest/paint.mjs)
18
+ * - the sheet check is receipts: rows present for daylight and evening,
19
+ * custom rows, the gap multiplier
20
+ *
21
+ * What it does NOT do: compare doors to upstream drawings (editing is the
22
+ * intended use, and the drawings for the create-era fronts are generated,
23
+ * not stored), or add any "unless shadcn" clause to a counter.
24
+ *
25
+ * Kitchen words used in comments: kit, front (style), hinge system (base),
26
+ * door (component file), sheet (theme CSS), row (one --name: value).
27
+ */
28
+ import { existsSync, readFileSync } from 'node:fs';
29
+ import { join, dirname, basename } from 'node:path';
30
+ import { CATALOGUE, BLOCK_COMPONENTS, KNOWN_ROWS, TWEAKCN_ROWS, LIGHT_ONLY_ROWS, FACTORY_SPACING,
31
+ FRONTS, LEGACY_FRONTS, BASES, BASE_COLORS, ICON_LIBRARIES, RADIUS_MAP, THEMES, SHADCN_ROWS } from './shadcn-data.mjs';
32
+
33
+ const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
34
+ const read = (p) => { try { return readFileSync(p, 'utf8'); } catch { return ''; } };
35
+ const stripExt = (f) => f.replace(/\.[cm]?[jt]sx?$/, '');
36
+
37
+ /**
38
+ * Every folder in the walk holding 8+ catalogue door names AND the marks of
39
+ * shadcn-generated code in at least 3 of them: a headless base import
40
+ * (radix-ui, @radix-ui/react-*, @base-ui/react, react-aria-components), a
41
+ * `data-slot` attribute, or the cva + cn pair. Names alone are not enough:
42
+ * button, card, input and label are what anyone calls their components, and
43
+ * a hand-written library must never be handed the shadcn card.
44
+ */
45
+ const SHADCN_CODE_RE = /from ['"](?:radix-ui|@radix-ui\/react-[\w-]+|@base-ui\/react(?:\/[\w-]+)?|react-aria-components)['"]|data-slot=|cva\(/;
46
+ function catalogueSweep(root, files) {
47
+ const byDir = new Map();
48
+ for (const f of files.code) {
49
+ if (!/\.[jt]sx$/.test(f)) continue;
50
+ const d = dirname(f);
51
+ if (!CATALOGUE.has(stripExt(basename(f)))) continue;
52
+ const e = byDir.get(d) ?? { names: 0, marked: 0 };
53
+ e.names += 1;
54
+ if (e.marked < 3 && SHADCN_CODE_RE.test(read(join(root, f)))) e.marked += 1;
55
+ byDir.set(d, e);
56
+ }
57
+ return [...byDir.entries()].filter(([, e]) => e.names >= 8 && e.marked >= 3).map(([dir, e]) => ({ dir, catalogueNames: e.names }));
58
+ }
59
+
60
+ /** Resolve an alias like "@/components/ui" or "@acme/ui/components" to a folder under wsRoot, if it exists. */
61
+ function resolveAlias(root, wsRoot, alias) {
62
+ if (!alias) return null;
63
+ const rel = (p) => p.replace(/\\/g, '/').replace(/^\.\//, '');
64
+ // tsconfig paths in the workspace, then the root
65
+ for (const dir of [wsRoot, root]) {
66
+ const ts = readJSON(join(dir, 'tsconfig.json'));
67
+ const paths = ts?.compilerOptions?.paths ?? {};
68
+ const baseUrl = ts?.compilerOptions?.baseUrl ?? '.';
69
+ for (const [key, targets] of Object.entries(paths)) {
70
+ const stem = key.replace(/\/?\*$/, '');
71
+ if (alias !== stem && !alias.startsWith(`${stem}/`)) continue;
72
+ const tail = alias.slice(stem.length).replace(/^\//, '');
73
+ for (const t of targets) {
74
+ const cand = join(dir, baseUrl, rel(t).replace(/\/?\*$/, ''), tail);
75
+ if (existsSync(cand)) return cand;
76
+ }
77
+ }
78
+ }
79
+ // package.json#imports ("#components/*": "./src/components/*.tsx")
80
+ const pkg = readJSON(join(wsRoot, 'package.json'));
81
+ for (const [key, target] of Object.entries(pkg?.imports ?? {})) {
82
+ const stem = key.replace(/\/?\*$/, '');
83
+ if (typeof target !== 'string' || (alias !== stem && !alias.startsWith(`${stem}/`))) continue;
84
+ const tail = alias.slice(stem.length).replace(/^\//, '');
85
+ const cand = join(wsRoot, rel(target).replace(/\/?\*(\.\w+)?$/, ''), tail);
86
+ if (existsSync(cand)) return cand;
87
+ }
88
+ // the common shapes when no alias map resolves
89
+ const tail = alias.replace(/^[@~#]\/?/, '').replace(/^[^/]+\/ui\//, '');
90
+ for (const cand of [join(wsRoot, 'src', tail), join(wsRoot, tail), join(wsRoot, 'app', tail)]) {
91
+ if (existsSync(cand)) return cand;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /** Rows declared under a selector in a CSS text: name -> value. */
97
+ function rowsUnder(css, selector) {
98
+ const out = new Map();
99
+ const re = new RegExp(`(^|[\\s,}])${selector}\\s*\\{([^}]*)\\}`, 'g');
100
+ for (const m of css.matchAll(re)) {
101
+ for (const d of m[2].matchAll(/--([a-z0-9-]+)\s*:\s*([^;]+);/gi)) out.set(d[1], d[2].trim());
102
+ }
103
+ return out;
104
+ }
105
+
106
+ const norm = (v) => (v ?? '').replace(/\s+/g, ' ').trim();
107
+
108
+ /** Read the sheet: the CSS file the config names. */
109
+ function readSheet(root, wsRoot, cssPath) {
110
+ if (!cssPath) return null;
111
+ const file = [join(wsRoot, cssPath), join(wsRoot, 'src', cssPath)].find((p) => existsSync(p));
112
+ if (!file) return { file: cssPath, found: false };
113
+ const css = read(file);
114
+ const light = rowsUnder(css, ':root'), dark = rowsUnder(css, '\\.dark');
115
+ const themeInline = rowsUnder(css, '@theme\\s+inline');
116
+ const rows = [...light.keys()];
117
+ const known = rows.filter((r) => KNOWN_ROWS.has(r));
118
+ const custom = rows.filter((r) => !KNOWN_ROWS.has(r));
119
+ const missingDark = rows.filter((r) => KNOWN_ROWS.has(r) && !LIGHT_ONLY_ROWS.has(r) && !dark.has(r));
120
+ const customMissingDark = custom.filter((r) => !dark.has(r) && !/^(font|shadow|tracking|spacing|radius|ease|breakpoint)/.test(r));
121
+ const shadcnPresent = SHADCN_ROWS.filter((r) => light.has(r)).length;
122
+ const tweakcnPresent = TWEAKCN_ROWS.filter((r) => light.has(r) && !/^font-(sans|mono)$/.test(r)).length;
123
+ const hslEra = /--background:\s*\d+(\.\d+)?\s+\d+(\.\d+)?%\s+\d+(\.\d+)?%/.test(css);
124
+ const spacing = light.get('spacing') ?? null;
125
+ const themeVars = new Set(themeInline.keys());
126
+ // a custom colour row must be registered for Tailwind 4 to see it
127
+ const customUnregistered = themeVars.size
128
+ ? custom.filter((r) => !/^(font|shadow|tracking|spacing|radius|ease|breakpoint|typeset)/.test(r) && !themeVars.has(`color-${r}`))
129
+ : [];
130
+ return {
131
+ file: file.slice(root.length + 1), found: true, hslEra,
132
+ lightRows: rows.length, darkRows: dark.size, shadcnPresent, shadcnMissing: SHADCN_ROWS.filter((r) => !light.has(r)),
133
+ known: known.length, custom, missingDark, customMissingDark, customUnregistered, tweakcnPresent,
134
+ spacing, spacingChanged: spacing !== null && norm(spacing) !== FACTORY_SPACING,
135
+ radius: light.get('radius') ?? null,
136
+ primary: norm(light.get('primary')), chart2: norm(light.get('chart-2')), fontSans: light.get('font-sans') ?? null,
137
+ };
138
+ }
139
+
140
+ /** The kit's flavour, the way `shadcn preset resolve` reads it. */
141
+ function readKit(cfg, sheet, deps) {
142
+ const style = cfg.style ?? null;
143
+ let base = null, front = null;
144
+ if (style) {
145
+ const m = style.match(/^(base|radix|aria)-(.+)$/);
146
+ if (m) { base = m[1]; front = m[2]; }
147
+ else { front = style; base = LEGACY_FRONTS.includes(style) ? 'radix' : null; }
148
+ }
149
+ const fellBack = [];
150
+ const theme = THEMES.find((t) => t.primary === sheet?.primary)?.name ?? null;
151
+ const chartColor = THEMES.find((t) => t.chart2 === sheet?.chart2)?.name ?? null;
152
+ if (!theme) fellBack.push('theme');
153
+ if (!chartColor) fellBack.push('chartColor');
154
+ const radius = sheet?.radius ? (RADIUS_MAP[norm(sheet.radius)] ?? `custom (${norm(sheet.radius)})`) : null;
155
+ if (!radius) fellBack.push('radius');
156
+ const twRaw = deps?.tailwindcss ?? null;
157
+ const twClean = twRaw ? String(twRaw).replace(/^[\^~>=<\s]*/, '') : '';
158
+ const tailwind = /^\d/.test(twClean) ? twClean.split('.')[0] : null;
159
+ return {
160
+ style, front: front && (FRONTS.includes(front) || LEGACY_FRONTS.includes(front)) ? front : front,
161
+ frontKnown: front ? FRONTS.includes(front) || LEGACY_FRONTS.includes(front) : false,
162
+ base, baseKnown: base ? BASES.includes(base) : false,
163
+ baseColor: cfg.tailwind?.baseColor ?? null, baseColorKnown: BASE_COLORS.includes(cfg.tailwind?.baseColor),
164
+ cssVariables: cfg.tailwind?.cssVariables !== false,
165
+ iconLibrary: cfg.iconLibrary ?? null, iconLibraryKnown: ICON_LIBRARIES.includes(cfg.iconLibrary),
166
+ rtl: cfg.rtl === true, menuAccent: cfg.menuAccent ?? null, menuColor: cfg.menuColor ?? null,
167
+ theme, chartColor, radius, tailwind, fellBack,
168
+ };
169
+ }
170
+
171
+ export default {
172
+ kind: 'shadcn',
173
+
174
+ /**
175
+ * @param profile the profiler's facts (mutated: uiDir, uiDirs, vendoredUi, designSystem, shadcn)
176
+ * @param counts reusable/pages/codeFiles
177
+ * @param ctx { root, files } the walk
178
+ */
179
+ recognise(profile, counts, ctx) {
180
+ if (!ctx?.root || !ctx?.files) return null;
181
+ const { root, files } = ctx;
182
+ const evidence = [];
183
+
184
+ // 1. marker files, root or any workspace
185
+ const configs = (files.other ?? []).filter((f) => basename(f) === 'components.json')
186
+ .map((f) => ({ file: f, cfg: readJSON(join(root, f)) }))
187
+ .filter((c) => c.cfg && (c.cfg.aliases || c.cfg.style !== undefined || c.cfg.tailwind));
188
+
189
+ // 2. catalogues: through each config's alias, then a sweep for door names
190
+ const installs = [];
191
+ for (const { file, cfg } of configs) {
192
+ const wsRoot = join(root, dirname(file));
193
+ const uiAlias = cfg.aliases?.ui ?? (cfg.aliases?.components ? `${cfg.aliases.components}/ui` : '@/components/ui');
194
+ let uiAbs = resolveAlias(root, wsRoot, uiAlias);
195
+ let catalogueNames = 0;
196
+ if (uiAbs) {
197
+ const rel = uiAbs.slice(root.length + 1).replace(/\\/g, '/');
198
+ catalogueNames = files.code.filter((f) => f.startsWith(`${rel}/`) && CATALOGUE.has(stripExt(basename(f)))).length;
199
+ uiAbs = rel;
200
+ }
201
+ const pkg = readJSON(join(wsRoot, 'package.json')) ?? readJSON(join(root, 'package.json')) ?? {};
202
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
203
+ const sheet = readSheet(root, wsRoot, cfg.tailwind?.css);
204
+ installs.push({ config: file, uiDir: uiAbs, catalogueNames, kit: readKit(cfg, sheet, deps), sheet });
205
+ }
206
+ const swept = catalogueSweep(root, files);
207
+ for (const s of swept) {
208
+ if (!installs.some((i) => i.uiDir === s.dir)) installs.push({ config: null, uiDir: s.dir, catalogueNames: s.catalogueNames, kit: null, sheet: null });
209
+ else { const i = installs.find((x) => x.uiDir === s.dir); i.catalogueNames = Math.max(i.catalogueNames, s.catalogueNames); }
210
+ }
211
+
212
+ // 3. the decision: a marker plus a folder, or a folder that is plainly the
213
+ // catalogue by name (8+). Same bar the profiler used for vendoredUi.
214
+ const withConfigAndDir = installs.filter((i) => i.config && i.uiDir);
215
+ const byName = installs.filter((i) => i.catalogueNames >= 8);
216
+ if (!withConfigAndDir.length && !byName.length) return null;
217
+
218
+ installs.sort((a, b) => b.catalogueNames - a.catalogueNames || (b.config ? 1 : 0) - (a.config ? 1 : 0));
219
+ const primary = installs[0];
220
+ // the kit is read from a config; when the biggest catalogue has none
221
+ // (a shared package found by sweep), the nearest configured install speaks
222
+ const configured = installs.find((i) => i.kit) ?? null;
223
+ const kit = primary.kit ?? configured?.kit ?? null;
224
+ const sheet = primary.sheet ?? configured?.sheet ?? null;
225
+ const confidence = primary.config && primary.catalogueNames >= 8 ? 'high'
226
+ : primary.catalogueNames >= 8 || (primary.config && primary.uiDir) ? 'medium' : 'low';
227
+
228
+ for (const i of installs) {
229
+ if (i.config) evidence.push(`components.json in ${dirname(i.config) === '.' ? 'the root' : dirname(i.config)}${i.uiDir ? `, ${i.catalogueNames} catalogue component${i.catalogueNames === 1 ? '' : 's'} in ${i.uiDir}` : ', no catalogue folder found through its alias'}`);
230
+ else evidence.push(`${i.catalogueNames} catalogue components in ${i.uiDir}, no components.json`);
231
+ }
232
+ if (kit?.style) evidence.push(`style ${kit.style}${kit.baseColor ? `, base colour ${kit.baseColor}` : ''}${kit.tailwind ? `, Tailwind ${kit.tailwind}` : ''}`);
233
+
234
+ // 4. write the facts every consumer reads
235
+ profile.uiDirs = installs.map((i) => i.uiDir).filter(Boolean);
236
+ profile.uiDir = primary.uiDir ?? profile.uiDir ?? null;
237
+ profile.vendoredUi = true;
238
+ const cssVars = kit ? kit.cssVariables : true;
239
+ profile.designSystem = { kind: 'shadcn', name: 'shadcn/ui', confidence: 'high', cssVariables: cssVars };
240
+ // kit doors installed as blocks into own code
241
+ const blockFiles = files.code.filter((f) => /\.[jt]sx$/.test(f) && !profile.uiDirs.some((d) => f.startsWith(`${d}/`)) && BLOCK_COMPONENTS.has(stripExt(basename(f))));
242
+ profile.shadcn = {
243
+ installs: installs.map((i) => ({ config: i.config, uiDir: i.uiDir, catalogueNames: i.catalogueNames })),
244
+ kit,
245
+ sheet,
246
+ blockFiles,
247
+ };
248
+ return { confidence, evidence };
249
+ },
250
+
251
+ /**
252
+ * Question 6: the ideal for this kind. Per 100 own-code files. The numbers
253
+ * come from the 2026-09-11 fleet probe over the 15 shadcn kitchens in the
254
+ * benchmark (docs: shadcn-step1-fleet-probe): the ideal is the tidiest third,
255
+ * the stats are the whole set so the "cleaner than" line is honest. The
256
+ * step 4 slice replaces these with a rebuilt ruler.
257
+ */
258
+ ideals: {
259
+ paintTin: { value: 25, note: 'palette colours per 100 own-code files; the tidiest third of 15 shadcn repos sit under this' },
260
+ doorOverrides: { value: 15, note: 'kit components repainted through className, per 100 own-code files; the tidiest third sit under this' },
261
+ },
262
+ // the 15 shadcn kitchens in the benchmark fleet, 2026-09-11, per 100 own-code files
263
+ stats: {
264
+ paintTin: { values: [5, 8, 20, 21, 22, 32, 33, 58, 62, 66, 100, 179, 296, 488, 658], median: 62 },
265
+ doorOverrides: { values: [0, 3, 5, 14, 16, 16, 16, 17, 22, 26, 28, 31, 40, 44, 55], median: 22 },
266
+ },
267
+ };
@@ -163,6 +163,28 @@ if (neverImported.length >= 3) {
163
163
  rule(`The repo uses ${faces.length} typeface${faces.length === 1 ? '' : 's'}: ${faces.slice(0, 4).join(', ')}. Do not introduce another, and do not re-declare font stacks by hand; use the existing setup.`);
164
164
  }
165
165
 
166
+ // ---------- shadcn: the kit's own rules ----------
167
+ // Written from shadcn's docs and the agent skill it ships (skills/shadcn in
168
+ // shadcn-ui/ui), with this repo's receipts where the scan has them.
169
+ const P = profileOf(h);
170
+ if (P.isShadcn) {
171
+ const sc = P.shadcn ?? {};
172
+ const sheetFile = sc.sheet?.found ? sc.sheet.file : null;
173
+ const paint = sc.paint ?? null;
174
+ section('shadcn: the components and the theme');
175
+ 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.`);
176
+ 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.');
177
+ if (paint?.tin?.uses) lines.push(` (${paint.tin.uses} palette colour${paint.tin.uses === 1 ? '' : 's'} already sit in own code, ${paint.tin.samples.slice(0, 3).map((s) => `\`${s.value}\` ×${s.count}`).join(', ')}; do not add to them.)`);
178
+ rule('Before adding classes to a shadcn component, use one of its variants (`variant="outline"`, `size="sm"`). `className` on a shadcn component is for layout only: width, margin, position. Never colour, never typography.');
179
+ if (paint?.doors?.uses) lines.push(` (${paint.doors.uses} shadcn component${paint.doors.uses === 1 ? '' : 's'} already restyled through className, like ${paint.doors.samples.slice(0, 2).map((s) => `\`${s.value}\``).join(' and ')}; do not add to them.)`);
180
+ rule(`Edit the component you own in \`${P.uiDir ?? 'components/ui'}\`. Never build a second one beside it under another name. A wrapper that composes shadcn components is fine; a second implementation is not.`);
181
+ rule('Merge classes with `cn()`. Never concatenate strings and never write a ternary inside a className string.');
182
+ rule('Add a component with `npx shadcn@latest add <name>`, then edit it. To see what changed upstream, run `npx shadcn@latest add <name> --diff`.');
183
+ rule('Radius comes from the `--radius` variable and the scale derived from it; never a bracket value. Never change `--spacing`: it resizes every gap in the app.');
184
+ if (sc.sheet?.customUnregistered?.length) rule(`Custom variables must be registered where Tailwind can see them: ${sc.sheet.customUnregistered.slice(0, 3).map((r) => `\`--${r}\``).join(', ')} ${sc.sheet.customUnregistered.length === 1 ? 'is' : 'are'} defined but never mapped in \`@theme inline\`, so \`bg-${sc.sheet.customUnregistered[0]}\` does nothing.`);
185
+ rule('Small habits shadcn expects: `gap-*` not `space-x/y-*`; `size-4` not `w-4 h-4`; `truncate` for one-line clipping; no `z-index` on Dialog, Sheet, Popover or Tooltip, they stack themselves; icons from the project\'s own icon library only.');
186
+ }
187
+
166
188
  // ---------- styling discipline ----------
167
189
  section('Styling discipline');
168
190
  rule(`Never write ${reusable.length ? '`style={{ ... }}`' : 'inline `style="..."` attributes'} for static values; styling belongs to classes and tokens where the system can see it.`);