roast-my-design-system 7.4.0 → 7.5.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.4.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.5.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.5: the kit is read as it is built, and every file left out of a count is named.** A fresh `npx shadcn create` install is told apart from a repo built on one: "The score is the kit's, not yours." A rules file a framework wrote for itself is named as such, and so is the shadcn skill when it is installed. The theme file is found where it is, even when `components.json` points at a file that moved; `button/index.tsx` is a Button. The repaint advice checks whether the theme file holds the variables before telling you to swap to them. And the side panel now lists everything a check leaves out, with the reason and one honest line: your agent reads it anyway. New on that list: the Next.js crash page, an embedded widget's `!important`, and `!important` aimed at a library's own class names.
14
+
13
15
  > **New in 7.4: the score says what it measures, and the report has a new shape.** Under the number: "How safely an AI agent can build on this repo without going off-system." A fixed side panel holds the score, the stack, how the repo was read, an index of every section, and what is not yours and not counted. Installed code splits by one test, does it teach an agent a wrong lesson: shadcn's own bracket values are out of the count and named; palette colours inside an installed registry stay in the score and are never prompted; catalogue stock is not a trap. A factory install scores 100. Leftover theme variables become a fix.
14
16
 
15
17
  > **New in 7.3: shadcn repos are compared with shadcn repos.** The benchmark carries a shadcn slice, 17 of the 34 fleet repos, and on a shadcn repo every fleet line reads against it: "Avg shadcn repo", "cleaner than 60% of shadcn repos". Ideals and the reputable-systems line stay the same for everyone.
@@ -79,11 +81,11 @@ Not to be confused with each other: **"Why this matters"** is generic, ships wit
79
81
 
80
82
  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:
81
83
 
82
- ![The full diagnosis report for vercel/ai-chatbot in dark mode: a fixed side panel with the health score and what it measures, the stack, how the repo was read as a shadcn install, an index of every section and what is not the team's and not counted; then the summary, the What the numbers mean analysis written by Claude, priced Where to start moves each with its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, 3-yardstick tiles including the 2 shadcn tiles, the adoption map treemap, palette forensics, the shadcn theme variable by variable, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-full-dark.png?v=7.4.0)
84
+ ![The full diagnosis report for vercel/ai-chatbot in dark mode: a fixed side panel with the health score and what it measures, the stack, how the repo was read as a shadcn install, an index of every section and what is not the team's and not counted; then the summary, the What the numbers mean analysis written by Claude, priced Where to start moves each with its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, 3-yardstick tiles including the 2 shadcn tiles, the adoption map treemap, palette forensics, the shadcn theme variable by variable, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-full-dark.png?v=7.5.0)
83
85
 
84
86
  The same report in light mode (one file, built-in toggle):
85
87
 
86
- ![The diagnosis report in light mode](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-light-hero.png?v=7.4.0)
88
+ ![The diagnosis report in light mode](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-light-hero.png?v=7.5.0)
87
89
 
88
90
  ## What makes the numbers trustworthy
89
91
 
@@ -237,8 +239,8 @@ After the roast, the skill also offers to write `design-system-rules.md` to disk
237
239
 
238
240
  - **[telekom/scale](https://gregkozakiewicz.github.io/roast-my-design-system/examples/telekom-scale.html)**: Stencil, 95 components read by tag, with Claude's notes embedded
239
241
  - **[adobe/spectrum-web-components](https://gregkozakiewicz.github.io/roast-my-design-system/examples/adobe-spectrum.html)**: Lit, the `--spectrum-*` namespace named in the header
240
- - **[npx shadcn create, fresh](https://gregkozakiewicz.github.io/roast-my-design-system/examples/shadcn-create-fresh.html)**: a factory install with all 61 components, scanned as is
241
- - **[vercel/ai-chatbot](https://gregkozakiewicz.github.io/roast-my-design-system/examples/vercel-ai-chatbot.html)**: React, with Claude's notes embedded
242
+ - **[npx shadcn create, fresh](https://gregkozakiewicz.github.io/roast-my-design-system/examples/shadcn-create-fresh.html)**: a factory install with all 61 components, scanned as is and read as fresh
243
+ - **[vercel/ai-chatbot](https://gregkozakiewicz.github.io/roast-my-design-system/examples/vercel-ai-chatbot.html)**: React on shadcn, with Claude's notes embedded
242
244
  - **[excalidraw/excalidraw](https://gregkozakiewicz.github.io/roast-my-design-system/examples/excalidraw-excalidraw.html)**
243
245
  - **[dubinc/dub](https://gregkozakiewicz.github.io/roast-my-design-system/examples/dubinc-dub.html)**
244
246
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "7.4.0",
3
+ "version": "7.5.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": [
@@ -1852,7 +1852,7 @@
1852
1852
  },
1853
1853
  "slices": {
1854
1854
  "shadcn": {
1855
- "builtAt": "2026-09-12T20:07:59.374Z",
1855
+ "builtAt": "2026-09-12T23:43:34.615Z",
1856
1856
  "repoCount": 16,
1857
1857
  "note": "every repo in the fleet the scanner reads as shadcn; the \"cleaner than\" line on a shadcn repo reads against these",
1858
1858
  "stats": {
@@ -2055,7 +2055,7 @@
2055
2055
  5,
2056
2056
  7,
2057
2057
  11,
2058
- 22,
2058
+ 21,
2059
2059
  24,
2060
2060
  42,
2061
2061
  47,
@@ -2129,20 +2129,20 @@
2129
2129
  0,
2130
2130
  0,
2131
2131
  1,
2132
+ 1,
2132
2133
  2,
2134
+ 4,
2133
2135
  9,
2134
- 9,
2135
- 12,
2136
- 42,
2137
- 42,
2138
- 55,
2139
- 93,
2140
- 157
2136
+ 11,
2137
+ 14,
2138
+ 34,
2139
+ 65,
2140
+ 126
2141
2141
  ],
2142
2142
  "p25": 0,
2143
- "median": 6,
2144
- "p75": 42,
2145
- "p90": 74
2143
+ "median": 2,
2144
+ "p75": 12,
2145
+ "p90": 50
2146
2146
  },
2147
2147
  "neverImported": {
2148
2148
  "values": [
@@ -2151,7 +2151,7 @@
2151
2151
  0,
2152
2152
  0,
2153
2153
  1,
2154
- 2,
2154
+ 3,
2155
2155
  5,
2156
2156
  13,
2157
2157
  19,
@@ -2208,7 +2208,7 @@
2208
2208
  66,
2209
2209
  100,
2210
2210
  179,
2211
- 296,
2211
+ 297,
2212
2212
  488
2213
2213
  ],
2214
2214
  "p25": 22,
@@ -2249,7 +2249,7 @@
2249
2249
  "baseColor": "zinc",
2250
2250
  "tailwind": null,
2251
2251
  "codeFiles": 4391,
2252
- "ownFiles": 811,
2252
+ "ownFiles": 810,
2253
2253
  "metrics": {
2254
2254
  "colors": 310,
2255
2255
  "greys": 66,
@@ -2262,7 +2262,7 @@
2262
2262
  "inlineStyles": 79,
2263
2263
  "arbitrary": 217,
2264
2264
  "nearPairs": 18,
2265
- "important": 157,
2265
+ "important": 126,
2266
2266
  "neverImported": 99,
2267
2267
  "components": 1453,
2268
2268
  "paintTin": 22,
@@ -2316,7 +2316,7 @@
2316
2316
  "inlineStyles": 2,
2317
2317
  "arbitrary": 5,
2318
2318
  "nearPairs": 0,
2319
- "important": 2,
2319
+ "important": 1,
2320
2320
  "neverImported": 0,
2321
2321
  "components": 60,
2322
2322
  "paintTin": 57,
@@ -2343,7 +2343,7 @@
2343
2343
  "inlineStyles": 7,
2344
2344
  "arbitrary": 74,
2345
2345
  "nearPairs": 0,
2346
- "important": 9,
2346
+ "important": 2,
2347
2347
  "neverImported": 0,
2348
2348
  "components": 316,
2349
2349
  "paintTin": 66,
@@ -2411,7 +2411,7 @@
2411
2411
  "baseColor": "slate",
2412
2412
  "tailwind": null,
2413
2413
  "codeFiles": 2604,
2414
- "ownFiles": 788,
2414
+ "ownFiles": 787,
2415
2415
  "metrics": {
2416
2416
  "colors": 132,
2417
2417
  "greys": 16,
@@ -2424,10 +2424,10 @@
2424
2424
  "inlineStyles": 24,
2425
2425
  "arbitrary": 113,
2426
2426
  "nearPairs": 8,
2427
- "important": 55,
2428
- "neverImported": 2,
2427
+ "important": 34,
2428
+ "neverImported": 3,
2429
2429
  "components": 1085,
2430
- "paintTin": 296,
2430
+ "paintTin": 297,
2431
2431
  "doorOverrides": 26
2432
2432
  }
2433
2433
  },
@@ -2438,7 +2438,7 @@
2438
2438
  "baseColor": "neutral",
2439
2439
  "tailwind": null,
2440
2440
  "codeFiles": 2141,
2441
- "ownFiles": 775,
2441
+ "ownFiles": 772,
2442
2442
  "metrics": {
2443
2443
  "colors": 174,
2444
2444
  "greys": 38,
@@ -2465,7 +2465,7 @@
2465
2465
  "baseColor": "gray",
2466
2466
  "tailwind": null,
2467
2467
  "codeFiles": 875,
2468
- "ownFiles": 410,
2468
+ "ownFiles": 409,
2469
2469
  "metrics": {
2470
2470
  "colors": 17,
2471
2471
  "greys": 3,
@@ -2475,7 +2475,7 @@
2475
2475
  "radii": 14,
2476
2476
  "shadows": 0,
2477
2477
  "exactDuplicates": 17,
2478
- "inlineStyles": 22,
2478
+ "inlineStyles": 21,
2479
2479
  "arbitrary": 25,
2480
2480
  "nearPairs": 1,
2481
2481
  "important": 0,
@@ -2505,7 +2505,7 @@
2505
2505
  "inlineStyles": 47,
2506
2506
  "arbitrary": 360,
2507
2507
  "nearPairs": 7,
2508
- "important": 42,
2508
+ "important": 14,
2509
2509
  "neverImported": 13,
2510
2510
  "components": 777,
2511
2511
  "paintTin": 488,
@@ -2519,7 +2519,7 @@
2519
2519
  "baseColor": "slate",
2520
2520
  "tailwind": "3",
2521
2521
  "codeFiles": 2120,
2522
- "ownFiles": 350,
2522
+ "ownFiles": 349,
2523
2523
  "metrics": {
2524
2524
  "colors": 254,
2525
2525
  "greys": 52,
@@ -2586,7 +2586,7 @@
2586
2586
  "inlineStyles": 47,
2587
2587
  "arbitrary": 366,
2588
2588
  "nearPairs": 10,
2589
- "important": 12,
2589
+ "important": 4,
2590
2590
  "neverImported": 0,
2591
2591
  "components": 1204,
2592
2592
  "paintTin": 21,
@@ -2613,7 +2613,7 @@
2613
2613
  "inlineStyles": 58,
2614
2614
  "arbitrary": 569,
2615
2615
  "nearPairs": 16,
2616
- "important": 42,
2616
+ "important": 11,
2617
2617
  "neverImported": 30,
2618
2618
  "components": 1494,
2619
2619
  "paintTin": 58,
@@ -2627,7 +2627,7 @@
2627
2627
  "baseColor": "neutral",
2628
2628
  "tailwind": "4",
2629
2629
  "codeFiles": 1775,
2630
- "ownFiles": 605,
2630
+ "ownFiles": 604,
2631
2631
  "metrics": {
2632
2632
  "colors": 187,
2633
2633
  "greys": 51,
@@ -2667,7 +2667,7 @@
2667
2667
  "inlineStyles": 263,
2668
2668
  "arbitrary": 1602,
2669
2669
  "nearPairs": 59,
2670
- "important": 93,
2670
+ "important": 65,
2671
2671
  "neverImported": 23,
2672
2672
  "components": 3630,
2673
2673
  "paintTin": 8,
@@ -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 { profileOf, installedDirs, splitArbitrary } from '../profiles/index.mjs';
35
+ import { ownSpacing, profileOf, installedDirs, splitArbitrary } 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.
@@ -207,9 +207,12 @@ const greys = greysSorted(bgLumOf(THEMES.dark.pageBgHex));
207
207
  const greysLight = greysSorted(bgLumOf(THEMES.light.pageBgHex));
208
208
  const nonGreys = colors.filter((c) => !isGrey(c.value));
209
209
  const twColorUtils = (h.tokens.tailwind?.colors ?? []).length;
210
- const spacing = h.tokens.spacing ?? [];
211
- const twSpacing = h.tokens.tailwind?.spacing ?? [];
212
- // Off-scale spacing only (formula in score.mjs, shared with the packages pass).
210
+ // On a kit the off-scale values inside installed folders are shadcn's own:
211
+ // kept out of the tile and the bars (formula in score.mjs), named at the top.
212
+ const P_isShadcn = (x) => profileOf(x).isShadcn;
213
+ const spacingOwn = ownSpacing(h);
214
+ const spacing = spacingOwn.css;
215
+ const twSpacing = P_isShadcn(h) ? [...(h.tokens.tailwind?.spacing ?? []).filter((v) => !v.value.startsWith('[')), ...spacingOwn.tw] : (h.tokens.tailwind?.spacing ?? []);
213
216
  const spacingTotal = M.spacing;
214
217
  const exactDupes = h.duplicates.exactDuplicates ?? [];
215
218
  // A wrapped pair (one file imports the name from the other) is composition,
@@ -248,6 +251,7 @@ const nearPairs = nearColorPairs(colors);
248
251
  const collisions = h.tokens.tokenCollisions ?? [];
249
252
 
250
253
  const P = profileOf(h);
254
+ const fresh = P.isShadcn && P.shadcn?.fresh?.fresh === true;
251
255
  // On a shadcn repo the bracket list is the team's own; installed brackets are
252
256
  // named in the side panel instead (see coreMetrics for the count).
253
257
  if (P.isShadcn) arbitrary = splitArbitrary(arbitrary, installedDirs(P)).own;
@@ -297,15 +301,18 @@ if (componentsMeasured && hardDupes.length > 0) candidates.push({ ratio: 1 + har
297
301
  if (inline.count > 20) candidates.push({ ratio: inline.count / 20, text: `${n(inline.count)} inline style blocks skip the design system. An agent that reads them learns to do the same` });
298
302
  if (arbitraryCount >= 20) candidates.push({ ratio: arbitraryCount / 30, text: `${n(arbitraryCount)} values like ${arbitrary[0].value} are written outside the Tailwind scale. An agent that reads them learns to do the same` });
299
303
  if (agentFiles.length === 0) candidates.push({ ratio: 1.3, text: `No agent rules file, so your AI agent has nothing to follow` });
300
- if ((h.staleRules ?? []).length >= 2) candidates.push({ ratio: 1.2 + h.staleRules.length / 10, text: `${h.staleRules.length} lines in your agent rules refer to files that no longer exist` });
304
+ const staleOwn = (h.staleRules ?? []).filter((s) => !s.tool);
305
+ if (staleOwn.length >= 2) candidates.push({ ratio: 1.2 + staleOwn.length / 10, text: `${staleOwn.length} lines in your agent rules refer to files that no longer exist` });
301
306
  const findings = candidates.sort((a, b) => b.ratio - a.ratio).map((c) => c.text);
302
307
 
303
- let verdict = findings.length === 0
308
+ let verdict = fresh
309
+ ? 'A fresh shadcn install with nothing built on it yet. Everything below describes the kit you are starting from.'
310
+ : findings.length === 0
304
311
  ? (agentFiles.length > 0
305
312
  ? 'This repo is in good shape, and your agent has rules to read. Keep them in step with the code.'
306
313
  : 'This repo is in good shape. The gap is documentation: your agent still can\'t see the system.')
307
314
  : findings.slice(0, 3).join('. ') + '.';
308
- if (bench && findings.length) {
315
+ if (bench && findings.length && !fresh) {
309
316
  const core = [['colors', tokenLed ? colorStrays : colors.length], ['greys', tokenLed ? greyStrays : greys.length],
310
317
  ['spacing', spacingTotal], ['typefaces', typefaces.length], ['exactDuplicates', hardDupes.length], ['inlineStyles', inline.count]];
311
318
  const worse = core.filter(([m, v]) => median(m) !== null && v > median(m)).length;
@@ -595,7 +602,7 @@ function sheetSection() {
595
602
  const sheet = sc.sheet, paint = sc.paint;
596
603
  const parts = [];
597
604
  if (sheet?.found) {
598
- const bits = [`${n(sheet.lightRows)} variables for light, ${n(sheet.darkRows)} for dark in ${esc(sheet.file)}`];
605
+ const bits = [`${n(sheet.lightRows)} variables for light, ${n(sheet.darkRows)} for dark in ${esc(sheet.file)}${sheet.scope ? ` under ${esc(sheet.scope)}` : ''}${sheet.configured && sheet.configured !== sheet.file ? ` (components.json names ${esc(sheet.configured)}, ${sheet.configuredFound ? 'which holds no theme rows' : 'which does not exist'})` : ''}`];
599
606
  if (sheet.hslEra) bits.push('written in the Tailwind 3 form (hsl triplets)');
600
607
  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' : ''}`);
601
608
  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(', ')})`);
@@ -635,7 +642,7 @@ function duplicatesSection() {
635
642
  </div>` : '';
636
643
  const dupeCards = exactDupes.slice(0, 8).map((d) => `
637
644
  <div class="fam">
638
- ${eyebrow(`&lt;${esc(d.name)}&gt; · ${d.wrapped ? 'defined twice, one wraps the other' : `${d.files.length} implementations`}`)}
645
+ ${eyebrow(`&lt;${esc(d.name)}&gt; · ${d.upstream ? 'two shadcn components define it: upstream\'s overlap, not counted' : d.wrapped ? 'defined twice, one wraps the other' : `${d.files.length} implementations`}`)}
639
646
  <div class="fam-rows">
640
647
  ${d.files.slice(0, 6).map((f) => `<div class="mini-card">${fileLink(f)}</div>`).join('')}
641
648
  ${d.files.length > 6 ? `<div class="mini-card dim">…and ${d.files.length - 6} more</div>` : ''}
@@ -742,7 +749,7 @@ function typographySection() {
742
749
  ${typeSpecimens || receipts('the font sizes, by use', sizeChips)}
743
750
  ${radiiSpecimens || receipts('the radii, by use', radiiChips)}
744
751
  ${shadowSpecimens}
745
- ${fontFamilies.length > 1 ? `<div class="glass pad" style="margin-top:16px">${sectionHead(`${typefaces.length} typeface${typefaces.length === 1 ? '' : 's'}, declared ${fontFamilies.length} different ways`, 'every distinct declaration is a chance for the next one to be wrong')}<div class="fam-rows">${fams}</div></div>` : ''}
752
+ ${fontFamilies.length > 1 && fontFamilies.length > typefaces.length ? `<div class="glass pad" style="margin-top:16px">${sectionHead(`${typefaces.length} typeface${typefaces.length === 1 ? '' : 's'}, declared ${fontFamilies.length} different ways`, 'every distinct declaration is a chance for the next one to be wrong')}<div class="fam-rows">${fams}</div></div>` : ''}
746
753
  </section>`;
747
754
  }
748
755
 
@@ -882,7 +889,8 @@ function componentsSection() {
882
889
  ${sectionHead(isLibrary ? 'The composition map' : 'The adoption map', `${n(reusable.length)} components defined · tile area is ${isLibrary ? 'internal use: how the system builds from itself, downstream consumers invisible from here' : 'import count'} · the real system, drawn to scale`)}
883
890
  ${mapped}
884
891
  ${overflow > 0 ? `<p class="sub">${overflow} more adopted component${overflow === 1 ? '' : 's'} below the top 24, not drawn.</p>` : ''}
885
- ${onceUsed.length ? `<p class="sub">${n(onceUsed.length)} component${onceUsed.length === 1 ? ' is' : 's are'} imported exactly once: ${onceUsed.slice(0, 6).map((c) => `&lt;${esc(c.name)}&gt;`).join(', ')}${onceUsed.length > 6 ? ` and ${onceUsed.length - 6} more` : ''}. Quiet corners, not yet a system.</p>` : ''}
892
+ ${fresh ? '<p class="sub">No code of your own uses the kit yet. The counts below are the components using each other.</p>' : ''}
893
+ ${onceUsed.length && !fresh ? `<p class="sub">${n(onceUsed.length)} component${onceUsed.length === 1 ? ' is' : 's are'} imported exactly once: ${onceUsed.slice(0, 6).map((c) => `&lt;${esc(c.name)}&gt;`).join(', ')}${onceUsed.length > 6 ? ` and ${onceUsed.length - 6} more` : ''}. Quiet corners, not yet a system.</p>` : ''}
886
894
  ${top.length ? `<div class="tbl-wrap"><table><thead><tr><th>component</th><th>used</th><th>defined in</th><th>props</th></tr></thead><tbody>${rows}</tbody></table></div>` : ''}
887
895
  ${neverImported.length >= 2 ? `
888
896
  <div class="receipts">${eyebrow(isLibrary ? `${n(neverImported.length)} components unused internally · showroom stock to review, not dead weight: consumers in other repos are invisible from here` : vendoredUi ? `${n(neverImported.length)} catalogue components not used yet · installed by the shadcn CLI and waiting to be used, not written by this team` : `${n(neverImported.length)} components defined but never imported · they sit in the system as wrong answers waiting to be picked`)}
@@ -1081,9 +1089,18 @@ function whereToStartSection() {
1081
1089
  const pt = P.shadcn.paint;
1082
1090
  if (pt.tin.uses >= 10) {
1083
1091
  const s0 = pt.tin.samples[0], f0 = pt.tin.top[0];
1084
- c.push({ score: 20 + pt.tin.per100 / 4, metric: 'paintTin', after: 0,
1085
- title: `Repaint the ${n(pt.tin.uses)} colours from outside the theme`,
1086
- 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.` });
1092
+ // The swap only works when the theme file holds the variables. A
1093
+ // product whose sheet defines none of them (formbricks: brand rows
1094
+ // only, slate as the palette) needs a decision first, not a prompt:
1095
+ // swapping classes there leaves text with no colour.
1096
+ const sheetHasRows = (P.shadcn?.sheet?.shadcnPresent ?? 0) >= 5 || P.shadcn?.kit?.cssVariables === false;
1097
+ c.push(sheetHasRows
1098
+ ? { score: 20 + pt.tin.per100 / 4, metric: 'paintTin', after: 0,
1099
+ title: `Repaint the ${n(pt.tin.uses)} colours from outside the theme`,
1100
+ 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.` }
1101
+ : { score: 20 + pt.tin.per100 / 4, metric: 'paintTin', after: 0,
1102
+ title: `Decide what the product paints from`,
1103
+ sub: `${esc(s0.value)} appears ${s0.count} times${f0 ? `, ${esc(basename(f0.file))} alone carries ${f0.count}` : ''}, and there is no theme file to point them at: ${P.shadcn?.sheet?.file ? `${esc(P.shadcn.sheet.file)} defines` : 'the theme file defines'} none of shadcn's colour variables, so the palette is the system today. The first move is a decision, not a swap: adopt the theme variables (define them, map the palette onto them), then repaint file by file. Swapping <code>text-slate-500</code> for <code>text-muted-foreground</code> before that leaves the text with no colour.` });
1087
1104
  }
1088
1105
  if (pt.doors.uses >= 5) {
1089
1106
  const s0 = pt.doors.samples[0];
@@ -1130,6 +1147,7 @@ function whereToStartSection() {
1130
1147
  // reads at click time, so a forwarded report keeps working offline.
1131
1148
  const unesc = (s) => s.replace(/<[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&rarr;/g, '->').replace(/&amp;/g, '&');
1132
1149
  const promptFor = (item) => fixPrompt({
1150
+ metric: item.metric,
1133
1151
  title: unesc(item.title),
1134
1152
  sub: unesc(item.sub),
1135
1153
  deltaText: item.delta > 0 ? `about +${item.delta} points` : (item.target ? `about +${item.target.gain} points once ${item.target.target === 0 ? 'they are all cleared' : `the count is under ${n(item.target.target)}`}` : ''),
@@ -1205,7 +1223,20 @@ function agentSection() {
1205
1223
  const have = rootAgent.map((c) => `<span class="chip chip-agent">${esc(c.file)} · ${esc(c.tool ?? c.kind)}</span>`).join('')
1206
1224
  + (nestedCount ? `<span class="chip chip-agent">+${nestedCount} nested in subfolders</span>` : '');
1207
1225
  const readsList = (rootAgent.length ? rootAgent : nestedAgent.slice(0, 3)).map((c) => `<span class="mono">${esc(c.file)}</span>`).join(', ');
1208
- const msg = agentFiles.length
1226
+ // A file a framework wrote for itself (Next.js re-adds its block on every
1227
+ // dev run) is not guidance about the design system, and says so.
1228
+ const managedOnly = agentFiles.length > 0 && agentFiles.every((c) => c.managedOnly === true);
1229
+ const managedBy = managedOnly ? agentFiles[0].managedBy : null;
1230
+ const skill = (h.context ?? []).find((c) => c.kind === 'agent-skill');
1231
+ const skillLine = P.isShadcn ? (skill ? ` The shadcn skill is installed (<span class="mono">${esc(skill.file)}</span>), so the agent can read the kit's configuration.` : ' shadcn offers a skill for that and it is not installed here.') : '';
1232
+ // the files name the kit (or the skill is installed): the gap is the
1233
+ // repo's own habits, which no hand-written file carries
1234
+ const namesKit = P.isShadcn && (skill || agentFiles.some((c) => c.mentionsDesign === true && c.managedOnly !== true));
1235
+ const msg = managedOnly
1236
+ ? `<p class="sub">Your agent reads ${readsList}. ${esc(managedBy)} wrote it, and it is only about ${esc(managedBy)}: nothing in it mentions the theme file or the component folder, so the agent does not know the ${P.isShadcn ? 'kit' : 'design system'} is there.${skillLine} Add the rules file below and the agent knows the theme, the components and the styling rules.</p>`
1237
+ : namesKit
1238
+ ? `<p class="sub">Your agent reads ${readsList}${nestedCount && rootAgent.length ? `, plus ${nestedCount} rules file${nestedCount === 1 ? '' : 's'} nested in subfolders` : ''}${skill ? `, and the shadcn skill is installed (<span class="mono">${esc(skill.file)}</span>)` : ''}. ${skill && !agentFiles.some((c) => c.mentionsDesign) ? 'The skill tells it' : 'They tell it'} the kit is there. What ${skill && !agentFiles.some((c) => c.mentionsDesign) ? 'it does' : 'they do'} not carry is how this repo actually uses it: the numbers below, which the rules file turns into rules with receipts.</p>`
1239
+ : agentFiles.length
1209
1240
  ? `<p class="sub">Your agent reads ${readsList}${nestedCount && rootAgent.length ? `, plus ${nestedCount} rules file${nestedCount === 1 ? '' : 's'} nested in subfolders` : ''}. But none of it points at a single source of truth for components and tokens, because there isn't one yet. The numbers below are what your agent actually works from.</p>`
1210
1241
  : `<p class="sub">No <span class="mono">CLAUDE.md</span>, no <span class="mono">AGENTS.md</span>, no <span class="mono">.cursorrules</span>. Every time your AI builds UI here, it guesses, from everything below. This is why its output looks almost-but-not-quite right.</p>`;
1211
1242
  // Door coverage, stated as fact: which tools can read what exists. Claude
@@ -1222,13 +1253,13 @@ function agentSection() {
1222
1253
  const staleRows = stale.map((s) => `
1223
1254
  <div class="ledger-row">
1224
1255
  <span class="mono strong">${esc(s.ref)}</span>
1225
- <span class="dim">${s.problem === 'missing' ? 'named in' : 'named canonical in'} ${esc(s.file)}, ${s.problem === 'missing' ? 'but the path no longer exists' : 'but nothing imports it this scan'}</span>
1256
+ <span class="dim">${s.problem === 'missing' ? 'named in' : 'named canonical in'} ${esc(s.file)}${s.tool ? ` (written by ${esc(s.tool)}, not by this team)` : ''}, ${s.problem === 'missing' ? 'but the path no longer exists' : 'but nothing imports it this scan'}</span>
1226
1257
  </div>`).join('');
1227
1258
  const staleBlock = stale.length ? `
1228
1259
  <div class="stale">
1229
1260
  ${eyebrow(`${stale.length} rule reference${stale.length === 1 ? '' : 's'} gone stale · a rule your agent obeys is worse than no rule when the repo has moved on`)}
1230
1261
  <div class="ledger">${staleRows}</div>
1231
- <p class="sub" style="margin-top:10px">Rules rot quietly: nobody edits them when a component is renamed or a file moves. Regenerate them from a fresh scan, or fix the lines by hand.</p>
1262
+ <p class="sub" style="margin-top:10px">${stale.every((s) => s.tool) ? 'These lines were written by a tool, not by this team: a rule pack that speaks about files this app does not have. Not yours to fix; worth knowing your agent reads them.' : 'Rules rot quietly: nobody edits them when a component is renamed or a file moves. Regenerate them from a fresh scan, or fix the lines by hand.'}</p>
1232
1263
  </div>` : '';
1233
1264
  return `<section class="glass pad agent">
1234
1265
  ${sectionHead('What your AI agent sees today', '')}
@@ -1279,7 +1310,14 @@ function shadcnReceipt() {
1279
1310
  ].filter(Boolean);
1280
1311
  const unmatched = (k?.fellBack ?? []).filter((f) => f === 'theme' || f === 'chartColor');
1281
1312
  const fell = unmatched.length ? ` · ${unmatched.length === 2 ? 'accent and chart colour' : unmatched[0] === 'theme' ? 'accent' : 'chart colour'} not matched to a named shadcn theme` : '';
1282
- 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>`;
1313
+ const receipt = `${esc(P.evidence.join(' · '))}${facts.length ? ` · ${esc(facts.join(', '))}` : ''}${esc(fell)}`;
1314
+ if (fresh) {
1315
+ const f = P.shadcn.fresh;
1316
+ const doors = f.catalogueCount;
1317
+ const pages = comps.filter((c) => c.isPage && f.ownFiles.includes(c.file)).length;
1318
+ return `<div class="excl fresh">It is a fresh shadcn install${k?.style ? ` (style ${esc(k.style)}${k.baseColor ? `, base colour ${esc(k.baseColor)}` : ''}${k.tailwind ? `, Tailwind ${esc(k.tailwind)}` : ''})` : ''}. Nothing of your own yet: ${n(doors)} component${doors === 1 ? '' : 's'} installed, the theme file untouched, ${pages === 1 ? 'one demo page' : `${pages} pages`}. <b>The score is the kit's, not yours.</b> Run this again once you have built a few screens.</div><div class="excl">Evidence: ${receipt}</div>`;
1319
+ }
1320
+ return `<div class="excl">Read as a shadcn install (${esc(P.confidence ?? 'medium')} confidence): ${receipt}</div>`;
1283
1321
  }
1284
1322
 
1285
1323
  // User exclusions are printed in the header, never hidden: a scoped scan must
@@ -1339,12 +1377,38 @@ function exceptionsBlock() {
1339
1377
  if (P.isShadcn) {
1340
1378
  const ai = P.shadcn?.arbitraryInstalled;
1341
1379
  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.`);
1380
+ const si = spacingOwn.installed;
1381
+ const sv = si.values.length;
1382
+ 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.`);
1342
1383
  if (vendoredUi && neverImported.length) lines.push(`${n(neverImported.length)} catalogue component${neverImported.length === 1 ? '' : 's'} not used yet: stock on the shelf, not scored.`);
1343
1384
  const rp = P.shadcn?.registryPaint;
1344
1385
  if (rp) lines.push(`Installed registr${rp.dirs.length === 1 ? 'y' : 'ies'} ${esc(rp.dirs.map((d) => basename(d)).join(', '))}: ${n(rp.files)} file${rp.files === 1 ? '' : 's'}${rp.tinUses ? `, ${n(rp.tinUses)} palette colour${rp.tinUses === 1 ? '' : 's'}` : ''}. Kept in the score, left out of the fixes. Your agent reads them like everything else.`);
1345
1386
  } else if (isLibrary) {
1346
1387
  lines.push('A library: unused components are internal-only, and downstream consumers are invisible from here.');
1347
1388
  }
1389
+ const li = h.tokens?.important?.library;
1390
+ if (li?.count) lines.push(`${n(li.count)} !important declaration${li.count === 1 ? '' : 's'} aimed at a library's own class names (${li.classes.slice(0, 3).map((c) => esc(c.value)).join(', ')}) ${li.count === 1 ? 'is' : 'are'} not counted: the library ships its CSS, and this is the only way through it. Your agent reads ${li.count === 1 ? 'it' : 'them'} like everything else.`);
1391
+ // Files no checker judges (email, print, artwork, render-to-image, the
1392
+ // crash page): named here with the reason, because the agent reads them
1393
+ // like everything else and can copy what is in them.
1394
+ const ex = h.tokens?.exemptFiles ?? [];
1395
+ if (ex.length) {
1396
+ const byReason = new Map();
1397
+ for (const e of ex) { if (!byReason.has(e.reason)) byReason.set(e.reason, []); byReason.get(e.reason).push(e.file); }
1398
+ for (const [reason, files] of byReason) {
1399
+ // two files with one name (a spare-part copy) are told apart by their folder
1400
+ const label = (f) => {
1401
+ const parts = f.split('/');
1402
+ for (let k = 1; k <= parts.length; k++) {
1403
+ const tail = parts.slice(-k).join('/');
1404
+ if (files.filter((o) => o === tail || o.endsWith(`/${tail}`)).length === 1) return tail;
1405
+ }
1406
+ return f;
1407
+ };
1408
+ const names = files.slice(0, 3).map((f) => esc(label(f))).join(', ') + (files.length > 3 ? ` and ${files.length - 3} more` : '');
1409
+ lines.push(`${n(files.length)} file${files.length === 1 ? '' : 's'} not judged (${names}): ${esc(reason)}. Not counted, but your agent reads ${files.length === 1 ? 'it' : 'them'} like everything else, so what is in there is still an example it can copy.`);
1410
+ }
1411
+ }
1348
1412
  if (!lines.length) return '';
1349
1413
  return `<div class="excs"><b>Not yours, and not counted</b>${lines.map((l) => `<p>${l}</p>`).join('')}</div>`;
1350
1414
  }
@@ -1409,6 +1473,8 @@ const html = `<!doctype html>
1409
1473
  .side .chips { margin:0 0 14px; gap:5px; }
1410
1474
  .side .facts { font:500 11.5px/1.55 var(--sans); color:var(--dim); margin-bottom:16px; }
1411
1475
  .side .facts .excl { margin-top:6px; font-size:11.5px; }
1476
+ .side .facts .excl.fresh { color:var(--text); }
1477
+ .side .facts .excl.fresh b { color:var(--text); }
1412
1478
  .side .facts b { color:var(--text); font-weight:600; }
1413
1479
  .side .idx-head { margin-bottom:4px; }
1414
1480
  .side .idx a { display:block; font:500 13px/1.3 var(--sans); color:var(--dim); text-decoration:none; padding:6px 0 6px 12px; border-left:2px solid var(--line); }
@@ -1961,7 +2027,7 @@ if (summaryPath) {
1961
2027
  verdict,
1962
2028
  role: P.role,
1963
2029
  kind: P.kind,
1964
- ...(P.isShadcn && P.shadcn ? { shadcn: { confidence: P.confidence, evidence: P.evidence, style: P.shadcn.kit?.style ?? null, baseColor: P.shadcn.kit?.baseColor ?? null, tailwind: P.shadcn.kit?.tailwind ?? null, catalogues: P.uiDirs, registries: P.shadcn.registryDirs ?? [], ...(P.shadcn.registryPaint ? { registryFiles: P.shadcn.registryPaint.files, registryPaletteColours: P.shadcn.registryPaint.tinUses } : {}), ownFiles: P.shadcn.paint?.ownFiles ?? null } } : {}),
2030
+ ...(P.isShadcn && P.shadcn ? { shadcn: { confidence: P.confidence, evidence: P.evidence, style: P.shadcn.kit?.style ?? null, baseColor: P.shadcn.kit?.baseColor ?? null, tailwind: P.shadcn.kit?.tailwind ?? null, catalogues: P.uiDirs, registries: P.shadcn.registryDirs ?? [], ...(P.shadcn.registryPaint ? { registryFiles: P.shadcn.registryPaint.files, registryPaletteColours: P.shadcn.registryPaint.tinUses } : {}), ownFiles: P.shadcn.paint?.ownFiles ?? null, fresh: P.shadcn.fresh?.fresh === true } } : {}),
1965
2031
  componentsMeasured,
1966
2032
  metrics: (({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }) =>
1967
2033
  ({ 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, installedDirs, splitArbitrary } from '../profiles/index.mjs';
29
+ import { profileOf, installedDirs, splitArbitrary, ownSpacing } from '../profiles/index.mjs';
30
30
 
31
31
  export { SCHEMA_VERSION };
32
32
 
@@ -136,8 +136,9 @@ export function coreMetrics(h, opts = {}) {
136
136
  const greys = colors.filter((c) => isGrey(c.value));
137
137
  const colorTokens = colors.filter((c) => c.isToken).length;
138
138
  const colorStrays = colors.length - colorTokens;
139
- const spacing = h.tokens?.spacing ?? [];
140
- const twSpacing = h.tokens?.tailwind?.spacing ?? [];
139
+ // Off-scale spacing the team wrote: on a kit the values inside installed
140
+ // folders are shadcn's own, kept out like its bracket values and named.
141
+ const sp = ownSpacing(h);
141
142
  const hardDupes = (h.duplicates?.exactDuplicates ?? []).filter((d) => !d.wrapped);
142
143
  return {
143
144
  colors: colors.length,
@@ -150,7 +151,7 @@ export function coreMetrics(h, opts = {}) {
150
151
  greyStrays: greys.filter((c) => !c.isToken).length,
151
152
  // Off-scale spacing only: CSS-declared values plus bracket utilities.
152
153
  // Many steps of the sanctioned Tailwind scale is health, not sprawl.
153
- spacing: spacing.length + twSpacing.filter((v) => v.value.startsWith('[')).length,
154
+ spacing: sp.css.length + sp.tw.length,
154
155
  // A wrapped pair (one file imports the name from the other) is
155
156
  // composition, not competition: listed with a badge, never counted.
156
157
  exactDuplicates: hardDupes.length,
@@ -48,6 +48,21 @@ const SKIP_DIRS = new Set([
48
48
  '.turbo', '.cache', 'vendor', 'tmp',
49
49
  ]);
50
50
  const MAX_DEPTH = 6;
51
+ // where the skills CLI installs a skill, per tool
52
+ const SKILL_DIRS = ['.claude/skills', '.agents/skills', '.cursor/skills', '.codex/skills', '.github/skills', '.windsurf/skills', 'skills'];
53
+ // framework-managed blocks: markers and who writes them
54
+ const MANAGED = [
55
+ { by: 'Next.js', start: /<!--\s*(BEGIN:nextjs-agent-rules|NEXT-AGENTS-MD-START)\s*-->/, end: /<!--\s*(END:nextjs-agent-rules|NEXT-AGENTS-MD-END)\s*-->/ },
56
+ ];
57
+ function managedBlock(raw) {
58
+ for (const m of MANAGED) {
59
+ const a = raw.search(m.start), e = raw.search(m.end);
60
+ if (a < 0 || e < 0) continue;
61
+ const rest = (raw.slice(0, a) + raw.slice(e).replace(m.end, '')).trim();
62
+ return { by: m.by, only: rest.length === 0 };
63
+ }
64
+ return null;
65
+ }
51
66
  const MAX_NESTED = 60; // entries recorded; the true count keeps counting
52
67
 
53
68
  function sweepNested(root) {
@@ -98,12 +113,28 @@ export function harvestContext(root) {
98
113
  if (c.dir || st.isDirectory()) { found.push({ ...c, size: null, mentionsDesign: null }); continue; }
99
114
  size = st.size;
100
115
  if (size < 200_000) {
101
- const text = readFileSync(p, 'utf8').toLowerCase();
116
+ const raw = readFileSync(p, 'utf8');
117
+ const text = raw.toLowerCase();
102
118
  mentionsDesign = /design system|component|token|color|colour|styling|tailwind|css/.test(text);
119
+ // A block a framework writes and re-writes (Next.js's dev server adds
120
+ // its agent-rules block whenever it sees an agent) is not the team's
121
+ // guidance. managedOnly: nothing else in the file.
122
+ const m = managedBlock(raw);
123
+ if (m) { found.push({ file: c.file, kind: c.kind, tool: c.tool ?? null, size, mentionsDesign, managedBy: m.by, managedOnly: m.only }); continue; }
103
124
  }
104
125
  } catch { /* unreadable */ }
105
126
  found.push({ file: c.file, kind: c.kind, tool: c.tool ?? null, size, mentionsDesign });
106
127
  }
128
+ // shadcn's own skill, installed by the skills CLI into a tool's skills
129
+ // folder: the one thing that tells an agent the kit is there.
130
+ for (const dir of SKILL_DIRS) {
131
+ const p = join(root, dir);
132
+ if (!existsSync(p)) continue;
133
+ let names = [];
134
+ try { names = readdirSync(p); } catch { continue; }
135
+ const hit = names.find((nm) => /^shadcn/i.test(nm));
136
+ if (hit) { found.push({ file: `${dir}/${hit}`, kind: 'agent-skill', tool: 'shadcn skill', size: null, mentionsDesign: true }); break; }
137
+ }
107
138
  const nested = sweepNested(root);
108
139
  found.push(...nested.found);
109
140
  if (nested.total > nested.found.length) {
@@ -1,4 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { CATALOGUE } from '../profiles/shadcn-data.mjs';
3
+ import { doorName } from '../profiles/shadcn.mjs';
2
4
  import { join } from 'node:path';
3
5
 
4
6
  /**
@@ -55,7 +57,8 @@ const ROUTE_FRAGMENT_RE = /(^|\/)app\/.*\/(form|header|footer|nav|page|layout|lo
55
57
  * name from the other are tagged wrapped:true (composition, not competition)
56
58
  * @returns { exactDuplicates: [{name, files, wrapped?}], families: [{root, members: [{name, file, usageCount}]}] }
57
59
  */
58
- export function findDuplicates(components, uiDir = null, root = null) {
60
+ export function findDuplicates(components, uiDir = null, root = null, uiDirs = null) {
61
+ const catalogueDirs = uiDirs ?? (uiDir ? [uiDir] : []);
59
62
  // Email templates legitimately mirror web component names (email Footer !=
60
63
  // web Footer); cross-matching them manufactures duplicates.
61
64
  const EMAIL_PATH_RE = /(^|\/)emails?(\/|-)/i;
@@ -88,9 +91,16 @@ export function findDuplicates(components, uiDir = null, root = null) {
88
91
  // agent trap worth listing, but not a competing implementation.
89
92
  const perFile = new Map();
90
93
  for (const c of comps) perFile.set(c.file, (perFile.get(c.file) ?? 0) + 1);
94
+ // Every copy inside the shadcn catalogue, each a catalogue door (toast.tsx
95
+ // and sonner.tsx both define Toaster): upstream's overlap, not the team's.
96
+ // Listed with a badge, never counted, like a wrapped pair.
97
+ for (const d of exactDuplicates) {
98
+ if (!catalogueDirs.length) break;
99
+ if (d.files.every((f) => catalogueDirs.some((u) => f.startsWith(`${u}/`)) && CATALOGUE.has(doorName(f)))) { d.wrapped = true; d.upstream = true; }
100
+ }
91
101
  if (root) {
92
102
  for (const d of exactDuplicates) {
93
- if (d.files.length !== 2) continue;
103
+ if (d.files.length !== 2 || d.wrapped) continue;
94
104
  // A file defining 8+ components is an API surface (menu-item variants of
95
105
  // real components elsewhere) — same-name there is deliberate, not a copy.
96
106
  if (d.files.some((f) => (perFile.get(f) ?? 0) >= 8)) { d.wrapped = true; continue; }
@@ -27,7 +27,7 @@ import { ruleStaleness } from '../lib/staleness.mjs';
27
27
  import { neverImportedComponents } from '../lib/neverimported.mjs';
28
28
  import { lastTouchedDates } from '../lib/lasttouched.mjs';
29
29
  import { SCHEMA_VERSION } from '../lib/version.mjs';
30
- import { decideProfile, profileOf, installedDirs, splitArbitrary } from '../profiles/index.mjs';
30
+ import { decideProfile, decideFresh, profileOf, installedDirs, splitArbitrary } from '../profiles/index.mjs';
31
31
  import { countPaint } from './paint.mjs';
32
32
 
33
33
  function arg(name, fallback) {
@@ -90,10 +90,13 @@ decideProfile(profile, components, files, target);
90
90
  // choices: a true lesson, badly framed. Kept out of the count, named.
91
91
  const split = splitArbitrary(tokens.tailwind?.arbitrary ?? [], allInstalled);
92
92
  profile.shadcn.arbitraryInstalled = split.installed;
93
+ // A fresh kit (the install command's output, nothing built yet) is
94
+ // decided here, once, from the facts above.
95
+ decideFresh(profile, components, files, tokens, target);
93
96
  }
94
97
  }
95
98
 
96
- const duplicates = findDuplicates(components, profile.uiDir, target);
99
+ const duplicates = findDuplicates(components, profile.uiDir, target, profile.uiDirs ?? null);
97
100
  const context = harvestContext(target);
98
101
  const staleRules = ruleStaleness(target, components,
99
102
  new Set(neverImportedComponents(components, profile.uiDir).map((c) => c.name)),
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { existsSync } from 'node:fs';
10
10
  import {
11
- EMAIL_PRINT_RE, ARTWORK_NAME_RE, RENDER_TO_IMAGE_RE, OG_ROUTE_RE, RENDERER_PATH_RE, svgHeavy,
11
+ EMAIL_PRINT_RE, ARTWORK_NAME_RE, RENDER_TO_IMAGE_RE, OG_ROUTE_RE, RENDERER_PATH_RE, CRASH_PAGE_RE, svgHeavy, exemptReason, isLibraryClass,
12
12
  } from '../lib/exempt.mjs';
13
13
  import { join } from 'node:path';
14
14
  import { canonical, parseColor } from '../lib/color.mjs';
@@ -293,6 +293,24 @@ export function harvestTokens(root, styleFiles, codeFiles) {
293
293
  const inlineStyleFiles = new Map();
294
294
  // !important is the cascade admitting defeat; counted per style file.
295
295
  const importantFiles = new Map();
296
+ // An embedded widget (a survey, a chat bubble) lives inside a stranger's
297
+ // page and must beat the host's CSS: Tailwind imported with the important
298
+ // flag, or a config scoping every utility under an id. There, !important is
299
+ // the medium, like inline styles in email. Skipped, named with the reason.
300
+ const WIDGET_CSS_RE = /@import\s+["']tailwindcss(?:\/utilities\.css)?["'][^;]*\bimportant\b/;
301
+ const WIDGET_CONFIG_RE = /\bimportant\s*:\s*["']#/;
302
+ const widgetDirs = codeFiles.filter((f) => /(^|\/)tailwind\.config\.[mc]?[jt]s$/.test(f))
303
+ .filter((f) => WIDGET_CONFIG_RE.test(readSource(join(root, f)) ?? ''))
304
+ .map((f) => f.slice(0, f.lastIndexOf('/') + 1));
305
+ const mediumFiles = [];
306
+ // !important aimed at a library's own class names (a code editor, a date
307
+ // picker, an emoji picker ships its CSS; shouting is the only way through)
308
+ // is the medium too. Decided after the code walk: a class the team writes
309
+ // anywhere in code is the team's, so doubt counts (fleet probe 2026-09-13:
310
+ // 30% of all !important, the tile keeps its spread).
311
+ const importantBlocks = [];
312
+ const ownClasses = new Set();
313
+ const WIDGET_REASON = 'an embedded widget forces its rules through the host page\'s CSS, so !important is the medium';
296
314
 
297
315
  // Colours that appear in a CSS custom-property DEFINITION (--grey-100: #f5f5f5)
298
316
  // are deliberate tokens; everything else is a hardcoded stray. The diagnosis
@@ -342,7 +360,22 @@ export function harvestTokens(root, styleFiles, codeFiles) {
342
360
  // character offset below still points at the same place in the file.
343
361
  const text = raw.replace(/\/\*[\s\S]*?\*\//g, (m) => ' '.repeat(m.length));
344
362
  const imp = (text.match(/!\s*important/gi) ?? []).length;
345
- if (imp) importantFiles.set(file, (importantFiles.get(file) ?? 0) + imp);
363
+ const widget = WIDGET_CSS_RE.test(text) || widgetDirs.some((d) => file.startsWith(d));
364
+ if (imp && widget) mediumFiles.push({ file, reason: WIDGET_REASON, count: imp });
365
+ else if (imp) {
366
+ // innermost blocks carry a selector to judge; anything outside them
367
+ // (a declaration above a nested SCSS block) is counted, unjudged
368
+ let inBlocks = 0;
369
+ for (const m of text.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
370
+ const n = (m[2].match(/!\s*important/gi) ?? []).length;
371
+ if (!n) continue;
372
+ inBlocks += n;
373
+ const sel = m[1].trim().split(/\s*\n\s*/).pop();
374
+ const classes = sel.startsWith('@') ? [] : [...sel.matchAll(/\.([A-Za-z_][\w-]*)/g)].map((x) => x[1]);
375
+ importantBlocks.push({ file, count: n, classes });
376
+ }
377
+ if (imp > inBlocks) importantBlocks.push({ file, count: imp - inBlocks, classes: [] });
378
+ }
346
379
  // Character ranges holding a re-statement of an already-named token. The
347
380
  // blanket colour sweep below reads the whole file, so it needs to be told
348
381
  // which stretches of it are variants rather than new colours.
@@ -417,14 +450,18 @@ export function harvestTokens(root, styleFiles, codeFiles) {
417
450
  // Email, artwork, mostly-SVG files and pixel renderers: all imported from
418
451
  // lib/exempt.mjs, the one list every checker reads.
419
452
  const EXEMPT_RE = EMAIL_PRINT_RE, ARTWORK_RE = ARTWORK_NAME_RE;
453
+ // every file a check skipped, with the reason, so the report can name them
454
+ const exemptFiles = [];
455
+ const skip = (f, src) => { const r = exemptReason(f, src); if (r) exemptFiles.push({ file: f, reason: r }); return true; };
420
456
  // Colour-picker palettes and design-field option lists are user-facing data.
421
457
  const PALETTE_FILE_RE = /(color-picker|colour-picker|palette|design-fields|swatch)/i;
422
458
 
423
459
  for (const f of codeFiles) {
424
460
  if (!/\.(tsx|jsx|ts|js)$/.test(f)) continue;
425
- if (EXEMPT_RE.test(f)) continue;
461
+ if (EXEMPT_RE.test(f) || CRASH_PAGE_RE.test(f)) { skip(f, ''); continue; }
426
462
  let src = readSource(join(root, f));
427
463
  if (src === null) continue;
464
+ for (const m of src.matchAll(/[A-Za-z_][\w-]+/g)) ownClasses.add(m[0]);
428
465
 
429
466
  // CSS-in-tagged-templates (Lit css``, styled-components css``) IS the
430
467
  // stylesheet in those worlds: Shoelace keeps its entire component styling
@@ -474,13 +511,13 @@ export function harvestTokens(root, styleFiles, codeFiles) {
474
511
  paletteFiles.add(f);
475
512
  for (const m of src.matchAll(HEX_RE)) tokenDefined.add(normalizeHex(m[0]));
476
513
  }
477
- if (ARTWORK_RE.test(f) || RENDERER_PATH_RE.test(f) || svgHeavy(src)) continue;
514
+ if (ARTWORK_RE.test(f) || RENDERER_PATH_RE.test(f) || svgHeavy(src)) { skip(f, src); continue; }
478
515
  // A render-to-image surface (OG card, PDF invoice) is artwork drawn with
479
516
  // code: its colours are the picture's, not the product's palette. Until
480
517
  // 5.10.0 they were the only "strays" a clean shadcn repo had, and they
481
518
  // triggered the "every single one is hardcoded" banner on it.
482
519
  const renderToImage = RENDER_TO_IMAGE_RE.test(src) || OG_ROUTE_RE.test(f);
483
- if (renderToImage) continue;
520
+ if (renderToImage) { skip(f, src); continue; }
484
521
 
485
522
  // Tailwind classes
486
523
  for (const cls of classStrings(src)) {
@@ -624,6 +661,17 @@ export function harvestTokens(root, styleFiles, codeFiles) {
624
661
  }
625
662
  }
626
663
 
664
+ // Resolve the !important blocks now that every class the team writes is known.
665
+ const libraryImportant = { count: 0, classes: new Map(), files: new Map() };
666
+ for (const b of importantBlocks) {
667
+ // a known library's class names, none of which the team writes in code:
668
+ // both conditions, so a team class used only from CSS still counts
669
+ const library = b.classes.length > 0 && !b.classes.some((c) => ownClasses.has(c)) && b.classes.some(isLibraryClass);
670
+ if (!library) { importantFiles.set(b.file, (importantFiles.get(b.file) ?? 0) + b.count); continue; }
671
+ libraryImportant.count += b.count;
672
+ libraryImportant.classes.set(b.classes[0], (libraryImportant.classes.get(b.classes[0]) ?? 0) + b.count);
673
+ libraryImportant.files.set(b.file, (libraryImportant.files.get(b.file) ?? 0) + b.count);
674
+ }
627
675
  return {
628
676
  tokenFile,
629
677
  namespaces,
@@ -650,10 +698,17 @@ export function harvestTokens(root, styleFiles, codeFiles) {
650
698
  arbitrary: twArbitrary.toJSON(),
651
699
  },
652
700
  important: {
701
+ // aimed at a library's own class names: kept out, named at the top
702
+ library: {
703
+ count: libraryImportant.count,
704
+ classes: [...libraryImportant.classes.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4).map(([value, count]) => ({ value: `.${value}`, count })),
705
+ files: [...libraryImportant.files.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4).map(([file, count]) => ({ file, count })),
706
+ },
653
707
  count: [...importantFiles.values()].reduce((a, b) => a + b, 0),
654
708
  files: [...importantFiles.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6)
655
709
  .map(([file, count]) => ({ file, count })),
656
710
  },
711
+ exemptFiles: [...exemptFiles, ...mediumFiles],
657
712
  inlineStyles: {
658
713
  count: inlineStyleCount,
659
714
  files: [...inlineStyleFiles.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
@@ -24,6 +24,10 @@
24
24
  * lines), so both give the same answer about the same file.
25
25
  */
26
26
  export const EMAIL_PRINT_RE = /email|(^|[/.])print([/.]|$)/i;
27
+ // Next.js's crash page replaces the root layout, so the app's stylesheet
28
+ // never loads there: Next.js tells developers to build it self-contained,
29
+ // styling written on the elements. The medium, not a lapse (2026-09-13).
30
+ export const CRASH_PAGE_RE = /(^|\/)global-error\.(tsx|jsx)$/;
27
31
  export const ARTWORK_NAME_RE = /(^|\/)[\w.-]*(icon|logo|badge|illustration|shield|artwork|graphic|background)[\w.-]*\.(tsx|jsx)$/i;
28
32
  export const SVG_MARKUP_RE = /<(svg|path|rect|circle|ellipse|polygon|defs|mask)\b/i;
29
33
  export const RENDER_TO_IMAGE_RE = /ImageResponse|from ['"]satori['"]|from ['"]@react-pdf|next\/og/;
@@ -42,9 +46,21 @@ export const svgHeavy = (text) =>
42
46
  export function exemptReason(file, text = '') {
43
47
  if (!file) return null;
44
48
  if (EMAIL_PRINT_RE.test(file)) return 'email and print styling has to be inline, because there is no cascade to inherit';
49
+ if (CRASH_PAGE_RE.test(file)) return 'the crash page replaces the root layout, so the stylesheet never loads there and its styling has to be inline';
45
50
  if (RENDERER_PATH_RE.test(file)) return 'a renderer draws pixels, so its colours are the picture rather than the interface';
46
51
  if (OG_ROUTE_RE.test(file) || RENDER_TO_IMAGE_RE.test(text)) return 'a render-to-image surface accepts nothing but inline styling, so there is no on-system way to write one';
47
52
  if (svgHeavy(text)) return 'the file is mostly drawing rather than styling';
48
53
  if (ARTWORK_NAME_RE.test(file) && SVG_MARKUP_RE.test(text)) return 'the colours belong to the artwork, not to the interface';
49
54
  return null;
50
55
  }
56
+
57
+ /**
58
+ * Class names a library ships and the team can only shout over: a code
59
+ * editor, a date picker, a grid, an emoji picker, a docs theme. An !important
60
+ * on a selector made only of these, none of which the team writes in its own
61
+ * code, is the medium rather than the mess (fleet probe 2026-09-13: 30% of
62
+ * all !important, the tile keeps its spread). Prefix match, by name, so the
63
+ * report can say which library. Extend as the fleet shows new ones.
64
+ */
65
+ export const LIBRARY_CLASS_RE = /^(cm-|ͼ|ProseMirror|monaco-|sp-|react-datepicker|react-tel-input|DateInput_|DayPicker|react-grid-|react-resizable|react-flow|react-select|react-toastify|Toastify|select2|EmojiPickerReact|epr-|notion-|fc-|fc$|tippy-|hljs|language-|ps__|ace_|ng-|docsearch|DocSearch|grecaptcha|rr-|sbdocs|docs-story|mapboxgl-|leaflet-|swiper-|slick-|rc-|ant-|Mui|ck-|ql-|tox-|mce-|DraftEditor|public-Draft|w-md-editor|rdp-|recharts-|apexcharts-|intercom-|crisp-|hubspot|shiki|katex|mermaid|prism-)/;
66
+ export const isLibraryClass = (c) => LIBRARY_CLASS_RE.test(c);
@@ -7,7 +7,18 @@
7
7
  * plan. Born from the first user feedback (Willem, 2026-08-31).
8
8
  */
9
9
 
10
- export function fixPrompt({ title, sub, deltaText, repoName }) {
10
+ // The two mistakes an agent chasing points makes (three repos, 2026-09-13):
11
+ // repainting a picture to a grey, and rounding a width another element
12
+ // depends on. Named in the prompt for the moves where they happen.
13
+ const TRAP_LINES = {
14
+ paintTin: '- A gradient, an illustration or a status colour keeps its colour. Add a variable for it rather than swapping it to a grey; the swap is for text, borders and surfaces.',
15
+ colors: '- A gradient, an illustration or a status colour keeps its colour. Add a variable for it rather than swapping it to a grey; the swap is for text, borders and surfaces.',
16
+ arbitrary: '- Never round a width or height another element depends on: a preview panel, a skeleton that mirrors a chart, an editor pane. Name it if it repeats; leave it if it is one.',
17
+ spacing: '- Never round a width or height another element depends on: a preview panel, a skeleton that mirrors a chart, an editor pane. Name it if it repeats; leave it if it is one.',
18
+ };
19
+
20
+ export function fixPrompt({ title, sub, deltaText, repoName, metric = null }) {
21
+ const trap = TRAP_LINES[metric] ? `\n${TRAP_LINES[metric]}` : '';
11
22
  return `You are fixing one design-system finding in ${repoName ? `the ${repoName} repository` : 'this repository'}, measured by roast-my-design-system.
12
23
 
13
24
  The finding: ${title}
@@ -18,7 +29,7 @@ How to fix it, calmly and with respect for intent:
18
29
  - A value used many times is a decision without a name, not a mistake. Name it and consolidate; never blind-delete.
19
30
  - Small pixel nudges and one-off layout widths can be deliberate craft. Keep the deliberate exceptions; round the accidents to a neighbouring step.
20
31
  - Prefer the tokens, scale steps and canonical components this repository already has over inventing new ones.
21
- - Keep every change mechanical and reviewable. No redesigns, no drive-by refactors, no renamed files unless the finding asks for it.
32
+ - Keep every change mechanical and reviewable. No redesigns, no drive-by refactors, no renamed files unless the finding asks for it.${trap}
22
33
 
23
34
  Work through the files named in the finding. When you are done, re-run the scan to verify the payoff:
24
35
 
@@ -25,6 +25,20 @@ const TICK_RE = /`<?([A-Z][A-Za-z0-9]{3,})>?`/g;
25
25
  // --apply trigger the staleness alarm on itself. In our content, only path
26
26
  // claims are checked (they rot for real); component-usage claims are not.
27
27
  const OURS_RE = /roast-my-design-system/i;
28
+ // A rules file a tool wrote for itself (a lint preset's rule pack, the
29
+ // Next.js agent block): its stale lines are the tool's, not the team's, so
30
+ // the report names the tool instead of saying "your agent rules".
31
+ const GENERATORS = [
32
+ [/ultracite/i, 'Ultracite'],
33
+ [/nextjs-agent-rules|NEXT-AGENTS-MD/, 'Next.js'],
34
+ [/\bbiome\b/i, 'Biome'],
35
+ [/generated by ([\w@/.-]+)/i, null],
36
+ ];
37
+ function generatorOf(text) {
38
+ const head = text.slice(0, 600);
39
+ for (const [re, name] of GENERATORS) { const m = head.match(re); if (m) return name ?? m[1]; }
40
+ return null;
41
+ }
28
42
  const BLOCK_RE = /<!-- roast-my-design-system:rules:begin[\s\S]*?roast-my-design-system:rules:end -->/g;
29
43
 
30
44
  function ruleTexts(root) {
@@ -33,7 +47,7 @@ function ruleTexts(root) {
33
47
  // the file minus our injected block, and component checks run only on it.
34
48
  const push = (file, text) => {
35
49
  const handWritten = text.replace(BLOCK_RE, '');
36
- texts.push({ file, text, handWritten, ours: OURS_RE.test(handWritten) });
50
+ texts.push({ file, text, handWritten, ours: OURS_RE.test(handWritten), tool: generatorOf(text) });
37
51
  };
38
52
  for (const f of AGENT_FILES) {
39
53
  const p = join(root, f);
@@ -79,16 +93,16 @@ export function ruleStaleness(root, components, neverImportedNames = new Set(),
79
93
  }
80
94
  const findings = [];
81
95
  const seen = new Set();
82
- const add = (file, ref, kind, problem) => {
96
+ const add = (file, ref, kind, problem, tool = null) => {
83
97
  const key = `${ref}|${problem}`;
84
98
  if (seen.has(key) || findings.length >= MAX_FINDINGS) return;
85
99
  seen.add(key);
86
- findings.push({ file, ref, kind, problem });
100
+ findings.push({ file, ref, kind, problem, ...(tool ? { tool } : {}) });
87
101
  };
88
102
  const isPlaceholder = (ref) => ref.split('/').some((seg) => PLACEHOLDER_SEG.test(seg));
89
103
  const existsAsSuffix = (ref) => fileSuffixes.some((f) => f === ref || f.endsWith(`/${ref}`));
90
104
 
91
- for (const { file, text, handWritten, ours } of ruleTexts(root)) {
105
+ for (const { file, text, handWritten, ours, tool } of ruleTexts(root)) {
92
106
  for (const m of text.matchAll(PATH_RE)) {
93
107
  const ref = m[1].replace(/^\.\//, '');
94
108
  if (isPlaceholder(ref)) continue;
@@ -98,7 +112,7 @@ export function ruleStaleness(root, components, neverImportedNames = new Set(),
98
112
  // a short test-file ref is almost surely package-relative and unverifiable;
99
113
  // a deep root-relative one was checked by existsSync above and is real drift
100
114
  if (UNVERIFIABLE_RE.test(ref) && ref.split('/').length < 4) continue;
101
- add(file, ref, 'path', 'missing');
115
+ add(file, ref, 'path', 'missing', tool);
102
116
  }
103
117
  if (ours) continue; // fully generated file: paths checked above, component claims are ours to regenerate, not to second-guess
104
118
  const compRefs = new Set();
@@ -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.4.0';
4
+ export const VERSION = '7.5.0';
5
5
  // The shape of harvest.json and summary.json. Bumped only when a field is
6
6
  // renamed, removed or changes meaning; a new field is not a new schema. A
7
7
  // tool comparing two scans compares like with like by this number, not by VERSION.
@@ -1,3 +1,5 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  /**
2
4
  * Profiles — what kind of repo is this, decided once and read everywhere.
3
5
  *
@@ -138,3 +140,74 @@ export function splitArbitrary(entries, dirs) {
138
140
  const values = [...installedValues.entries()].sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
139
141
  return { own, installed: { uses: installedUses, values } };
140
142
  }
143
+
144
+ /**
145
+ * A fresh kit: the shadcn install command's output with nothing built on it
146
+ * yet (Greg, 2026-09-12). Four absence checks and one presence check, all
147
+ * from facts already harvested; all five must hold. Decided once here, so the
148
+ * report, the rules file and the summary read one flag. When it holds the
149
+ * copy says "the score is the kit's, not yours" instead of narrating the
150
+ * catalogue's internal wiring as the team's habits.
151
+ *
152
+ * "Untouched" means untouched by these measures: a door edited by hand is
153
+ * invisible here, so the wording says "theme file untouched", never
154
+ * "components untouched".
155
+ */
156
+ // what the install command writes around the catalogue, by file stem
157
+ const SCAFFOLD_STEMS = new Set([
158
+ 'layout', 'page', 'loading', 'error', 'not-found', 'template', 'globals',
159
+ 'utils', 'use-mobile', 'theme-provider', 'providers', 'main', 'app', 'index',
160
+ 'root', '__root', 'routes', 'router', 'vite-env.d', 'next-env.d', 'env.d',
161
+ 'middleware', 'proxy', 'instrumentation',
162
+ ]);
163
+ export function decideFresh(profile, components, files, tokens, root = null) {
164
+ const P = profileOf(profile);
165
+ if (!P.isShadcn || !profile.shadcn) return;
166
+ const sc = profile.shadcn;
167
+ const dirs = installedDirs(P);
168
+ const own = (files.code ?? []).filter((f) => /\.[jt]sx?$/.test(f) && !underAny(f, dirs));
169
+ // build config at the root (next.config, vite.config, tailwind.config) is scaffold too
170
+ const stem = (f) => f.split('/').pop().replace(/\.[jt]sx?$/, '').replace(/^[\w-]+\.config$/, 'app');
171
+ const ownComponents = (components ?? []).filter((c) => !c.isPage && !underAny(c.file, dirs));
172
+ const ownArbitrary = splitArbitrary(tokens?.tailwind?.arbitrary ?? [], dirs).own.reduce((s, a) => s + a.count, 0);
173
+ const ownInline = (tokens?.inlineStyles?.files ?? []).filter((f) => !underAny(f.file ?? f, dirs)).length;
174
+ // the demo page the install command writes shows one Button; a page that
175
+ // composes several doors is a screen someone built
176
+ const ownPages = (components ?? []).filter((c) => c.isPage && !underAny(c.file, dirs)).map((c) => c.file);
177
+ const doorImports = (f) => {
178
+ if (!root) return 0;
179
+ let src = '';
180
+ try { src = readFileSync(join(root, f), 'utf8'); } catch { return 0; }
181
+ return (src.match(/from\s+["'][^"']*\/ui\/[\w-]+["']/g) ?? []).length;
182
+ };
183
+ const checks = {
184
+ // 1. nothing of the team's own: every non-page component lives in a scaffold file
185
+ noOwnComponents: ownComponents.every((c) => SCAFFOLD_STEMS.has(stem(c.file))),
186
+ // 2. own code carries no colour, bracket or inline style
187
+ ownCodeClean: (sc.paintOwn?.tin?.uses ?? 0) === 0 && (sc.paintOwn?.doors?.uses ?? 0) === 0 && ownArbitrary === 0 && ownInline === 0,
188
+ // 3. the theme file is stock: every shadcn row, no custom row, --spacing untouched
189
+ sheetStock: sc.sheet?.found === true && (sc.sheet.custom ?? []).length === 0 && (sc.sheet.shadcnMissing ?? []).length === 0 && !sc.sheet.spacingChanged,
190
+ // 4. nothing installed beside the catalogue: no registries, no kit blocks
191
+ catalogueOnly: (sc.registryDirs ?? []).length === 0 && (sc.blockFiles ?? []).length === 0,
192
+ // 5. the reverse check: own code IS the scaffold, by name and by size
193
+ scaffold: own.length > 0 && own.length <= 10 && own.every((f) => SCAFFOLD_STEMS.has(stem(f)))
194
+ && ownPages.length <= 1 && ownPages.every((f) => doorImports(f) <= 1),
195
+ };
196
+ sc.fresh = {
197
+ fresh: Object.values(checks).every(Boolean),
198
+ checks,
199
+ ownFiles: own,
200
+ catalogueCount: (sc.installs ?? []).reduce((s, i) => s + (i.catalogueNames ?? 0), 0),
201
+ };
202
+ }
203
+
204
+ /** Off-scale spacing entries the team wrote (installed folders kept out on a kit). */
205
+ export function ownSpacing(h) {
206
+ const P = profileOf(h);
207
+ const css = h.tokens?.spacing ?? [];
208
+ const tw = (h.tokens?.tailwind?.spacing ?? []).filter((v) => v.value.startsWith('['));
209
+ if (!P.isShadcn) return { css, tw, installed: { uses: 0, values: [] } };
210
+ const dirs = installedDirs(P);
211
+ const a = splitArbitrary(css, dirs), b = splitArbitrary(tw, dirs);
212
+ 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) } };
213
+ }
@@ -33,6 +33,11 @@ import { CATALOGUE, BLOCK_COMPONENTS, REGISTRY_DIRS, KNOWN_ROWS, TWEAKCN_ROWS, L
33
33
  const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
34
34
  const read = (p) => { try { return readFileSync(p, 'utf8'); } catch { return ''; } };
35
35
  const stripExt = (f) => f.replace(/\.[cm]?[jt]sx?$/, '');
36
+ // A door is a file named after the component, or a folder named after it
37
+ // holding an index file (formbricks: button/index.tsx). doorDir is the
38
+ // catalogue folder the door sits in, either way.
39
+ export const doorName = (f) => { const st = stripExt(basename(f)); return st === 'index' ? basename(dirname(f)) : st; };
40
+ const doorDir = (f) => (stripExt(basename(f)) === 'index' ? dirname(dirname(f)) : dirname(f));
36
41
 
37
42
  /**
38
43
  * Every folder in the walk holding 8+ catalogue door names AND the marks of
@@ -47,8 +52,8 @@ function catalogueSweep(root, files) {
47
52
  const byDir = new Map();
48
53
  for (const f of files.code) {
49
54
  if (!/\.[jt]sx$/.test(f)) continue;
50
- const d = dirname(f);
51
- if (!CATALOGUE.has(stripExt(basename(f)))) continue;
55
+ const d = doorDir(f);
56
+ if (!CATALOGUE.has(doorName(f))) continue;
52
57
  const e = byDir.get(d) ?? { names: 0, marked: 0 };
53
58
  e.names += 1;
54
59
  if (e.marked < 3 && SHADCN_CODE_RE.test(read(join(root, f)))) e.marked += 1;
@@ -104,11 +109,17 @@ function resolveAlias(root, wsRoot, alias) {
104
109
  const CONTRACT_ROWS = ['background', 'foreground', 'primary', 'muted', 'border', 'ring', 'accent', 'card', 'popover', 'destructive'];
105
110
  function themeContract(root, files) {
106
111
  const seen = new Set();
112
+ let best = null, bestCount = 0;
107
113
  for (const f of (files.styles ?? []).slice(0, 60)) {
108
114
  const css = read(join(root, f));
109
- for (const r of CONTRACT_ROWS) if (!seen.has(r) && new RegExp(`--${r}\\s*:`).test(css)) seen.add(r);
110
- if (seen.size === CONTRACT_ROWS.length) break;
115
+ let here = 0;
116
+ for (const r of CONTRACT_ROWS) if (new RegExp(`--${r}\\s*:`).test(css)) { seen.add(r); here += 1; }
117
+ // the file that carries most of the contract is the sheet when the
118
+ // config names one that is missing or empty (a starter that moved it)
119
+ if (here > bestCount) { best = f; bestCount = here; }
120
+ if (seen.size === CONTRACT_ROWS.length && bestCount === CONTRACT_ROWS.length) break;
111
121
  }
122
+ themeContract.file = best;
112
123
  return seen.size;
113
124
  }
114
125
 
@@ -130,7 +141,22 @@ function readSheet(root, wsRoot, cssPath, files = null) {
130
141
  const file = [join(wsRoot, cssPath), join(wsRoot, 'src', cssPath)].find((p) => existsSync(p));
131
142
  if (!file) return { file: cssPath, found: false };
132
143
  const css = read(file);
133
- const light = rowsUnder(css, ':root'), dark = rowsUnder(css, '\\.dark');
144
+ let light = rowsUnder(css, ':root'), dark = rowsUnder(css, '\\.dark');
145
+ // Rows under a scoped selector instead of :root (a theme picker's
146
+ // [data-theme='x'] blocks, a widget's #id): the first block holding
147
+ // --background is the sheet, its .dark twin the evening values.
148
+ let scope = null;
149
+ if (!light.has('background')) {
150
+ for (const m of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
151
+ const sel = m[1].trim().split(/\s*\n\s*/).pop();
152
+ if (!/--background\s*:/.test(m[2]) || sel.startsWith('@')) continue;
153
+ const rows = new Map();
154
+ for (const d of m[2].matchAll(/--([a-z0-9-]+)\s*:\s*([^;]+);/gi)) rows.set(d[1], d[2].trim());
155
+ if (/\.dark\b/.test(sel)) { if (!dark.has('background')) dark = rows; }
156
+ else if (!light.has('background')) { light = rows; scope = sel; }
157
+ if (light.has('background') && dark.has('background')) break;
158
+ }
159
+ }
134
160
  const themeInline = rowsUnder(css, '@theme\\s+inline');
135
161
  const rows = [...light.keys()];
136
162
  const known = rows.filter((r) => KNOWN_ROWS.has(r));
@@ -162,7 +188,7 @@ function readSheet(root, wsRoot, cssPath, files = null) {
162
188
  customUnused = [...pending];
163
189
  }
164
190
  return {
165
- file: file.slice(root.length + 1), found: true, hslEra, customUnused,
191
+ file: file.slice(root.length + 1), found: true, scope, hslEra, customUnused,
166
192
  lightRows: rows.length, darkRows: dark.size, shadcnPresent, shadcnMissing: SHADCN_ROWS.filter((r) => !light.has(r)),
167
193
  known: known.length, custom, missingDark, customMissingDark, customUnregistered, tweakcnPresent,
168
194
  spacing, spacingChanged: spacing !== null && norm(spacing) !== FACTORY_SPACING,
@@ -220,6 +246,10 @@ export default {
220
246
  .map((f) => ({ file: f, cfg: readJSON(join(root, f)) }))
221
247
  .filter((c) => c.cfg && (c.cfg.aliases || c.cfg.style !== undefined || c.cfg.tailwind));
222
248
 
249
+ // the theme contract (5+ of shadcn's named variables in one stylesheet),
250
+ // computed once: the decision below reads the count, the sheet reader
251
+ // falls back to the file
252
+ const contract = themeContract(root, files);
223
253
  // 2. catalogues: through each config's alias, then a sweep for door names
224
254
  const installs = [];
225
255
  for (const { file, cfg } of configs) {
@@ -229,12 +259,18 @@ export default {
229
259
  let catalogueNames = 0;
230
260
  if (uiAbs) {
231
261
  const rel = uiAbs.slice(root.length + 1).replace(/\\/g, '/');
232
- catalogueNames = files.code.filter((f) => f.startsWith(`${rel}/`) && CATALOGUE.has(stripExt(basename(f)))).length;
262
+ catalogueNames = files.code.filter((f) => f.startsWith(`${rel}/`) && CATALOGUE.has(doorName(f))).length;
233
263
  uiAbs = rel;
234
264
  }
235
265
  const pkg = readJSON(join(wsRoot, 'package.json')) ?? readJSON(join(root, 'package.json')) ?? {};
236
266
  const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
237
- const sheet = readSheet(root, wsRoot, cfg.tailwind?.css, files);
267
+ let sheet = readSheet(root, wsRoot, cfg.tailwind?.css, files);
268
+ // the named sheet is missing or holds no rows: read the stylesheet that
269
+ // carries the theme contract instead, and say so
270
+ if ((!sheet?.found || sheet.lightRows === 0) && contract && themeContract.file) {
271
+ const fallback = readSheet(root, root, themeContract.file, files);
272
+ if (fallback?.found && fallback.lightRows > 0) sheet = { ...fallback, configured: cfg.tailwind?.css ?? null, configuredFound: sheet?.found === true };
273
+ }
238
274
  installs.push({ config: file, uiDir: uiAbs, catalogueNames, kit: readKit(cfg, sheet, deps), sheet });
239
275
  }
240
276
  const swept = catalogueSweep(root, files);
@@ -247,7 +283,6 @@ export default {
247
283
  // catalogue folder. Without it: a catalogue by name AND the theme contract
248
284
  // in a stylesheet (5 or more of shadcn's named variables); names and
249
285
  // Radix alone describe half the React world.
250
- const contract = themeContract(root, files);
251
286
  const withConfigAndDir = installs.filter((i) => i.config && i.uiDir);
252
287
  const byName = installs.filter((i) => i.catalogueNames >= 8);
253
288
  if (!withConfigAndDir.length && !(byName.length && contract >= 5)) return null;
@@ -276,7 +311,7 @@ export default {
276
311
  const cssVars = kit ? kit.cssVariables : true;
277
312
  profile.designSystem = { kind: 'shadcn', name: 'shadcn/ui', confidence: 'high', cssVariables: cssVars };
278
313
  // kit doors installed as blocks into own code
279
- const blockFiles = files.code.filter((f) => /\.[jt]sx$/.test(f) && !profile.uiDirs.some((d) => f.startsWith(`${d}/`)) && BLOCK_COMPONENTS.has(stripExt(basename(f))));
314
+ const blockFiles = files.code.filter((f) => /\.[jt]sx$/.test(f) && !profile.uiDirs.some((d) => f.startsWith(`${d}/`)) && BLOCK_COMPONENTS.has(doorName(f)));
280
315
  // third-party registries installed beside the catalogue: a folder named
281
316
  // as the CLI creates it, or as a `registries` key in components.json
282
317
  // names it. Installed code, judged like the catalogue.
@@ -8,7 +8,7 @@ import { distinctTypefaces } from '../lib/typefaces.mjs';
8
8
  import { nearColorPairs } from './../lib/nearpairs.mjs';
9
9
  import { neverImportedComponents } from '../lib/neverimported.mjs';
10
10
  import { VERSION } from '../lib/version.mjs';
11
- import { profileOf } from '../profiles/index.mjs';
11
+ import { profileOf, installedDirs, splitArbitrary, ownSpacing } from '../profiles/index.mjs';
12
12
 
13
13
  export function rulesMarkdown(h, opts = {}) {
14
14
  // compact: the size-aware variant for agent files with tight practical
@@ -92,16 +92,18 @@ const repoName = h.profile?.name ?? 'this repo';
92
92
  .sort((a, b) => b.usageCount - a.usageCount).slice(0, CAP.components);
93
93
  if (top.length) {
94
94
  section('Canonical components');
95
- rule(compact
95
+ const fresh = profileOf(h).shadcn?.fresh?.fresh === true;
96
+ rule(compact || fresh
96
97
  ? 'Use these existing components instead of writing new ones:'
97
98
  : 'Use these existing components instead of writing new ones, the way this repo already uses them:');
99
+ if (fresh && !compact) lines.push(' (No code of your own uses the kit yet. The counts below are the components using each other.)');
98
100
  for (const c of top) {
99
101
  const props = !compact && c.propsHint?.named?.length ? ` · props: ${c.propsHint.named.slice(0, 4).join(', ')}` : '';
100
102
  lines.push(` - \`<${c.name}>\` from \`${c.file}\` (used ${c.usageCount}x${props})`);
101
103
  // Golden example: the repo's own most common real usage, quoted with a
102
104
  // receipt. Full variant only; compact hosts trade examples for size.
103
105
  const ex = c.usageExample;
104
- if (!compact && ex) {
106
+ if (!compact && !fresh && ex) {
105
107
  lines.push(` - most common usage, as in \`${ex.file}\` (matching ${ex.matches} of ${ex.total} usages): \`${ex.snippet}\``);
106
108
  }
107
109
  }
@@ -119,7 +121,9 @@ const repoName = h.profile?.name ?? 'this repo';
119
121
  const files = d.files.map((f) => (typeof f === 'string' ? f : f.file));
120
122
  const ranked = [...files].sort((a, b) => sharedScore(b) - sharedScore(a));
121
123
  const clear = sharedScore(ranked[0]) > sharedScore(ranked[1]);
122
- if (d.wrapped) {
124
+ if (d.upstream) {
125
+ rule(`\`<${d.name}>\` is defined by two shadcn components (${files.map((f) => `\`${f}\``).join(', ')}), upstream's overlap. Import whichever the surrounding code already uses; do not create a third.`);
126
+ } else if (d.wrapped) {
123
127
  rule(`\`<${d.name}>\` is defined twice and one wraps the other${clear ? `. Import \`${ranked[0]}\`` : ''}; do not create a third.`);
124
128
  } else if (clear) {
125
129
  rule(`\`<${d.name}>\` exists in ${files.length} places. Treat \`${ranked[0]}\` as canonical; do not import the other ${files.length === 2 ? 'copy' : 'copies'}, and never create another.`);
@@ -146,9 +150,12 @@ if (neverImported.length >= 3) {
146
150
  }
147
151
 
148
152
  // ---------- spacing ----------
149
- const arbitrary = t.tailwind?.arbitrary ?? [];
153
+ // On a kit the values inside installed folders are shadcn's own: the
154
+ // counts here are the team's, the shadcn section explains the rest.
155
+ const P0 = profileOf(h);
156
+ const arbitrary = P0.isShadcn ? splitArbitrary(t.tailwind?.arbitrary ?? [], installedDirs(P0)).own : (t.tailwind?.arbitrary ?? []);
150
157
  const arbCount = arbitrary.reduce((s, a) => s + a.count, 0);
151
- const offScale = (t.spacing ?? []).length;
158
+ const offScale = ownSpacing(h).css.length;
152
159
  if (t.tailwind?.spacing?.length || offScale || arbCount) {
153
160
  section('Spacing and sizing');
154
161
  if (t.tailwind?.spacing?.length) rule('Stay on the Tailwind spacing scale. If a gap looks wrong on a scale step, flag it instead of nudging by a pixel.');
@@ -173,7 +180,8 @@ if (neverImported.length >= 3) {
173
180
  const paint = sc.paint ?? null;
174
181
  section('shadcn: the components and the theme');
175
182
  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.');
183
+ if ((sc.sheet?.shadcnPresent ?? 0) >= 5 || sc.kit?.cssVariables === false) rule('Use the semantic classes the theme gives you (`bg-background`, `text-muted-foreground`, `border-border`), never a palette colour like `bg-blue-500` or `text-gray-600`, and never a hand-written `dark:` colour. The variables already carry both modes.');
184
+ 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.`);
177
185
  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
186
  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
187
  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.)`);