roast-my-design-system 9.4.0 → 9.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
@@ -142,11 +142,11 @@ Eleven reports, hosted exactly as the tool writes them. Every number determinist
142
142
 
143
143
  The full report for vercel/ai-chatbot. The verdict answers what the agent will learn here, the gap section comes first, then "What the repo teaches the agent", Claude's read of the scan:
144
144
 
145
- ![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 where the agent will have to guess, the fixes you can make right now, each with what it is worth and 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](assets/report-full-dark.png?v=9.3.3)
145
+ ![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 where the agent will have to guess, the fixes you can make right now, each with what it is worth and 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](assets/report-full-dark.png?v=9.6.0)
146
146
 
147
147
  The same report in light mode (one file, built-in toggle):
148
148
 
149
- ![The diagnosis report in light mode](assets/report-light-hero.png?v=9.3.3)
149
+ ![The diagnosis report in light mode](assets/report-light-hero.png?v=9.6.0)
150
150
 
151
151
  ## Why the numbers hold
152
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "9.4.0",
3
+ "version": "9.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 112 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,7 +32,7 @@ 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, scoreBreakdown, scorePackage as scorePackageOf, ZERO_IDEAL, WARN_TOLERANCE, SCORE_OF, SCHEMA_VERSION } from './score.mjs';
35
- import { ownSpacing, profileOf, installedDirs, splitArbitrary } from '../profiles/index.mjs';
35
+ import { ownSpacing, profileOf, installedOf, splitArbitrary, installedByOwner } from '../profiles/index.mjs';
36
36
  import { KITS } from '../profiles/kit-common.mjs';
37
37
  import { rulesOn as lintRulesOn, describeRule as lintDescribe, entryMatcher as lintMatcher, RULE_TILE as LINT_TILE } from '../profiles/shadcn-lint.mjs';
38
38
  import { publishesLine } from '../profiles/registry.mjs';
@@ -257,7 +257,7 @@ const P = profileOf(h);
257
257
  const fresh = P.isShadcn && P.shadcn?.fresh?.fresh === true;
258
258
  // On a shadcn repo the bracket list is the team's own; installed brackets are
259
259
  // named in the side panel instead (see coreMetrics for the count).
260
- if (P.isShadcn) arbitrary = splitArbitrary(arbitrary, installedDirs(P)).own;
260
+ if (P.isShadcn) arbitrary = splitArbitrary(arbitrary, installedOf(P)).own;
261
261
  const breakdown = scoreBreakdown(h, bench);
262
262
  const neverImported = neverImportedComponents(h.components, P.uiDir);
263
263
 
@@ -1632,11 +1632,26 @@ function sidePanel() {
1632
1632
  function exceptionsBlock() {
1633
1633
  const lines = [];
1634
1634
  if (P.isShadcn) {
1635
- const ai = P.shadcn?.arbitraryInstalled;
1636
- if (ai?.uses) lines.push(`${n(ai.uses)} bracket value${ai.uses === 1 ? '' : 's'} inside ${esc(P.uiDirs.map((d) => basename(d)).join(', '))} are shadcn's own (${ai.values.slice(0, 3).map((v) => esc(v.value)).join(', ')}) and are not counted. shadcn's docs allow brackets for one-off values, so your agent will treat them as normal; the rules file tells it otherwise for your own code.`);
1637
- const si = spacingOwn.installed;
1638
- const sv = si.values.length;
1639
- if (sv) lines.push(`${n(sv)} off-scale spacing value${sv === 1 ? '' : 's'} inside ${esc(P.uiDirs.map((d) => basename(d)).join(', '))} ${sv === 1 ? 'is' : 'are'} shadcn's own (${si.values.slice(0, 3).map((v) => esc(v.value)).join(', ')}) and ${sv === 1 ? 'is' : 'are'} not counted, for the same reason as the bracket values.`);
1635
+ // Installed values by owner (9.6.0, words approved by Greg 2026-10-01):
1636
+ // shadcn's components and kit blocks, or a registry by name. Until then
1637
+ // every value was called shadcn's and placed "inside ui", wherever it sat.
1638
+ const andList = (xs) => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}`);
1639
+ const ownersOf = (entries) => installedByOwner(entries, P);
1640
+ const where = (o) => (o.kind === 'registry'
1641
+ ? `the ${esc(o.name)} registry`
1642
+ : andList([...o.folders.map(esc), ...(o.blocks.length ? [`the ${andList(o.blocks.map(esc))} block${o.blocks.length === 1 ? '' : 's'}`] : [])]));
1643
+ const top3 = (o) => o.values.slice(0, 3).map((v) => esc(v.value)).join(', ');
1644
+ for (const o of ownersOf(h.tokens?.tailwind?.arbitrary ?? [])) {
1645
+ const one = o.uses === 1;
1646
+ lines.push(o.kind === 'registry'
1647
+ ? `${n(o.uses)} bracket value${one ? '' : 's'} inside ${where(o)} ${one ? 'is' : 'are'} the registry's own (${top3(o)}) and ${one ? 'is' : 'are'} not counted. ${one ? 'It was' : 'They were'} written for those components, not as a pattern for your own code.`
1648
+ : `${n(o.uses)} bracket value${one ? '' : 's'} inside ${where(o)} ${one ? 'is' : 'are'} shadcn's own (${top3(o)}) and ${one ? 'is' : 'are'} not counted. shadcn's docs allow brackets for one-off values, so your agent will treat them as normal; the rules file tells it otherwise for your own code.`);
1649
+ }
1650
+ for (const o of ownersOf([...(h.tokens?.spacing ?? []), ...(h.tokens?.tailwind?.spacing ?? []).filter((v) => v.value.startsWith('['))])) {
1651
+ const sv = o.values.length;
1652
+ const one = sv === 1;
1653
+ lines.push(`${n(sv)} off-scale spacing value${one ? '' : 's'} inside ${where(o)} ${one ? 'is' : 'are'} ${o.kind === 'registry' ? "the registry's own" : "shadcn's own"} (${top3(o)}) and ${one ? 'is' : 'are'} not counted, for the same reason as the bracket values.`);
1654
+ }
1640
1655
  if (P.isRegistry && P.registry) {
1641
1656
  const r = P.registry;
1642
1657
  const sc = r.showcase ?? [];
@@ -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, installedDirs, splitArbitrary, ownSpacing } from '../profiles/index.mjs';
29
+ import { profileOf, installedOf, splitArbitrary, ownSpacing } from '../profiles/index.mjs';
30
30
 
31
31
  export { SCHEMA_VERSION };
32
32
 
@@ -146,7 +146,7 @@ export function coreMetrics(h, opts = {}) {
146
146
  const P = profileOf(h);
147
147
  // On a shadcn repo the bracket count is the team's own: brackets inside the
148
148
  // catalogue, kit blocks and registries are installed choices, not drift.
149
- const arbitraryEntries = P.isShadcn ? splitArbitrary(h.tokens?.tailwind?.arbitrary ?? [], installedDirs(P)).own : (h.tokens?.tailwind?.arbitrary ?? []);
149
+ const arbitraryEntries = P.isShadcn ? splitArbitrary(h.tokens?.tailwind?.arbitrary ?? [], installedOf(P)).own : (h.tokens?.tailwind?.arbitrary ?? []);
150
150
  // ownCode: the breakdown under the score. Same metrics, paint counted on
151
151
  // own code only, so the difference is what installed registries cost.
152
152
  const paint = P.isTailwind ? P.tailwind?.paint
@@ -29,9 +29,9 @@ import { ruleStaleness } from '../lib/staleness.mjs';
29
29
  import { neverImportedComponents } from '../lib/neverimported.mjs';
30
30
  import { lastTouchedDates } from '../lib/lasttouched.mjs';
31
31
  import { SCHEMA_VERSION } from '../lib/version.mjs';
32
- import { decideProfile, decideFresh, profileOf, installedDirs, splitArbitrary, scopeFiles } from '../profiles/index.mjs';
32
+ import { decideProfile, decideFresh, profileOf, installedOf, doorFile, installedFile, splitArbitrary, scopeFiles } from '../profiles/index.mjs';
33
33
  import { publishesLine } from '../profiles/registry.mjs';
34
- import { countPaint } from './paint.mjs';
34
+ import { countPaint, doorShades } from './paint.mjs';
35
35
 
36
36
  function arg(name, fallback) {
37
37
  const i = process.argv.indexOf(`--${name}`);
@@ -120,17 +120,18 @@ if (profileOf(profile).isRegistry) {
120
120
  const P = profileOf(profile);
121
121
  if (P.isShadcn) {
122
122
  const regDirs = profile.shadcn.registryDirs ?? [];
123
- const blocks = profile.shadcn.blockFiles ?? [];
124
- const allInstalled = installedDirs(P);
125
- const doorFiles = new Set(allInstalled.flatMap((d) => files.code.filter((f) => f === d || f.startsWith(`${d}/`))));
123
+ // installed code, file by file (profiles/installed.mjs): a component of
124
+ // the team's own kept in the catalogue folder is own code (9.5.0)
125
+ const installed = installedOf(P);
126
+ const doorFiles = new Set(files.code.filter(installed));
126
127
  const kitNames = new Set(components.filter((c) => doorFiles.has(c.file)).map((c) => c.name));
127
128
  // The score reads the repo as the agent meets it: own code plus installed
128
129
  // registries, because a palette colour in ai-elements teaches the same
129
- // wrong lesson as one in own code. The catalogue and kit blocks stay out:
130
- // editing them is the intended use.
131
- profile.shadcn.paint = countPaint(target, files.code, { uiDirs: [...P.uiDirs, ...blocks], kitNames, email });
130
+ // wrong lesson as one in own code. shadcn's own components and kit blocks
131
+ // stay out: editing them is the intended use.
132
+ profile.shadcn.paint = countPaint(target, files.code, { isDoor: (f) => doorFile(P, f), kitNames, email });
132
133
  // Own code alone, for the breakdown line under the score.
133
- profile.shadcn.paintOwn = regDirs.length ? countPaint(target, files.code, { uiDirs: allInstalled, kitNames, email }) : profile.shadcn.paint;
134
+ profile.shadcn.paintOwn = regDirs.length ? countPaint(target, files.code, { isDoor: installed, kitNames, email }) : profile.shadcn.paint;
134
135
  if (regDirs.length) {
135
136
  const regFiles = files.code.filter((f) => regDirs.some((d) => f.startsWith(`${d}/`)));
136
137
  const rp = countPaint(target, regFiles, { uiDirs: [], kitNames, email });
@@ -138,7 +139,18 @@ if (profileOf(profile).isRegistry) {
138
139
  }
139
140
  // Bracket values inside installed code are shadcn's (or a registry's)
140
141
  // choices: a true lesson, badly framed. Kept out of the count, named.
141
- const split = splitArbitrary(tokens.tailwind?.arbitrary ?? [], allInstalled);
142
+ // Utility-class mode (9.5.0), for the rules file only: the shades shadcn's
143
+ // own components paint with, and, where the repo keeps a theme of its
144
+ // own, the palette classes in own code that theme does not own.
145
+ if (P.designSystem?.cssVariables === false) {
146
+ profile.shadcn.doorShades = doorShades(target, files.code.filter((f) => /\.(tsx|jsx)$/.test(f) && doorFile(P, f)));
147
+ const own = profile.shadcn.ownTheme;
148
+ if (own) {
149
+ const p = countPaint(target, files.code, { isDoor: installed, retuned: own.retuned, families: own.families, email });
150
+ profile.shadcn.ownThemePaint = { uses: p.tin.uses, samples: p.tin.samples.slice(0, 6) };
151
+ }
152
+ }
153
+ const split = splitArbitrary(tokens.tailwind?.arbitrary ?? [], installed);
142
154
  profile.shadcn.arbitraryInstalled = split.installed;
143
155
  // A fresh kit (the install command's output, nothing built yet) is
144
156
  // decided here, once, from the facts above.
@@ -162,8 +174,8 @@ const context = harvestContext(target);
162
174
  // The chart palette the repo keeps, or the charts painting by hand without
163
175
  // one, and the gaps an agent will fill by inventing (lib/charts, lib/gaps).
164
176
  const charts = (() => {
165
- const installed = installedDirs(profileOf(profile));
166
- const own = (f) => !installed.some((d) => f === d || f.startsWith(`${d}/`));
177
+ const P = profileOf(profile);
178
+ const own = (f) => !installedFile(P, f);
167
179
  return chartSystemOf(files, (f) => readSource(resolve(target, f)), { own });
168
180
  })();
169
181
  const gaps = designGaps({ charts, tokenFile: tokens.tokenFile ?? null });
@@ -60,11 +60,13 @@ export const DEMO_PATH_RE = /(^|\/)(stories|storybook|__stories__|examples?|demo
60
60
  /**
61
61
  * @param root repo root
62
62
  * @param codeFiles relative code paths from the walk
63
- * @param opts { uiDirs: string[], kitNames: Set<string> } — catalogue folders
63
+ * @param opts { uiDirs: string[], kitNames: Set<string> } — catalogue folders;
64
+ * `isDoor(file)` in place of uiDirs decides file by file (9.5.0: a team
65
+ * component kept in the catalogue folder is own code, profiles/installed.mjs)
64
66
  * and the component names defined in them
65
67
  */
66
- export function countPaint(root, codeFiles, { uiDirs = [], kitNames = new Set(), retuned = [], families = null, email = null } = {}) {
67
- const inCatalogue = (f) => uiDirs.some((d) => f === d || f.startsWith(`${d}/`));
68
+ export function countPaint(root, codeFiles, { uiDirs = [], isDoor = null, kitNames = new Set(), retuned = [], families = null, email = null } = {}) {
69
+ const inCatalogue = isDoor ?? ((f) => uiDirs.some((d) => f === d || f.startsWith(`${d}/`)));
68
70
  const names = [...kitNames].filter((n) => /^[A-Z][A-Za-z0-9]*$/.test(n));
69
71
  const doorOpen = names.length
70
72
  ? new RegExp(`<(${names.join('|')})\\b[^>]*?className=(?:"([^"]*)"|\\{cn\\(\\s*["'\`]([^"'\`]*)["'\`])`, 'g')
@@ -141,3 +143,22 @@ export function countPaint(root, codeFiles, { uiDirs = [], kitNames = new Set(),
141
143
  });
142
144
  return { ownFiles, tin: finish(tin), doors: finish(doors), typo: finish(typo) };
143
145
  }
146
+
147
+ /**
148
+ * The palette shades shadcn's own components paint with, most used first,
149
+ * with variant prefixes and opacity dropped (hover:bg-zinc-900/90 is
150
+ * bg-zinc-900). On a utility-class install these are the system: the rules
151
+ * file tells the agent to stay with them (9.5.0).
152
+ */
153
+ export function doorShades(root, doorFiles) {
154
+ const tally = new Map();
155
+ for (const f of doorFiles) {
156
+ const raw = readSource(join(root, f));
157
+ if (raw === null) continue;
158
+ for (const m of blankComments(raw).matchAll(PALETTE_CLASS_RE)) {
159
+ const cls = m[0].replace(/^(?:[\w-]+:)*/, '').replace(/\/\d+$/, '');
160
+ tally.set(cls, (tally.get(cls) ?? 0) + 1);
161
+ }
162
+ }
163
+ return [...tally.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 8).map(([value, count]) => ({ value, count }));
164
+ }
@@ -167,15 +167,69 @@ export function arbitraryLengths(cls) {
167
167
  return out;
168
168
  }
169
169
 
170
- /** Extract string contents of className=/class= attributes + template classes. */
171
- function classStrings(src) {
170
+ /**
171
+ * Class strings with where each one starts in the source: className="…",
172
+ * className='…' and className={`…`}, then the string arguments of cva(),
173
+ * cn(), clsx(), classnames() and twMerge(). One reader for the report and
174
+ * the live checks (9.6.0): until then the live checks read className only,
175
+ * so cn("text-[13px]") was counted by the report and never flagged on an
176
+ * edit or a pull request (3,794 values in 66 fleet repos, 2026-10-01). The
177
+ * reading itself is unchanged, the report's: a call is read up to its first
178
+ * closing bracket, so the report counts exactly what it counted before.
179
+ */
180
+ export function classStringSpans(src) {
172
181
  const out = [];
173
182
  for (const m of src.matchAll(/class(?:Name)?\s*=\s*(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`\})/g)) {
174
- out.push(m[1] ?? m[2] ?? m[3] ?? '');
183
+ const text = m[1] ?? m[2] ?? m[3] ?? '';
184
+ // the text ends just before the closing quote, or before `} for a template
185
+ out.push({ text, index: m.index + m[0].length - (m[3] !== undefined ? 2 : 1) - text.length });
175
186
  }
176
187
  // cva()/cn()/clsx() string args also carry classes
177
188
  for (const m of src.matchAll(/\b(?:cva|cn|clsx|classnames|twMerge)\s*\(([\s\S]{0,2000}?)\)/g)) {
178
- for (const s of m[1].matchAll(/["'`]([^"'`]+)["'`]/g)) out.push(s[1]);
189
+ const argsAt = m.index + m[0].length - 1 - m[1].length;
190
+ for (const s of m[1].matchAll(/["'`]([^"'`]+)["'`]/g)) out.push({ text: s[1], index: argsAt + s.index + 1 });
191
+ }
192
+ return out;
193
+ }
194
+ /** Extract string contents of className=/class= attributes + template classes. */
195
+ function classStrings(src) {
196
+ return classStringSpans(src).map((s) => s.text);
197
+ }
198
+
199
+ /**
200
+ * What the class strings in a piece of code carry, each value placed where
201
+ * it sits: bracket colours, spacing brackets (p-[13px] is off-scale spacing,
202
+ * not also an arbitrary value) and bracket lengths. A value read twice (a
203
+ * cn() inside a className template) is kept once. Exported for the guard,
204
+ * which reads class strings on the whole file: a cn() call usually runs over
205
+ * several lines, and a line read on its own never shows the call it is in.
206
+ * @returns { colors, spacing, arbitrary } each [{ value, index }]
207
+ */
208
+ export function classStringStyling(src) {
209
+ const out = { colors: [], spacing: [], arbitrary: [] };
210
+ const seen = new Set();
211
+ const once = (kind, index) => { const k = `${kind}${index}`; if (seen.has(k)) return false; seen.add(k); return true; };
212
+ for (const { text: cls, index: at } of classStringSpans(src)) {
213
+ for (const c of cls.matchAll(TW_COLOR_RE)) {
214
+ // the colour itself, not the class: the raw-hex pass in extractStyling
215
+ // dedupes by position
216
+ if (!c[1]) continue;
217
+ const i = at + c.index + c[0].indexOf(c[1]);
218
+ if (once('c', i)) out.colors.push({ value: c[1].startsWith('#') ? normalizeHex(c[1]) : c[1], index: i });
219
+ }
220
+ for (const a of arbitraryLengths(cls)) {
221
+ const i = at + a.index;
222
+ if (once('a', i)) out.arbitrary.push({ value: a.value, index: i });
223
+ }
224
+ // A bracket on a spacing utility (p-[13px]) is an off-scale spacing value
225
+ // and is judged by the spacing rule, not as an arbitrary bracket as well:
226
+ // one class, one finding, the same split the harvest makes.
227
+ for (const c of cls.matchAll(TW_SPACING_RE)) {
228
+ const inner = c[1].startsWith('[') ? c[1].slice(1, -1) : null;
229
+ if (!inner || !/^-?\d*\.?\d+(?:px|rem|em|%|vh|vw|ch)$/.test(inner)) continue;
230
+ const i = at + c.index;
231
+ if (once('s', i)) out.spacing.push({ value: inner, index: i });
232
+ }
179
233
  }
180
234
  return out;
181
235
  }
@@ -258,24 +312,15 @@ export function extractStyling(src, { css = false } = {}) {
258
312
  }
259
313
  return out;
260
314
  }
261
- // component code: class strings, inline style blocks, raw hex
262
- for (const m of src.matchAll(/class(?:Name)?\s*=\s*(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`\})/g)) {
263
- const cls = m[1] ?? m[2] ?? m[3] ?? '';
264
- for (const c of cls.matchAll(TW_COLOR_RE)) {
265
- // Index the colour itself, not the attribute: the raw-hex pass below
266
- // dedupes by position, and the attribute's start was 20+ chars away, so
267
- // every bracket colour used to be reported twice.
268
- if (c[1]) out.colors.push({ value: c[1].startsWith('#') ? normalizeHex(c[1]) : c[1], index: m.index + m[0].indexOf(c[1]) });
269
- }
270
- for (const a of arbitraryLengths(cls)) out.arbitrary.push({ value: a.value, index: m.index });
271
- // A bracket on a spacing utility (p-[13px]) is an off-scale spacing value
272
- // and is judged by the spacing rule, not as an arbitrary bracket as well:
273
- // one class, one finding, the same split the harvest makes.
274
- for (const c of cls.matchAll(TW_SPACING_RE)) {
275
- const inner = c[1].startsWith('[') ? c[1].slice(1, -1) : null;
276
- if (inner && /^-?\d*\.?\d+(?:px|rem|em|%|vh|vw|ch)$/.test(inner)) out.spacing.push({ value: inner, index: m.index });
277
- }
278
- }
315
+ // component code: class strings, inline style blocks, raw hex. The class
316
+ // strings are the report's own (classStringStyling, 9.6.0): className and
317
+ // the arguments of cn(), cva(), clsx(), classnames() and twMerge(), each
318
+ // value placed at the class itself, so a class on the third line of a
319
+ // multi-line list is reported on the third line.
320
+ const cs = classStringStyling(src);
321
+ out.colors.push(...cs.colors);
322
+ out.arbitrary.push(...cs.arbitrary);
323
+ out.spacing.push(...cs.spacing);
279
324
  const blocks = inlineStyleBlocks(src).filter((b) => !TRIVIAL_INLINE_RE.test(b.trim()));
280
325
  for (const b of blocks) {
281
326
  const at = src.indexOf(b);
@@ -14,12 +14,12 @@ import { walkRepo, readSource, profileRepo } from '../harvest/walk.mjs';
14
14
  import { chartSystemOf } from './charts.mjs';
15
15
  import { designGaps } from './gaps.mjs';
16
16
  export { designGaps } from './gaps.mjs';
17
- import { decideProfile, profileOf, installedDirs } from '../profiles/index.mjs';
17
+ import { decideProfile, profileOf, installedDirs, installedFile, installedFrom } from '../profiles/index.mjs';
18
18
  import { KITS } from '../profiles/kit-common.mjs';
19
19
  import { kitPaintFindings } from './kitpaint.mjs';
20
20
  import { paletteFindings } from './palette.mjs';
21
21
  import { PALETTE_CLASS_RE, DEMO_PATH_RE, blankComments } from '../harvest/paint.mjs';
22
- import { harvestTokens, extractStyling, normalizeHex, isGrey } from '../harvest/tokens.mjs';
22
+ import { harvestTokens, extractStyling, classStringStyling, normalizeHex, isGrey } from '../harvest/tokens.mjs';
23
23
  import { harvestComponents, definedComponents, tsconfigAliases } from '../harvest/components.mjs';
24
24
  import { findDuplicates } from '../harvest/duplicates.mjs';
25
25
  import { repoTokenDefs } from './tokentwins.mjs';
@@ -32,6 +32,13 @@ import { hexRgb } from './nearpairs.mjs';
32
32
  import { typefaceOf, GENERIC_FONTS } from './typefaces.mjs';
33
33
 
34
34
  export { extractStyling, normalizeHex, isGrey, hexRgb, typefaceOf, GENERIC_FONTS };
35
+ // The class strings in a piece of code, read the report's way (9.6.0):
36
+ // className and the arguments of cn(), cva(), clsx(), classnames() and
37
+ // twMerge(), each value placed at the class. classStringStyling(fileText)
38
+ // returns { colors, spacing, arbitrary }; a guard reads it on the whole file
39
+ // and keeps the hits on added lines, because a cn() call usually runs over
40
+ // several lines and a line read alone never shows the call it is in.
41
+ export { classStringStyling };
35
42
 
36
43
  // The components a piece of text defines. With the ledger below, a guard can
37
44
  // tell a second <Button> from an edit to the first one.
@@ -76,6 +83,11 @@ export { PALETTE_CLASS_RE, DEMO_PATH_RE, blankComments };
76
83
  // a guard that calls this agrees with the report's live checks by
77
84
  // construction, on a Tailwind theme and on a shadcn kit alike.
78
85
  export { paletteFindings };
86
+ // Installed code, file by file (9.5.0, profiles/installed.mjs): a guard
87
+ // leaves installedFile(system.profile.installedFrom, file, under) alone and
88
+ // judges everything else, including the team's own components kept in the
89
+ // shadcn catalogue folder. isShadcnFile(file) is the name test on its own.
90
+ export { installedFile, doorFile, isShadcnFile } from '../profiles/installed.mjs';
79
91
  // A product built on a kit (MUI, Mantine, Chakra UI, Ant Design), 8.4.6: a
80
92
  // colour or a pixel size written onto a kit component where the theme has a
81
93
  // value. kitPaintFindings(fileText, system.profile.kit, { file }) returns the
@@ -144,8 +156,7 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
144
156
  const read = (f) => readSource(join(repoRoot, f));
145
157
  const hardDupes = (findDuplicates(ledger, profile.uiDir, repoRoot, profile.uiDirs ?? null).exactDuplicates ?? []).filter((d) => !d.wrapped);
146
158
  const P = profileOf(profile);
147
- const installedNow = installedDirs(P);
148
- const charts = chartSystemOf(files, read, { own: (f) => !installedNow.some((d) => f === d || f.startsWith(`${d}/`)) });
159
+ const charts = chartSystemOf(files, read, { own: (f) => !installedFile(P, f) });
149
160
  // the kit the product is built on, with its definition attached, the way
150
161
  // the MCP knowledge reads it (mcp/knowledge.mjs): the theme's colours are
151
162
  // the token set on a kit repo, whatever stylesheet holds the most literals
@@ -225,12 +236,16 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
225
236
  profile: {
226
237
  kind: P.kind,
227
238
  role: P.role,
228
- // folders the team did not write (a shadcn catalogue, installed
229
- // registries, kit blocks): their brackets and copies are not the PR's
230
- // sin. Empty on a registry: what it publishes is its own work.
231
- // only a shadcn kit has installed code; a hand-written components/ui on a
232
- // plain product is the team's own (the report splits nothing there either)
239
+ // folders that hold installed code (a shadcn catalogue, installed
240
+ // registries, kit blocks). Empty on a registry: what it publishes is
241
+ // its own work. Only a shadcn kit has installed code; a hand-written
242
+ // components/ui on a plain product is the team's own.
233
243
  installedDirs: P.isShadcn && !P.isRegistry ? installedDirs(P) : [],
244
+ // Which files in those folders are installed, file by file (9.5.0): a
245
+ // component of the team's own kept in the catalogue folder is the
246
+ // team's, and judged. installedFile(profile.installedFrom, file, under)
247
+ // answers; `under` is how the caller matches a file to a folder.
248
+ installedFrom: installedFrom(P),
234
249
  // a product built on a kit: name, theme files and values, spacing step,
235
250
  // the team's own layers over it, and the definition kitPaintFindings reads
236
251
  kit,
@@ -34,6 +34,7 @@
34
34
  import { PALETTE_CLASS_RE, GREY_HUE_RE, DARK_WB_RE, DEMO_PATH_RE, blankComments } from '../harvest/paint.mjs';
35
35
  import { TAILWIND_DEFAULTS } from '../profiles/tailwind-defaults.mjs';
36
36
  import { oklab } from './color.mjs';
37
+ import { doorFile } from '../profiles/installed.mjs';
37
38
 
38
39
  // shadcn's names, one per role the utilities want; the sheet always has
39
40
  // greys and colours, so no family check
@@ -59,9 +60,10 @@ export function decidePalette(profile, P, readTheme = null) {
59
60
  const sc = profile.shadcn;
60
61
  // utility-class mode: the palette is the theme, and the report scores it so
61
62
  if (P.designSystem?.cssVariables === false) return null;
62
- // the catalogue and kit blocks are the kit's own doors: editing them is the
63
- // intended use, and the report's tile leaves them out
64
- const doors = [...(P.uiDirs ?? []), ...(sc.blockFiles ?? [])];
63
+ // shadcn's own components and kit blocks are the kit's doors: editing them
64
+ // is the intended use, and the report's tile leaves them out. A component
65
+ // of the team's own kept in the catalogue folder is not a door (9.5.0).
66
+ const doors = { uiDirs: [...(P.uiDirs ?? [])], shadcn: { blockFiles: [...(sc.blockFiles ?? [])] } };
65
67
  const rows = Math.max(sc.sheet?.shadcnPresent ?? 0, sc.contract?.rows ?? 0);
66
68
  if (rows >= CONTRACT_MIN) {
67
69
  return {
@@ -83,7 +85,7 @@ export function decidePalette(profile, P, readTheme = null) {
83
85
  export function paletteFindings(text, palette, { file = null, css = null } = {}) {
84
86
  if (!palette || !text) return [];
85
87
  if (css ?? (file ? CSS_FILE_RE.test(file) : false)) return [];
86
- if (file && (DEMO_PATH_RE.test(file) || (palette.doors ?? []).some((d) => file === d || file.startsWith(`${d}/`)))) return [];
88
+ if (file && (DEMO_PATH_RE.test(file) || doorFile(palette.doors, file))) return [];
87
89
  const retuned = new Set(palette.retuned ?? []);
88
90
  const fam = palette.families ?? null;
89
91
  const hasFamily = (cls) => !fam || (GREY_HUE_RE.test(cls) ? fam.grey : fam.colour);
@@ -124,6 +126,21 @@ export function paletteFindings(text, palette, { file = null, css = null } = {})
124
126
  return out;
125
127
  }
126
128
 
129
+ /**
130
+ * The theme class that stands in for a palette class, or null: the theme's
131
+ * own word for the hue when it has one (text-red-500 on a theme naming
132
+ * destructive is text-destructive), else the suitable theme colour nearest
133
+ * by value. For the rules file's example (9.5.0).
134
+ */
135
+ export function themeClassFor(cls, names = [], values = {}) {
136
+ const util = cls.replace(/^((?:[\w-]+:)*[a-z]+)-.*$/, '$1').replace(/^(?:[\w-]+:)*/, '');
137
+ const shade = cls.replace(/^(?:[\w-]+:)*[a-z]+-/, '').replace(/\/\d+$/, '');
138
+ const word = hueWord(shade, names);
139
+ if (names.includes(word)) return `${util}-${word}`;
140
+ const near = nearestThemeName(shade, utilRole(util), values);
141
+ return near ? `${util}-${near}` : null;
142
+ }
143
+
127
144
  // ---------- the theme colour a palette class stands in for ----------
128
145
  // Which theme names suit a utility, read from the name: a text class wants an
129
146
  // ink, a bg class a surface, a border class an edge. A name that says none of
@@ -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 = '9.4.0';
4
+ export const VERSION = '9.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.
@@ -18,6 +18,7 @@ import { exemptReason, isDrawing, SVG_MARKUP_RE } from '../lib/exempt.mjs';
18
18
  import { extraDeclarations, fontDeclarations } from '../lib/declarations.mjs';
19
19
  import { typefaceOf, GENERIC_FONTS } from '../lib/typefaces.mjs';
20
20
  import { kitPaintFindings } from '../lib/kitpaint.mjs';
21
+ import { installedFile } from '../profiles/installed.mjs';
21
22
  import { paletteFindings } from '../lib/palette.mjs';
22
23
  import { tokenTwinFindings } from '../lib/tokentwins.mjs';
23
24
  import { avoidedImportFindings } from '../lib/avoidedimports.mjs';
@@ -246,10 +247,12 @@ export function validateContent(content, k) {
246
247
  // a raw value the repo already uses, in a non-Tailwind repo, is consistency,
247
248
  // not a new offence — silence
248
249
  }
249
- // Bracket values inside installed code (the shadcn catalogue, kit blocks,
250
- // registries) are the kit's own choices. The report names them and keeps
251
- // them out of the count; a file under review is judged by the same line.
252
- const installed = !!file && (k.installedDirs ?? []).some((d) => file === d || file.startsWith(`${d}/`));
250
+ // Bracket values inside installed code (shadcn's own components, kit
251
+ // blocks, registries) are the kit's own choices. The report names them and
252
+ // keeps them out of the count; a file under review is judged by the same
253
+ // line, file by file: a component of the team's own kept in the catalogue
254
+ // folder is the team's (profiles/installed.mjs, 9.5.0).
255
+ const installed = !!file && installedFile(k.installedFrom, file);
253
256
  const localArb = new Map();
254
257
  for (const a of got.arbitrary) localArb.set(a.value, (localArb.get(a.value) ?? 0) + 1);
255
258
  for (const a of installed ? [] : got.arbitrary) {
@@ -24,7 +24,7 @@ import { tsconfigAliases } from '../harvest/components.mjs';
24
24
  import { neverImportedComponents } from '../lib/neverimported.mjs';
25
25
  import { typefaceOf } from '../lib/typefaces.mjs';
26
26
  import { hexRgb } from '../lib/nearpairs.mjs';
27
- import { decideProfile, profileOf, installedDirs } from '../profiles/index.mjs';
27
+ import { decideProfile, profileOf, installedDirs, installedFile, installedFrom } from '../profiles/index.mjs';
28
28
  import { KITS } from '../profiles/kit-common.mjs';
29
29
  import { repoTokenDefs } from '../lib/tokentwins.mjs';
30
30
  import { dupeCopiesOf } from '../lib/avoidedimports.mjs';
@@ -159,7 +159,7 @@ export function loadKnowledge(root) {
159
159
  // without one (lib/charts.mjs)
160
160
  // installed kit code is the kit's own door, never a precedent (profiles/index)
161
161
  const installed = installedDirs(P);
162
- const ownCode = (f) => !installed.some((d) => f === d || f.startsWith(`${d}/`));
162
+ const ownCode = (f) => !installedFile(P, f);
163
163
  const charts = chartSystemOf(files, read, { own: ownCode });
164
164
  const gaps = designGaps({ charts, tokenFile: tokens.tokenFile ?? null });
165
165
 
@@ -206,6 +206,9 @@ export function loadKnowledge(root) {
206
206
  // draws (lib/guard-api.mjs): only a shadcn kit has installed code, and a
207
207
  // registry's published folders are its own work
208
208
  installedDirs: P.isShadcn && !P.isRegistry ? installed : [],
209
+ // the facts installedFile reads (profiles/installed.mjs): a component of
210
+ // the team's own kept in the catalogue folder is judged like own code
211
+ installedFrom: installedFrom(P),
209
212
  // the kit the product is built on, and the Tailwind theme it names its
210
213
  // colours in: null when the repo is neither (see profiles/)
211
214
  kit,
@@ -24,6 +24,7 @@ import { join, basename, dirname } from 'node:path';
24
24
  import shadcn from './shadcn.mjs';
25
25
  import tailwind, { readTailwindTheme } from './tailwind.mjs';
26
26
  import { decidePalette } from '../lib/palette.mjs';
27
+ import { installedFile, componentName } from './installed.mjs';
27
28
  import mui from './mui.mjs';
28
29
  import mantine from './mantine.mjs';
29
30
  import chakra from './chakra.mjs';
@@ -89,6 +90,13 @@ export function decideProfile(profile, components, files, root = null) {
89
90
  // under a shadcn kit with no rows, or nothing. The report's tile keeps
90
91
  // its own gates (harvest/index.mjs) and no score reads this.
91
92
  profile.palette = decidePalette(profile, profileOf(profile), () => readTailwindTheme({ ...profile }, ctx)?.facts ?? null);
93
+ // shadcn in utility-class mode (components.json: cssVariables false) paints
94
+ // with Tailwind classes by design. A theme of the repo's own beside it
95
+ // (rybbit retunes the whole neutral scale) is what the rules file points
96
+ // the agent at. Kept as facts: no score and no check reads it (9.5.0).
97
+ if (profile.shadcn && profile.designSystem?.cssVariables === false) {
98
+ profile.shadcn.ownTheme = readTailwindTheme({ ...profile }, ctx)?.facts ?? null;
99
+ }
92
100
  // A shadcn repo that PUBLISHES a registry is a registry: the fourth kind.
93
101
  // Every count still reads the shadcn facts (release (a): zero score change);
94
102
  // the kind, the receipt and the header line say what it is.
@@ -118,6 +126,23 @@ export function decideProfile(profile, components, files, root = null) {
118
126
  // consumers of what it publishes live in other repos: library semantics
119
127
  profile.role = 'library';
120
128
  profile.registry = reg;
129
+ // What a registry publishes is its own work, never installed code
130
+ // (9.6.0). magicui publishes from a folder named magicui, which is also
131
+ // the name of a third-party registry the engine knows, so its own
132
+ // components were read as installed: their bracket values were called
133
+ // shadcn's and 9 points were put down to code it did not write. The
134
+ // kit blocks shadcn's own repo publishes are its own work too.
135
+ if (profile.shadcn) {
136
+ const inPublished = (f) => published.some((d) => f === d || f.startsWith(`${d}/`));
137
+ const before = profile.shadcn.registryDirs ?? [];
138
+ profile.shadcn.registryDirs = before.filter((d) => !inPublished(d));
139
+ profile.shadcn.blockFiles = (profile.shadcn.blockFiles ?? []).filter((f) => !inPublished(f));
140
+ if (profile.shadcn.registryDirs.length !== before.length) {
141
+ const left = profile.shadcn.registryDirs;
142
+ decision.evidence = decision.evidence.filter((e) => !/installed registr(y|ies) beside it/.test(e));
143
+ if (left.length) decision.evidence.push(`${left.length} installed registr${left.length === 1 ? 'y' : 'ies'} beside it: ${left.map((d) => basename(d)).join(', ')}`);
144
+ }
145
+ }
121
146
  profile.kindEvidence = [
122
147
  reg.builtFrom ? `registry built from ${reg.items} packages by ${reg.source}` : `${reg.source} publishes ${publishesLine(reg)}`,
123
148
  ...(reg.variants.length ? [`the same components kept in ${reg.variants.length} variants (${reg.variants.map((d) => basename(d)).join(', ')})`] : []),
@@ -194,14 +219,31 @@ export function installedDirs(P) {
194
219
  return [...(P?.uiDirs ?? []), ...(sc.registryDirs ?? []), ...(sc.blockFiles ?? [])];
195
220
  }
196
221
  const underAny = (file, dirs) => dirs.some((d) => file === d || file.startsWith(`${d}/`));
222
+ // Which files in those folders are installed, file by file (9.5.0): a
223
+ // component of the team's own kept in the catalogue folder is the team's.
224
+ // The rule lives in profiles/installed.mjs.
225
+ export { isShadcnFile, doorFile, installedFile, installedFrom } from './installed.mjs';
226
+
227
+ /**
228
+ * The installed-code test for one reading, as a function of a file path.
229
+ * `none` is set when the repo has no installed folders at all, so a caller
230
+ * can skip the split.
231
+ */
232
+ export function installedOf(P) {
233
+ const fn = (file) => installedFile(P, file);
234
+ fn.none = installedDirs(P).length === 0;
235
+ return fn;
236
+ }
197
237
 
198
238
  /**
199
239
  * Split bracket-value entries ({value, count, files:[{file,count}]}) into the
200
240
  * team's own uses and installed uses. Own entries keep only own files with
201
241
  * counts re-summed; installed is a flat count with its top values.
242
+ * `installed` is installedOf(P), or (older callers) a list of folders.
202
243
  */
203
- export function splitArbitrary(entries, dirs) {
204
- if (!dirs.length) return { own: entries, installed: { uses: 0, values: [] } };
244
+ export function splitArbitrary(entries, installed) {
245
+ const isInstalled = typeof installed === 'function' ? installed : (file) => underAny(file, installed);
246
+ if (typeof installed === 'function' ? installed.none : !installed.length) return { own: entries, installed: { uses: 0, values: [] } };
205
247
  const own = [];
206
248
  const installedValues = new Map();
207
249
  let installedUses = 0;
@@ -211,8 +253,8 @@ export function splitArbitrary(entries, dirs) {
211
253
  // as the team's own, so ai-chatbot's installed badge put one of shadcn's
212
254
  // [3px] values in the headline (2026-09-30)
213
255
  const all = e.every ?? e.files ?? [];
214
- const ownFiles = all.filter((f) => !underAny(f.file, dirs)).sort((a, b) => b.count - a.count).slice(0, 5);
215
- const instCount = all.filter((f) => underAny(f.file, dirs)).reduce((s, f) => s + f.count, 0);
256
+ const ownFiles = all.filter((f) => !isInstalled(f.file)).sort((a, b) => b.count - a.count).slice(0, 5);
257
+ const instCount = all.filter((f) => isInstalled(f.file)).reduce((s, f) => s + f.count, 0);
216
258
  if (instCount) { installedUses += instCount; installedValues.set(e.value, (installedValues.get(e.value) ?? 0) + instCount); }
217
259
  // own is the remainder of the entry's total, never a sum of the files
218
260
  // that happened to be listed
@@ -224,6 +266,44 @@ export function splitArbitrary(entries, dirs) {
224
266
  return { own, installed: { uses: installedUses, values } };
225
267
  }
226
268
 
269
+ /**
270
+ * Installed values by owner (9.6.0): shadcn's own components and kit blocks,
271
+ * or a third-party registry by its folder. Reads the same files and counts
272
+ * splitArbitrary reads, so the totals agree with the split; the report and
273
+ * the rules file name the owner from this instead of calling every value
274
+ * shadcn's (ai-chatbot: 20 in ui, 2 in the app-sidebar block, 8 in
275
+ * ai-elements, all once called "inside ui").
276
+ * @returns [{ kind: 'shadcn'|'registry', name, dir, uses, values: [{ value, count }], folders, blocks }]
277
+ */
278
+ export function installedByOwner(entries, P) {
279
+ const regDirs = P?.shadcn?.registryDirs ?? [];
280
+ const under = (f, d) => f === d || f.startsWith(`${d}/`);
281
+ const owners = new Map();
282
+ for (const e of entries ?? []) {
283
+ for (const f of (e.every ?? e.files ?? [])) {
284
+ if (!installedFile(P, f.file)) continue;
285
+ const reg = regDirs.find((d) => under(f.file, d)) ?? null;
286
+ const key = reg ?? '';
287
+ if (!owners.has(key)) owners.set(key, { kind: reg ? 'registry' : 'shadcn', name: reg ? basename(reg) : 'shadcn', dir: reg, uses: 0, values: new Map(), folders: new Set(), blocks: new Set() });
288
+ const o = owners.get(key);
289
+ o.uses += f.count;
290
+ o.values.set(e.value, (o.values.get(e.value) ?? 0) + f.count);
291
+ if (!reg) {
292
+ const ui = (P.uiDirs ?? []).find((d) => under(f.file, d));
293
+ if (ui) o.folders.add(basename(ui));
294
+ else o.blocks.add(componentName(f.file));
295
+ }
296
+ }
297
+ }
298
+ const rank = (o) => (o.kind === 'shadcn' ? -1 : regDirs.indexOf(o.dir));
299
+ return [...owners.values()].sort((a, b) => rank(a) - rank(b)).map((o) => ({
300
+ ...o,
301
+ values: [...o.values.entries()].sort((x, y) => y[1] - x[1]).map(([value, count]) => ({ value, count })),
302
+ folders: [...o.folders],
303
+ blocks: [...o.blocks],
304
+ }));
305
+ }
306
+
227
307
  /**
228
308
  * A fresh kit: the shadcn install command's output with nothing built on it
229
309
  * yet (Greg, 2026-09-12). Four absence checks and one presence check, all
@@ -247,16 +327,16 @@ export function decideFresh(profile, components, files, tokens, root = null) {
247
327
  const P = profileOf(profile);
248
328
  if (!P.isShadcn || !profile.shadcn) return;
249
329
  const sc = profile.shadcn;
250
- const dirs = installedDirs(P);
251
- const own = (files.code ?? []).filter((f) => /\.[jt]sx?$/.test(f) && !underAny(f, dirs));
330
+ const installed = installedOf(P);
331
+ const own = (files.code ?? []).filter((f) => /\.[jt]sx?$/.test(f) && !installed(f));
252
332
  // build config at the root (next.config, vite.config, tailwind.config) is scaffold too
253
333
  const stem = (f) => f.split('/').pop().replace(/\.[jt]sx?$/, '').replace(/^[\w-]+\.config$/, 'app');
254
- const ownComponents = (components ?? []).filter((c) => !c.isPage && !underAny(c.file, dirs));
255
- const ownArbitrary = splitArbitrary(tokens?.tailwind?.arbitrary ?? [], dirs).own.reduce((s, a) => s + a.count, 0);
256
- const ownInline = (tokens?.inlineStyles?.files ?? []).filter((f) => !underAny(f.file ?? f, dirs)).length;
334
+ const ownComponents = (components ?? []).filter((c) => !c.isPage && !installed(c.file));
335
+ const ownArbitrary = splitArbitrary(tokens?.tailwind?.arbitrary ?? [], installed).own.reduce((s, a) => s + a.count, 0);
336
+ const ownInline = (tokens?.inlineStyles?.files ?? []).filter((f) => !installed(f.file ?? f)).length;
257
337
  // the demo page the install command writes shows one Button; a page that
258
338
  // composes several doors is a screen someone built
259
- const ownPages = (components ?? []).filter((c) => c.isPage && !underAny(c.file, dirs)).map((c) => c.file);
339
+ const ownPages = (components ?? []).filter((c) => c.isPage && !installed(c.file)).map((c) => c.file);
260
340
  const doorImports = (f) => {
261
341
  if (!root) return 0;
262
342
  let src = '';
@@ -290,7 +370,7 @@ export function ownSpacing(h) {
290
370
  const css = h.tokens?.spacing ?? [];
291
371
  const tw = (h.tokens?.tailwind?.spacing ?? []).filter((v) => v.value.startsWith('['));
292
372
  if (!P.isShadcn) return { css, tw, installed: { uses: 0, values: [] } };
293
- const dirs = installedDirs(P);
294
- const a = splitArbitrary(css, dirs), b = splitArbitrary(tw, dirs);
373
+ const installed = installedOf(P);
374
+ const a = splitArbitrary(css, installed), b = splitArbitrary(tw, installed);
295
375
  return { css: a.own, tw: b.own, installed: { uses: a.installed.uses + b.installed.uses, values: [...a.installed.values, ...b.installed.values].sort((x, y) => y.count - x.count) } };
296
376
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Installed code on a shadcn repo: which files the team did not write.
3
+ *
4
+ * Until 9.5.0 every file in the catalogue folder (components/ui, or wherever
5
+ * components.json points) counted as installed: its bracket values were
6
+ * called shadcn's, its palette classes were not counted, and the guard left
7
+ * it alone. Probed on the fleet (2026-10-01): 33 of 44 shadcn product repos
8
+ * keep components of their own in that folder, 1,014 of 2,709 files, with
9
+ * 1,285 palette classes nothing counted. formbricks keeps 191 of its own
10
+ * there, and the report called their values shadcn's.
11
+ *
12
+ * The rule, in one place for the report, the live checks and the guard:
13
+ * - a third-party registry folder (ai-elements, magicui) is installed
14
+ * - a kit block (app-sidebar, login-form) is installed
15
+ * - a file in the catalogue folder is installed only when it is one of
16
+ * shadcn's components by name, however the team spells it (Nango keeps
17
+ * Avatar.tsx and Checkbox.tsx); anything else there is the team's
18
+ *
19
+ * The name list is shadcn's own (CATALOGUE, BLOCK_COMPONENTS in
20
+ * shadcn-data.mjs), checked on 2026-10-01 against every component name
21
+ * shadcn has shipped, current and historical. A third-party component
22
+ * installed straight into the catalogue folder, rather than a folder of its
23
+ * own, reads as the team's: nothing on disk tells the two apart.
24
+ */
25
+ import { basename, dirname } from 'node:path';
26
+ import { CATALOGUE, BLOCK_COMPONENTS } from './shadcn-data.mjs';
27
+
28
+ const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/_/g, '-').toLowerCase();
29
+
30
+ /** The component a file holds, by shadcn's naming: index files take their folder's name. */
31
+ export function componentName(file) {
32
+ const stem = basename(file).replace(/\.[cm]?[jt]sx?$/, '');
33
+ return kebab(stem === 'index' ? basename(dirname(file)) : stem);
34
+ }
35
+
36
+ /** Is this one of shadcn's own files: a catalogue component or a kit block, by name? */
37
+ export function isShadcnFile(file) {
38
+ const n = componentName(file);
39
+ return CATALOGUE.has(n) || BLOCK_COMPONENTS.has(n);
40
+ }
41
+
42
+ const prefixUnder = (file, dirs) => (dirs ?? []).some((d) => file === d || file.startsWith(`${d}/`));
43
+
44
+ /**
45
+ * Is `file` a kit door: one of shadcn's own components in the catalogue
46
+ * folder, or a kit block? Editing a door is the intended use, so the paint
47
+ * checks leave doors alone.
48
+ * @param from { uiDirs, shadcn: { blockFiles } } (a profileOf() reading, or the
49
+ * plain `installedFrom` object the knowledge and the doorway carry)
50
+ * @param under how a file is matched to a folder; the guard passes its own,
51
+ * which also matches when it runs in a subdirectory
52
+ */
53
+ export function doorFile(from, file, under = prefixUnder) {
54
+ if (!from || !file) return false;
55
+ if (under(file, from.shadcn?.blockFiles)) return true;
56
+ return under(file, from.uiDirs) && isShadcnFile(file);
57
+ }
58
+
59
+ /** Is `file` installed code: a door (above), or a file in a third-party registry folder? */
60
+ export function installedFile(from, file, under = prefixUnder) {
61
+ if (!from || !file) return false;
62
+ return under(file, from.shadcn?.registryDirs) || doorFile(from, file, under);
63
+ }
64
+
65
+ /**
66
+ * The plain facts installedFile and doorFile read, for a profileOf() reading:
67
+ * what the knowledge and the guard doorway carry. Null when nothing is
68
+ * installed (no shadcn kit, or a registry, whose published folders are its
69
+ * own work).
70
+ */
71
+ export function installedFrom(P) {
72
+ if (!P?.isShadcn || P.isRegistry) return null;
73
+ return { uiDirs: [...(P.uiDirs ?? [])], shadcn: { blockFiles: [...(P.shadcn?.blockFiles ?? [])], registryDirs: [...(P.shadcn?.registryDirs ?? [])] } };
74
+ }
@@ -9,7 +9,8 @@ import { distinctTypefaces } from '../lib/typefaces.mjs';
9
9
  import { nearColorPairs } from './../lib/nearpairs.mjs';
10
10
  import { neverImportedComponents } from '../lib/neverimported.mjs';
11
11
  import { VERSION } from '../lib/version.mjs';
12
- import { profileOf, installedDirs, splitArbitrary, ownSpacing } from '../profiles/index.mjs';
12
+ import { profileOf, installedOf, splitArbitrary, ownSpacing, installedByOwner } from '../profiles/index.mjs';
13
+ import { themeClassFor } from '../lib/palette.mjs';
13
14
  import { rulesOn as lintRulesOn, describeRule as lintDescribe } from '../profiles/shadcn-lint.mjs';
14
15
 
15
16
  export function rulesMarkdown(h, opts = {}) {
@@ -157,7 +158,7 @@ if (neverImported.length >= 3) {
157
158
  // On a kit the values inside installed folders are shadcn's own: the
158
159
  // counts here are the team's, the shadcn section explains the rest.
159
160
  const P0 = profileOf(h);
160
- const arbitrary = P0.isShadcn ? splitArbitrary(t.tailwind?.arbitrary ?? [], installedDirs(P0)).own : (t.tailwind?.arbitrary ?? []);
161
+ const arbitrary = P0.isShadcn ? splitArbitrary(t.tailwind?.arbitrary ?? [], installedOf(P0)).own : (t.tailwind?.arbitrary ?? []);
161
162
  const arbCount = arbitrary.reduce((s, a) => s + a.count, 0);
162
163
  const offScale = ownSpacing(h).css.length;
163
164
  if (t.tailwind?.spacing?.length || offScale || arbCount) {
@@ -205,10 +206,21 @@ if (neverImported.length >= 3) {
205
206
  const paint = sc.paint ?? null;
206
207
  section('shadcn: the components and the theme');
207
208
  if (P.isRegistry) rule(`This repo publishes a shadcn registry${P.registry?.publishes ? ` (${[['components', P.registry.publishes.components], ['blocks', P.registry.publishes.blocks], ['styles', P.registry.publishes.styles]].filter(([, v]) => v).map(([k, v]) => `${v} ${k}`).join(', ')})` : ''}. A palette colour, a bracket value or a hand-written dark: colour written here ships into every repo that installs it. Hold published code to the theme variables and the scale harder than app code, and keep demos and examples out of published files.`);
208
- 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.`);
209
- if ((sc.sheet?.shadcnPresent ?? 0) >= 5 || sc.kit?.cssVariables === false) rule('Use the semantic classes the theme gives you (`bg-background`, `text-muted-foreground`, `border-border`), never a palette colour like `bg-blue-500` or `text-gray-600`, and never a hand-written `dark:` colour. The variables already carry both modes.');
210
- else rule(`${sheetFile ? `\`${sheetFile}\` defines` : 'The theme file defines'} none of shadcn's colour variables, so \`bg-background\` and \`text-muted-foreground\` have nothing behind them here. Until the theme variables are adopted, stay with the palette classes the surrounding file already uses; do not introduce semantic classes with no variable behind them, and do not add a second palette.`);
211
- 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.)`);
209
+ const styleNote = sc.kit?.style ? ` (style \`${sc.kit.style}\`${sc.kit.baseColor ? `, base colour ${sc.kit.baseColor}` : ''})` : '';
210
+ if (sc.kit?.cssVariables === false) {
211
+ // Utility-class mode (9.5.0, words approved by Greg 2026-10-01): the
212
+ // components paint with Tailwind classes by design, so "use the theme
213
+ // variables, never a palette colour" was wrong advice here.
214
+ rule(`This is a shadcn install${styleNote}.`);
215
+ rule(utilityModeRule(sc));
216
+ const op = sc.ownTheme ? sc.ownThemePaint : null;
217
+ if (op?.uses) lines.push(` (${op.uses} palette colour${op.uses === 1 ? '' : 's'} the theme does not own already sit in own code, ${op.samples.slice(0, 3).map((s) => `\`${s.value}\` ×${s.count}`).join(', ')}; do not add to them.)`);
218
+ } else {
219
+ rule(`This is a shadcn install${styleNote}. 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.`);
220
+ if ((sc.sheet?.shadcnPresent ?? 0) >= 5) 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.');
221
+ else rule(`${sheetFile ? `\`${sheetFile}\` defines` : 'The theme file defines'} none of shadcn's colour variables, so \`bg-background\` and \`text-muted-foreground\` have nothing behind them here. Until the theme variables are adopted, stay with the palette classes the surrounding file already uses; do not introduce semantic classes with no variable behind them, and do not add a second palette.`);
222
+ 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.)`);
223
+ }
212
224
  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.');
213
225
  if (paint?.typo?.uses) lines.push(` (${paint.typo.uses} shadcn component${paint.typo.uses === 1 ? '' : 's'} get a text size or weight through className, like ${paint.typo.samples.slice(0, 2).map((s) => `\`${s.value}\``).join(' and ')}; add a size variant instead.)`);
214
226
  if (paint?.doors?.uses) lines.push(` (${paint.doors.uses} shadcn component${paint.doors.uses === 1 ? '' : 's'} already recoloured through className, like ${paint.doors.samples.slice(0, 2).map((s) => `\`${s.value}\``).join(' and ')}; do not add to them.)`);
@@ -220,7 +232,8 @@ if (neverImported.length >= 3) {
220
232
  const on = lintRulesOn(sc.lint);
221
233
  rule(`This repo runs shadcn/lint (\`${sc.lint.file}\`${on.length ? `: ${on.map((r) => lintDescribe(r, sc.lint.rules[r])).join('; ')}` : ': no rule on yet'}). Run the project's lint after every change; its errors name the variant or theme variable to use.`);
222
234
  }
223
- const ai = sc.arbitraryInstalled ?? null;
235
+ // shadcn's own values only (9.6.0): a registry's brackets are not shadcn's
236
+ const ai = installedByOwner(t.tailwind?.arbitrary ?? [], P).find((o) => o.kind === 'shadcn') ?? null;
224
237
  if (ai?.uses) rule(`Bracket values in \`${P.uiDir ?? 'components/ui'}\` are shadcn's, not a pattern to copy. The installed components use a few values Tailwind's scale does not have (${ai.values.slice(0, 3).map((v) => `\`${v.value}\``).join(', ')}), written by shadcn's authors for those components only. In your own code, do not write a bracket value: use a step from the scale, or a variable from the theme file. If a value you need is missing, add it to the theme file once and use it by name.`);
225
238
  else rule('In your own code, do not write a bracket value: use a step from the scale, or a variable from the theme file. If a value you need is missing, add it to the theme file once and use it by name.');
226
239
  if (sc.sheet?.customUnused?.length) rule(`${sc.sheet.customUnused.slice(0, 3).map((r) => `\`--${r}\``).join(', ')} in \`${sc.sheet.file}\` ${sc.sheet.customUnused.length === 1 ? 'is' : 'are'} defined and used nowhere. Do not use ${sc.sheet.customUnused.length === 1 ? 'it' : 'them'}; ${sc.sheet.customUnused.length === 1 ? 'it is' : 'they are'} a leftover next to the real theme.`);
@@ -257,3 +270,56 @@ if (neverImported.length >= 3) {
257
270
  const ruleCount = text.split('\n').filter((l) => l.startsWith('- ')).length;
258
271
  return { text, ruleCount };
259
272
  }
273
+
274
+ // ---------- shadcn in utility-class mode (words approved by Greg, 2026-10-01) ----------
275
+ const andList = (xs) => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}`);
276
+ // Tailwind names the theme gives new values, as ranges: neutral-50 to neutral-950
277
+ function retunedRanges(retuned) {
278
+ const fam = new Map();
279
+ for (const n of retuned) {
280
+ const m = /^(.*)-(\d+)$/.exec(n);
281
+ if (!m) { fam.set(n, null); continue; }
282
+ if (!fam.has(m[1])) fam.set(m[1], []);
283
+ fam.get(m[1]).push(Number(m[2]));
284
+ }
285
+ return [...fam].map(([f, steps]) => {
286
+ if (!steps) return f;
287
+ steps.sort((a, b) => a - b);
288
+ return steps.length === 1 ? `${f}-${steps[0]}` : `${f}-${steps[0]} to ${f}-${steps.at(-1)}`;
289
+ });
290
+ }
291
+ // the theme's own names worth naming: the ones a palette colour gets swapped
292
+ // for (destructive, accent), and the chart colours as a group
293
+ const NAMED_RE = /^(destructive|danger|error|negative|success|positive|warning|caution|info|accent|brand|highlight)$/;
294
+ function themeWords(names, retuned) {
295
+ const tuned = new Set(retuned);
296
+ const base = [...new Set(names.filter((n) => !tuned.has(n)).map((n) => n.replace(/-\d+$/, '')))];
297
+ const words = base.filter((n) => NAMED_RE.test(n));
298
+ const chart = base.some((n) => /^chart(-|$)/.test(n));
299
+ const picked = words.length ? words : base.filter((n) => !/(^|-)foreground$|^chart(-|$)/.test(n)).slice(0, 3);
300
+ return chart ? [...picked, 'the chart colours'] : picked;
301
+ }
302
+ function utilityModeRule(sc) {
303
+ const head = 'shadcn is installed without CSS variables here (components.json: cssVariables false), so the components paint with';
304
+ const own = sc.ownTheme;
305
+ if (!own) {
306
+ const shades = sc.doorShades ?? [];
307
+ // the install's base colour first (zinc on a zinc install), so the example is the everyday shade, not the destructive red
308
+ const base = sc.kit?.baseColor ? `-${sc.kit.baseColor}-` : null;
309
+ const first = (util) => (base && shades.find((s) => s.value.startsWith(util) && s.value.includes(base))) ?? shades.find((s) => s.value.startsWith(util));
310
+ const pick = [first('bg-'), first('text-')].filter(Boolean);
311
+ return `${head} Tailwind's palette classes by design. Stay with the shades they already use${pick.length ? ` (${pick.map((s) => `\`${s.value}\``).join(', ')})` : ''}. Never a hex, an rgb() or a bracket colour.`;
312
+ }
313
+ const ranges = retunedRanges(own.retuned ?? []);
314
+ const words = themeWords(own.names ?? [], own.retuned ?? []);
315
+ const gives = ranges.length ? `gives ${andList(ranges)} this repo's own values` : '';
316
+ const namesPart = words.length ? `names ${andList(words)}` : '';
317
+ const theme = gives || namesPart ? ` The theme file, \`${own.file}\`, ${[gives, namesPart].filter(Boolean).join(', and ')}. Use those classes.` : '';
318
+ const samples = sc.ownThemePaint?.samples ?? [];
319
+ const example = (samples.find((s) => !s.value.includes(':')) ?? samples[0])?.value ?? null;
320
+ const swap = example ? themeClassFor(example, own.names ?? [], own.values ?? {}) : null;
321
+ const never = example
322
+ ? ` Never a palette colour the theme does not own, such as \`${example}\`; ${swap ? `use \`${swap}\`, or add` : 'add'} the colour to the theme once.`
323
+ : ' Never a palette colour the theme does not own; add the colour to the theme once.';
324
+ return `${head} Tailwind classes.${theme}${never}`;
325
+ }