roast-my-design-system 7.3.3 → 7.4.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.3.2" 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.4.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.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
+
13
15
  > **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.
14
16
 
15
17
  > **New in 7.2: shadcn repos are read as shadcn repos.** The scanner finds the installed catalogue where it actually lives, reads the theme file `components.json` names, and prints how it decided under the report header. 2 new tiles appear on shadcn repos only, both from shadcn's own rules for agents: palette colours in your own code where a theme row exists, and kit components repainted through `className`. Counted per 100 of your own files, never inside the catalogue. The rules file gains a shadcn section. Scores on other repos do not move.
@@ -20,7 +22,7 @@ A free CLI tool (and Claude Code skill) that roasts your repo's design system wi
20
22
 
21
23
  Run it on your codebase and get, in about a second:
22
24
 
23
- - **A health score you can defend in a meeting.** 0 to 100, deterministic, and benchmarked against 3 yardsticks: Ideal Design System norms, 34 scanned public repos and 10 reputable design systems (Primer, Polaris, Carbon, shadcn/ui…).
25
+ - **A health score you can defend in a meeting.** It measures how safely an AI agent can build new UI on this repo without going off-system. Everything else on the report is receipts for humans. 0 to 100, deterministic, and benchmarked against 3 yardsticks: Ideal Design System norms, 34 scanned public repos and 10 reputable design systems (Primer, Polaris, Carbon, shadcn/ui…).
24
26
  - **Per-package scores for monorepos.** One blended number hides which package is the problem: `packages/ui` scores 80 while `apps/web` scores 40, and now you can see it.
25
27
  - **Reads React and web components alike.** Stencil, Lit and custom elements are detected by tag registration and counted by kebab tag. Libraries get composition maps, not adoption accusations. Earned token namespaces are named in the header. Anything the scan can not read is declared "not measured" instead of scored.
26
28
  - **The receipts behind it.** Every colour and its near-identical twin. Every spacing value, typeface, duplicated or never-imported component, inline style and !important. All with real file paths, in one self-contained HTML report you can open, Slack or email.
@@ -77,11 +79,11 @@ Not to be confused with each other: **"Why this matters"** is generic, ships wit
77
79
 
78
80
  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:
79
81
 
80
- ![The full diagnosis report for vercel/ai-chatbot in dark mode: health score, the line saying how the repo was read as a shadcn install, the What the numbers mean analysis written by Claude, priced Where to start moves each with its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, 3-yardstick tiles including the 2 shadcn tiles, the adoption map treemap, palette forensics, the shadcn theme variable by variable, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-full-dark.png?v=7.3.2)
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)
81
83
 
82
84
  The same report in light mode (one file, built-in toggle):
83
85
 
84
- ![The diagnosis report in light mode](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-light-hero.png?v=7.3.2)
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)
85
87
 
86
88
  ## What makes the numbers trustworthy
87
89
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "7.3.3",
3
+ "version": "7.4.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": [
@@ -91,8 +91,8 @@
91
91
  "note": "palette colours in own code where a theme variable exists, per 100 files"
92
92
  },
93
93
  "doorOverrides": {
94
- "value": 15,
95
- "note": "shadcn components given a colour or font through className, per 100 files"
94
+ "value": 2,
95
+ "note": "shadcn components given a colour through className, per 100 files; the tidiest half of 16 shadcn repos sit under this (2026-09-13 audit)"
96
96
  }
97
97
  },
98
98
  "stats": {
@@ -1852,7 +1852,7 @@
1852
1852
  },
1853
1853
  "slices": {
1854
1854
  "shadcn": {
1855
- "builtAt": "2026-09-12T19:02:27.297Z",
1855
+ "builtAt": "2026-09-12T20:07:59.374Z",
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": {
@@ -2074,27 +2074,27 @@
2074
2074
  },
2075
2075
  "arbitrary": {
2076
2076
  "values": [
2077
- 12,
2078
- 38,
2079
- 54,
2080
- 100,
2081
- 101,
2082
- 126,
2083
- 128,
2084
- 163,
2085
- 169,
2086
- 245,
2087
- 257,
2088
- 385,
2089
- 386,
2090
- 614,
2091
- 1625,
2092
- 1673
2077
+ 5,
2078
+ 25,
2079
+ 41,
2080
+ 74,
2081
+ 88,
2082
+ 99,
2083
+ 113,
2084
+ 113,
2085
+ 148,
2086
+ 204,
2087
+ 217,
2088
+ 360,
2089
+ 366,
2090
+ 569,
2091
+ 1186,
2092
+ 1602
2093
2093
  ],
2094
- "p25": 101,
2095
- "median": 166,
2096
- "p75": 385,
2097
- "p90": 1120
2094
+ "p25": 85,
2095
+ "median": 131,
2096
+ "p75": 362,
2097
+ "p90": 878
2098
2098
  },
2099
2099
  "nearPairs": {
2100
2100
  "values": [
@@ -2205,40 +2205,40 @@
2205
2205
  57,
2206
2206
  58,
2207
2207
  62,
2208
- 67,
2208
+ 66,
2209
2209
  100,
2210
- 183,
2210
+ 179,
2211
2211
  296,
2212
- 491
2212
+ 488
2213
2213
  ],
2214
2214
  "p25": 22,
2215
2215
  "median": 50,
2216
2216
  "p75": 75,
2217
- "p90": 240
2217
+ "p90": 238
2218
2218
  },
2219
2219
  "doorOverrides": {
2220
2220
  "values": [
2221
2221
  0,
2222
+ 0,
2223
+ 0,
2224
+ 1,
2225
+ 1,
2226
+ 1,
2227
+ 1,
2228
+ 1,
2229
+ 2,
2222
2230
  3,
2223
- 5,
2224
- 13,
2231
+ 4,
2232
+ 4,
2233
+ 6,
2225
2234
  13,
2226
- 15,
2227
- 16,
2228
- 16,
2229
- 17,
2230
- 18,
2231
- 22,
2232
2235
  26,
2233
- 28,
2234
- 31,
2235
- 44,
2236
- 55
2236
+ 32
2237
2237
  ],
2238
- "p25": 13,
2239
- "median": 17,
2240
- "p75": 27,
2241
- "p90": 38
2238
+ "p25": 1,
2239
+ "median": 2,
2240
+ "p75": 5,
2241
+ "p90": 20
2242
2242
  }
2243
2243
  },
2244
2244
  "repos": [
@@ -2260,13 +2260,13 @@
2260
2260
  "shadows": 10,
2261
2261
  "exactDuplicates": 134,
2262
2262
  "inlineStyles": 79,
2263
- "arbitrary": 257,
2263
+ "arbitrary": 217,
2264
2264
  "nearPairs": 18,
2265
2265
  "important": 157,
2266
2266
  "neverImported": 99,
2267
2267
  "components": 1453,
2268
2268
  "paintTin": 22,
2269
- "doorOverrides": 5
2269
+ "doorOverrides": 1
2270
2270
  }
2271
2271
  },
2272
2272
  {
@@ -2287,7 +2287,7 @@
2287
2287
  "shadows": 1,
2288
2288
  "exactDuplicates": 0,
2289
2289
  "inlineStyles": 0,
2290
- "arbitrary": 54,
2290
+ "arbitrary": 41,
2291
2291
  "nearPairs": 0,
2292
2292
  "important": 1,
2293
2293
  "neverImported": 94,
@@ -2314,7 +2314,7 @@
2314
2314
  "shadows": 2,
2315
2315
  "exactDuplicates": 1,
2316
2316
  "inlineStyles": 2,
2317
- "arbitrary": 12,
2317
+ "arbitrary": 5,
2318
2318
  "nearPairs": 0,
2319
2319
  "important": 2,
2320
2320
  "neverImported": 0,
@@ -2330,7 +2330,7 @@
2330
2330
  "baseColor": "neutral",
2331
2331
  "tailwind": "4",
2332
2332
  "codeFiles": 146,
2333
- "ownFiles": 60,
2333
+ "ownFiles": 68,
2334
2334
  "metrics": {
2335
2335
  "colors": 41,
2336
2336
  "greys": 0,
@@ -2341,13 +2341,13 @@
2341
2341
  "shadows": 4,
2342
2342
  "exactDuplicates": 1,
2343
2343
  "inlineStyles": 7,
2344
- "arbitrary": 100,
2344
+ "arbitrary": 74,
2345
2345
  "nearPairs": 0,
2346
2346
  "important": 9,
2347
2347
  "neverImported": 0,
2348
2348
  "components": 316,
2349
- "paintTin": 67,
2350
- "doorOverrides": 18
2349
+ "paintTin": 66,
2350
+ "doorOverrides": 1
2351
2351
  }
2352
2352
  },
2353
2353
  {
@@ -2368,13 +2368,13 @@
2368
2368
  "shadows": 0,
2369
2369
  "exactDuplicates": 1,
2370
2370
  "inlineStyles": 11,
2371
- "arbitrary": 126,
2371
+ "arbitrary": 113,
2372
2372
  "nearPairs": 2,
2373
2373
  "important": 0,
2374
2374
  "neverImported": 100,
2375
2375
  "components": 272,
2376
2376
  "paintTin": 33,
2377
- "doorOverrides": 17
2377
+ "doorOverrides": 4
2378
2378
  }
2379
2379
  },
2380
2380
  {
@@ -2395,13 +2395,13 @@
2395
2395
  "shadows": 0,
2396
2396
  "exactDuplicates": 16,
2397
2397
  "inlineStyles": 74,
2398
- "arbitrary": 245,
2398
+ "arbitrary": 204,
2399
2399
  "nearPairs": 18,
2400
2400
  "important": 0,
2401
2401
  "neverImported": 44,
2402
2402
  "components": 811,
2403
2403
  "paintTin": 62,
2404
- "doorOverrides": 22
2404
+ "doorOverrides": 6
2405
2405
  }
2406
2406
  },
2407
2407
  {
@@ -2422,13 +2422,13 @@
2422
2422
  "shadows": 6,
2423
2423
  "exactDuplicates": 61,
2424
2424
  "inlineStyles": 24,
2425
- "arbitrary": 169,
2425
+ "arbitrary": 113,
2426
2426
  "nearPairs": 8,
2427
2427
  "important": 55,
2428
2428
  "neverImported": 2,
2429
2429
  "components": 1085,
2430
2430
  "paintTin": 296,
2431
- "doorOverrides": 31
2431
+ "doorOverrides": 26
2432
2432
  }
2433
2433
  },
2434
2434
  {
@@ -2449,13 +2449,13 @@
2449
2449
  "shadows": 0,
2450
2450
  "exactDuplicates": 102,
2451
2451
  "inlineStyles": 120,
2452
- "arbitrary": 163,
2452
+ "arbitrary": 148,
2453
2453
  "nearPairs": 11,
2454
2454
  "important": 0,
2455
2455
  "neverImported": 5,
2456
2456
  "components": 1137,
2457
2457
  "paintTin": 5,
2458
- "doorOverrides": 16
2458
+ "doorOverrides": 1
2459
2459
  }
2460
2460
  },
2461
2461
  {
@@ -2476,13 +2476,13 @@
2476
2476
  "shadows": 0,
2477
2477
  "exactDuplicates": 17,
2478
2478
  "inlineStyles": 22,
2479
- "arbitrary": 38,
2479
+ "arbitrary": 25,
2480
2480
  "nearPairs": 1,
2481
2481
  "important": 0,
2482
2482
  "neverImported": 19,
2483
2483
  "components": 700,
2484
2484
  "paintTin": 100,
2485
- "doorOverrides": 3
2485
+ "doorOverrides": 1
2486
2486
  }
2487
2487
  },
2488
2488
  {
@@ -2492,7 +2492,7 @@
2492
2492
  "baseColor": "gray",
2493
2493
  "tailwind": "3",
2494
2494
  "codeFiles": 1379,
2495
- "ownFiles": 531,
2495
+ "ownFiles": 534,
2496
2496
  "metrics": {
2497
2497
  "colors": 66,
2498
2498
  "greys": 14,
@@ -2503,13 +2503,13 @@
2503
2503
  "shadows": 1,
2504
2504
  "exactDuplicates": 20,
2505
2505
  "inlineStyles": 47,
2506
- "arbitrary": 386,
2506
+ "arbitrary": 360,
2507
2507
  "nearPairs": 7,
2508
2508
  "important": 42,
2509
2509
  "neverImported": 13,
2510
2510
  "components": 777,
2511
- "paintTin": 491,
2512
- "doorOverrides": 55
2511
+ "paintTin": 488,
2512
+ "doorOverrides": 32
2513
2513
  }
2514
2514
  },
2515
2515
  {
@@ -2519,7 +2519,7 @@
2519
2519
  "baseColor": "slate",
2520
2520
  "tailwind": "3",
2521
2521
  "codeFiles": 2120,
2522
- "ownFiles": 340,
2522
+ "ownFiles": 350,
2523
2523
  "metrics": {
2524
2524
  "colors": 254,
2525
2525
  "greys": 52,
@@ -2530,13 +2530,13 @@
2530
2530
  "shadows": 0,
2531
2531
  "exactDuplicates": 21,
2532
2532
  "inlineStyles": 5,
2533
- "arbitrary": 128,
2533
+ "arbitrary": 99,
2534
2534
  "nearPairs": 21,
2535
2535
  "important": 9,
2536
2536
  "neverImported": 0,
2537
2537
  "components": 911,
2538
- "paintTin": 183,
2539
- "doorOverrides": 15
2538
+ "paintTin": 179,
2539
+ "doorOverrides": 3
2540
2540
  }
2541
2541
  },
2542
2542
  {
@@ -2557,13 +2557,13 @@
2557
2557
  "shadows": 0,
2558
2558
  "exactDuplicates": 25,
2559
2559
  "inlineStyles": 42,
2560
- "arbitrary": 101,
2560
+ "arbitrary": 88,
2561
2561
  "nearPairs": 0,
2562
2562
  "important": 0,
2563
2563
  "neverImported": 1,
2564
2564
  "components": 246,
2565
2565
  "paintTin": 42,
2566
- "doorOverrides": 13
2566
+ "doorOverrides": 4
2567
2567
  }
2568
2568
  },
2569
2569
  {
@@ -2584,13 +2584,13 @@
2584
2584
  "shadows": 0,
2585
2585
  "exactDuplicates": 14,
2586
2586
  "inlineStyles": 47,
2587
- "arbitrary": 385,
2587
+ "arbitrary": 366,
2588
2588
  "nearPairs": 10,
2589
2589
  "important": 12,
2590
2590
  "neverImported": 0,
2591
2591
  "components": 1204,
2592
2592
  "paintTin": 21,
2593
- "doorOverrides": 26
2593
+ "doorOverrides": 0
2594
2594
  }
2595
2595
  },
2596
2596
  {
@@ -2611,13 +2611,13 @@
2611
2611
  "shadows": 10,
2612
2612
  "exactDuplicates": 65,
2613
2613
  "inlineStyles": 58,
2614
- "arbitrary": 614,
2614
+ "arbitrary": 569,
2615
2615
  "nearPairs": 16,
2616
2616
  "important": 42,
2617
2617
  "neverImported": 30,
2618
2618
  "components": 1494,
2619
2619
  "paintTin": 58,
2620
- "doorOverrides": 28
2620
+ "doorOverrides": 2
2621
2621
  }
2622
2622
  },
2623
2623
  {
@@ -2638,13 +2638,13 @@
2638
2638
  "shadows": 2,
2639
2639
  "exactDuplicates": 104,
2640
2640
  "inlineStyles": 117,
2641
- "arbitrary": 1673,
2641
+ "arbitrary": 1186,
2642
2642
  "nearPairs": 21,
2643
2643
  "important": 0,
2644
2644
  "neverImported": 26,
2645
2645
  "components": 971,
2646
2646
  "paintTin": 32,
2647
- "doorOverrides": 44
2647
+ "doorOverrides": 1
2648
2648
  }
2649
2649
  },
2650
2650
  {
@@ -2665,13 +2665,13 @@
2665
2665
  "shadows": 14,
2666
2666
  "exactDuplicates": 288,
2667
2667
  "inlineStyles": 263,
2668
- "arbitrary": 1625,
2668
+ "arbitrary": 1602,
2669
2669
  "nearPairs": 59,
2670
2670
  "important": 93,
2671
2671
  "neverImported": 23,
2672
2672
  "components": 3630,
2673
2673
  "paintTin": 8,
2674
- "doorOverrides": 16
2674
+ "doorOverrides": 0
2675
2675
  }
2676
2676
  }
2677
2677
  ]
@@ -31,8 +31,8 @@ import { feedbackUrl, FEEDBACK_ASK, FEEDBACK_CTA } from '../lib/feedback.mjs';
31
31
  import { fixPrompt } from '../lib/fixprompt.mjs';
32
32
  import { WHY } from './why.mjs';
33
33
  import { parseColor, luminance, isGrey } from '../lib/color.mjs';
34
- import { loadBenchmark, benchHelpers, makeHealthOf, coreMetrics, tileHealths, scoreOfTiles, scorePackage as scorePackageOf, ZERO_IDEAL, WARN_TOLERANCE, SCORE_OF, SCHEMA_VERSION } from './score.mjs';
35
- import { profileOf } from '../profiles/index.mjs';
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';
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.
@@ -234,7 +234,7 @@ const typefaces = distinctTypefaces(fontFamilies);
234
234
  const { colorTokens, colorStrays, tokenLed, greyStrays } = M;
235
235
 
236
236
  // Arbitrary bracket values (p-[13px], text-[10px]) — scale erosion, counted.
237
- const arbitrary = h.tokens.tailwind?.arbitrary ?? [];
237
+ let arbitrary = h.tokens.tailwind?.arbitrary ?? [];
238
238
  const arbitraryCount = M.arbitrary;
239
239
 
240
240
  // !important declarations: the cascade admitting defeat.
@@ -248,6 +248,10 @@ const nearPairs = nearColorPairs(colors);
248
248
  const collisions = h.tokens.tokenCollisions ?? [];
249
249
 
250
250
  const P = profileOf(h);
251
+ // On a shadcn repo the bracket list is the team's own; installed brackets are
252
+ // named in the side panel instead (see coreMetrics for the count).
253
+ if (P.isShadcn) arbitrary = splitArbitrary(arbitrary, installedDirs(P)).own;
254
+ const breakdown = scoreBreakdown(h, bench);
251
255
  const neverImported = neverImportedComponents(h.components, P.uiDir);
252
256
 
253
257
  // Measurability and kind, decided once in profiles/ (telekom/scale, 2026-09-01):
@@ -339,7 +343,7 @@ const FALLBACK_TARGET = {
339
343
  colors: 'a system needs ~24', greys: 'a scale has up to 13', spacing: 'a dozen deliberate exceptions',
340
344
  exactDuplicates: 'should be 0', inlineStyles: 'invisible to any system', nearPairs: '1 colour recorded twice',
341
345
  important: 'forces a style through', neverImported: 'the system nobody found', arbitrary: 'a handful of deliberate exceptions',
342
- paintTin: 'the theme file already has a variable for it', doorOverrides: 'use a variant instead',
346
+ paintTin: 'the theme file already has a variable for it', doorOverrides: 'use a variant, or add one',
343
347
  };
344
348
  // Dress one judged tile for the page: formatted number, comparison rows.
345
349
  // Accepts a judged tile from score.mjs, or the positional form the
@@ -549,7 +553,9 @@ function collisionsTrap() {
549
553
  return trapBox(`--${esc(top.name)} is ${where(a)}, and ${where(b)}. One name, two colours, and nothing in the code says which is the real one. An agent reads whichever package it opened, so the brand depends on where it started.${rest}`);
550
554
  }
551
555
  function orphansTrap() {
552
- if (!componentsMeasured || isLibrary) return '';
556
+ // Catalogue stock is protective, not a trap: an agent reaches for it
557
+ // instead of building its own version (Sahaj Jain, 2026-09-09).
558
+ if (!componentsMeasured || isLibrary || vendoredUi) return '';
553
559
  if (neverImported.length < 10) return '';
554
560
  return trapBox(`${neverImported.length} components are never imported anywhere. An agent searching for a Button finds the unused ones next to the real one with nothing to tell them apart, so an old dead end becomes today's example.`);
555
561
  }
@@ -607,7 +613,8 @@ function sheetSection() {
607
613
  parts.push(`<div class="receipts">${eyebrow(`${n(paint.tin.uses)} colours from outside the theme in ${n(paint.tin.files)} of ${n(paint.ownFiles)} own files · ${n(paint.tin.per100)} per 100 files`)}${paint.tin.uses ? `<div class="chips-row">${tinChips}</div><div class="chips-row">${tinFiles}</div>` : '<p class="sub">Your own code takes every colour from the theme file. This is what shadcn is designed for.</p>'}${rp?.tinUses ? `<p class="sub">Not counted above: ${n(rp.tinUses)} palette colour${rp.tinUses === 1 ? '' : 's'} inside ${esc(rp.dirs.map((d) => basename(d)).join(', '))}, installed by a registry rather than written here. An agent reading those files will still copy them.</p>` : ''}</div>`);
608
614
  parts.push(whyToggle('paintTin'));
609
615
  const doorChips = (paint.doors.samples ?? []).slice(0, 6).map((s) => `<span class="vchip bad">${esc(s.value)} ×${s.count}</span>`).join('');
610
- parts.push(`<div class="receipts">${eyebrow(`${n(paint.doors.uses)} shadcn components restyled through className · ${n(paint.doors.per100)} per 100 files`)}${paint.doors.uses ? `<div class="chips-row">${doorChips}</div>` : '<p class="sub">No shadcn component is given a colour or a font through className. Variants are doing their job.</p>'}</div>`);
616
+ const typoChips = (paint.typo?.samples ?? []).slice(0, 6).map((s) => `<span class="vchip">${esc(s.value)} ×${s.count}</span>`).join('');
617
+ parts.push(`<div class="receipts">${eyebrow(`${n(paint.doors.uses)} shadcn components recoloured through className · ${n(paint.doors.per100)} per 100 files`)}${paint.doors.uses ? `<div class="chips-row">${doorChips}</div>` : '<p class="sub">No shadcn component is given a colour through className. Variants are doing their job.</p>'}${paint.typo?.uses ? `<p class="sub" style="margin-top:10px">Shown, not scored: ${n(paint.typo.uses)} shadcn components given a text size or weight through className, ${n(paint.typo.per100)} per 100 files. shadcn asks for a variant here too, but every shadcn repo does this at the same rate, so it says nothing about yours.</p><div class="chips-row">${typoChips}</div>` : ''}</div>`);
611
618
  parts.push(whyToggle('doorOverrides'));
612
619
  }
613
620
  if (!parts.length) return '';
@@ -975,9 +982,15 @@ function extraSectionsHtml() {
975
982
  // buttons hold, published in --summary so the MCP server's roast-fix prompt
976
983
  // hands out byte-identical text. One composer, two doors.
977
984
  let startMoves = [];
985
+ let startProjection = null;
978
986
 
979
987
  function whereToStartSection() {
980
988
  const c = [];
989
+ if (P.isShadcn && (P.shadcn?.sheet?.customUnused ?? []).length) {
990
+ const dead = P.shadcn.sheet.customUnused;
991
+ c.push({ score: 40, metric: null, title: `Delete the ${dead.length} theme variable${dead.length === 1 ? '' : 's'} nothing uses`,
992
+ sub: `${dead.slice(0, 3).map((r) => `--${esc(r)}`).join(', ')}${dead.length > 3 ? ' and more' : ''} sit in ${esc(P.shadcn.sheet.file)} next to the real theme, and nothing in the repo reads them. An agent opening that file sees 2 colour systems and can not tell which one is dead. ${dead.length * 2} lines to delete; the cheapest fix on this page.` });
993
+ }
981
994
  if (agentFiles.length === 0) c.push({ score: 60, metric: null, title: 'Write the agent rules file',
982
995
  sub: `No CLAUDE.md, no AGENTS.md. One page naming the canonical components and the tokens file stops your agent guessing on every UI change. Cheapest fix on this list.` });
983
996
  if (componentsMeasured && hardDupes.length > 0) {
@@ -1075,8 +1088,8 @@ function whereToStartSection() {
1075
1088
  if (pt.doors.uses >= 5) {
1076
1089
  const s0 = pt.doors.samples[0];
1077
1090
  c.push({ score: 15 + pt.doors.per100 / 4, metric: 'doorOverrides', after: 0,
1078
- title: `Stop restyling shadcn components through className`,
1079
- sub: `${n(pt.doors.uses)} shadcn components receive a colour or a font through className, like ${esc(s0.value)}. Use the variant that does it, or add one to the component you own. className on a shadcn component is for layout only.` });
1091
+ title: `Stop recolouring shadcn components through className`,
1092
+ sub: `${n(pt.doors.uses)} shadcn components receive a colour through className, like ${esc(s0.value)}. Use the variant that does it, or add one to the component you own. className on a shadcn component is for layout only.` });
1080
1093
  }
1081
1094
  }
1082
1095
 
@@ -1100,6 +1113,7 @@ function whereToStartSection() {
1100
1113
  const applied = new Map();
1101
1114
  for (const item of top3) if (item.metric && item.after !== undefined && item.delta > 0) applied.set(item.metric, item.after);
1102
1115
  const after = projectedScore(applied);
1116
+ startProjection = { count: top3.length, after };
1103
1117
  const words = ['One tweak', 'Two tweaks', 'Three tweaks'][top3.length - 1];
1104
1118
  const head = healthScore !== null && after > healthScore
1105
1119
  ? `${words} · <b class="proj">${healthScore} &rarr; ${after}</b>`
@@ -1265,9 +1279,7 @@ function shadcnReceipt() {
1265
1279
  ].filter(Boolean);
1266
1280
  const unmatched = (k?.fellBack ?? []).filter((f) => f === 'theme' || f === 'chartColor');
1267
1281
  const fell = unmatched.length ? ` · ${unmatched.length === 2 ? 'accent and chart colour' : unmatched[0] === 'theme' ? 'accent' : 'chart colour'} not matched to a named shadcn theme` : '';
1268
- const rp = P.shadcn?.registryPaint ?? null;
1269
- const regLine = rp ? `<div class="excl">Installed registries kept out of the own-code counts: ${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'} of their own` : ''}). Installed code, not written here, but your agent reads it like everything else and copies what it finds there.</div>` : '';
1270
- 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>${regLine}`;
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>`;
1271
1283
  }
1272
1284
 
1273
1285
  // User exclusions are printed in the header, never hidden: a scoped scan must
@@ -1286,6 +1298,85 @@ function exclusionsLine() {
1286
1298
  return `<div class="excl">${parts.join(' · ')} · ${n(total)} files kept out of this scan</div>`;
1287
1299
  }
1288
1300
 
1301
+
1302
+ // ---------- the side panel (fixed left column, 2026-09 template) ----------
1303
+ // Score, the definition it measures, the stack, how the repo was read, the
1304
+ // index of what rendered, and what is not the team's and not counted.
1305
+ function sidePanel() {
1306
+ const date = esc((h.harvestedAt ?? '').slice(0, 10));
1307
+ const def = 'How safely an AI agent can build on this repo without going off-system.';
1308
+ const lift = startProjection && healthScore !== null && startProjection.after > healthScore
1309
+ ? ` <b>${['One fix lifts', 'Two fixes lift', 'Three fixes lift'][startProjection.count - 1] ?? 'The fixes lift'} it to ${startProjection.after}.</b>` : '';
1310
+ const rp = P.shadcn?.registryPaint ?? null;
1311
+ const bd = breakdown.ownScore !== null && breakdown.installedPoints > 0 && rp
1312
+ ? `<div class="bd">Of which <b>${breakdown.installedPoints} point${breakdown.installedPoints === 1 ? '' : 's'}</b> come from installed code you did not write: ${esc(rp.dirs.map((d) => basename(d)).join(', '))} (${n(rp.files)} file${rp.files === 1 ? '' : 's'}, ${n(rp.tinUses)} palette colour${rp.tinUses === 1 ? '' : 's'}). Your own code alone would score <b>${breakdown.ownScore}</b>. Kept in the score because your agent reads those files like everything else; left out of the fixes because they are not yours to edit.</div>` : '';
1313
+ const scoreBlock = healthScore !== null
1314
+ ? `<div class="score${noSystemLikely ? ' muted' : ''}">${eyebrow('Health score')}<div class="val">${healthScore}<span class="slash">/</span><span class="of">100</span></div>${noSystemLikely ? '<div class="note">little here to score · see the note</div>' : ''}<div class="def">${def}${lift}</div>${bd}</div>` : '';
1315
+ const chips = `<div class="chips">${stack.map((c) => `<span class="chip">${esc(c)}</span>`).join('')}${dsUnrecognised ? '<span class="chip chip-dim">design system: unrecognised</span>' : ''}${legacyChip ? `<span class="chip chip-dim">${esc(legacyChip)}</span>` : ''}${agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)}</span>`).join('')}</div>`;
1316
+ const facts = [
1317
+ shadcnReceipt(),
1318
+ exclusionsLine(),
1319
+ commissionedBy ? `<div class="excl">Commissioned by <b>${esc(commissionedBy)}</b></div>` : '',
1320
+ ].join('');
1321
+ return `<aside class="side">
1322
+ <div class="eyebrow">Design-system diagnosis</div>
1323
+ <div class="repo"><span class="mono">${esc(repoName)}</span></div>
1324
+ <div class="scanned">Scanned ${date} · ${n(h.files.code)} code files · ${h.tookMs}ms</div>
1325
+ ${scoreBlock}
1326
+ ${chips}
1327
+ <div class="facts">${facts}</div>
1328
+ <div class="eyebrow idx-head">Index</div>
1329
+ <nav class="idx" id="idx"></nav>
1330
+ ${exceptionsBlock()}
1331
+ <div class="side-foot">roast-my-design-system ${VERSION}</div>
1332
+ </aside>`;
1333
+ }
1334
+
1335
+ // What is not the team's, and how it is treated. Every exclusion is named
1336
+ // here with what it carries and the honest caveat: the agent reads it anyway.
1337
+ function exceptionsBlock() {
1338
+ const lines = [];
1339
+ if (P.isShadcn) {
1340
+ const ai = P.shadcn?.arbitraryInstalled;
1341
+ 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.`);
1342
+ 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
+ const rp = P.shadcn?.registryPaint;
1344
+ 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
+ } else if (isLibrary) {
1346
+ lines.push('A library: unused components are internal-only, and downstream consumers are invisible from here.');
1347
+ }
1348
+ if (!lines.length) return '';
1349
+ return `<div class="excs"><b>Not yours, and not counted</b>${lines.map((l) => `<p>${l}</p>`).join('')}</div>`;
1350
+ }
1351
+
1352
+ // The index is built from what rendered: every section with a heading gets
1353
+ // a stable id, in page order, and the side panel lists them. Nothing here is
1354
+ // hand-maintained, so a section that did not render is never listed.
1355
+ function addIndex(page) {
1356
+ // heading text is already HTML-escaped; strip tags and leading counts only
1357
+ const short = (t) => t.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim().replace(/^[\d,]+ /, '')
1358
+ .replace(/^What your AI agent sees today$/, 'What your agent sees').replace(/^You sat through the roast$/, 'Your present')
1359
+ .replace(/^The shadcn theme and the 2 shadcn checks$/, 'shadcn theme and checks').replace(/, declared .*$/, '')
1360
+ .replace(/^off-scale spacing values$/, 'Off-scale spacing').replace(/^inline style blocks?$/, 'Inline styles').replace(/^typefaces?$/, 'Typefaces');
1361
+ const entries = [];
1362
+ let i = 0;
1363
+ const body = page.replace(/<section\b([^>]*)>([\s\S]*?)(?=<section\b|<footer>|<\/main>)/g, (m, attrs, inner) => {
1364
+ const h2 = inner.match(/<h2>([\s\S]*?)<\/h2>/);
1365
+ if (!h2) return m;
1366
+ i += 1;
1367
+ let id = (attrs.match(/\bid="([^"]+)"/) ?? [])[1];
1368
+ let open = attrs;
1369
+ if (!id) { id = `s-${i}`; open = ` id="${id}"${attrs}`; }
1370
+ entries.push({ id, text: short(h2[1]) });
1371
+ return `<section${open}>${inner}`;
1372
+ });
1373
+ const nav = entries.map((e, k) => `<a href="#${e.id}"><i>${String(k + 1).padStart(2, '0')}</i>${e.text}</a>`).join('');
1374
+ return body.replace('<nav class="idx" id="idx"></nav>', `<nav class="idx" id="idx">${nav}</nav>`);
1375
+ }
1376
+
1377
+ // Rendered first: the side panel quotes its projection.
1378
+ const whereToStartHtml = whereToStartSection();
1379
+
1289
1380
  const html = `<!doctype html>
1290
1381
  <html lang="en" data-theme="${themeName}"><head><meta charset="utf-8">
1291
1382
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -1302,7 +1393,34 @@ const html = `<!doctype html>
1302
1393
  /* fixed gradient wash on its own layer: background-attachment:fixed +
1303
1394
  backdrop-filter makes Chromium skip repaints on scroll */
1304
1395
  body::before { content:''; position:fixed; inset:0; z-index:-1; background:var(--page-grad), var(--bg); }
1305
- .wrap { max-width:1120px; margin:0 auto; padding:48px 28px 72px; }
1396
+ .wrap { zoom:0.9; }
1397
+ /* two columns: a fixed side panel and the scrolling main column */
1398
+ .side { position:fixed; left:0; top:0; bottom:0; width:300px; padding:22px 24px; overflow:auto; z-index:5;
1399
+ border-right:1px solid var(--line); background:var(--card-solid, var(--bg)); }
1400
+ .main { margin-left:300px; padding:22px 40px 60px; max-width:1080px; }
1401
+ .side .repo { font:600 20px/1.25 var(--disp); letter-spacing:-.01em; margin:4px 0 4px; word-break:break-all; }
1402
+ .side .repo .mono { font-size:17px; }
1403
+ .side .scanned { font:500 11.5px/1.5 var(--sans); color:var(--dim); margin:0 0 16px; }
1404
+ .side .score { text-align:left; margin:0 0 16px; }
1405
+ .side .score .def { font:500 11.5px/1.5 var(--sans); color:var(--dim); margin-top:8px; }
1406
+ .side .score .def b { color:var(--text); font-weight:700; }
1407
+ .side .score .bd { font:500 11.5px/1.5 var(--sans); color:var(--dim); margin-top:8px; padding-top:8px; border-top:1px solid var(--line); }
1408
+ .side .score .bd b { color:var(--text); }
1409
+ .side .chips { margin:0 0 14px; gap:5px; }
1410
+ .side .facts { font:500 11.5px/1.55 var(--sans); color:var(--dim); margin-bottom:16px; }
1411
+ .side .facts .excl { margin-top:6px; font-size:11.5px; }
1412
+ .side .facts b { color:var(--text); font-weight:600; }
1413
+ .side .idx-head { margin-bottom:4px; }
1414
+ .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); }
1415
+ .side .idx a:hover, .side .idx a.on { color:var(--accent); border-left-color:var(--accent); }
1416
+ .side .idx a i { font-style:normal; font-size:10.5px; margin-right:8px; opacity:.6; }
1417
+ .side .excs { margin-top:16px; padding-top:12px; border-top:1px solid var(--line); font:500 11.5px/1.55 var(--sans); color:var(--dim); }
1418
+ .side .excs b { color:var(--text); display:block; margin-bottom:4px; }
1419
+ .side .excs p { margin:0 0 6px; }
1420
+ .side .side-foot { margin-top:18px; font:500 11px/1.5 var(--sans); color:var(--dim2, var(--dim)); }
1421
+ .main > .verdict-card { margin-top:0; }
1422
+ @media (max-width: 900px) { .side { position:static; width:auto; border-right:0; border-bottom:1px solid var(--line); } .main { margin-left:0; padding:20px; } }
1423
+ @media print { .side { position:static; width:auto; border-right:0; } .main { margin-left:0; } .wrap { zoom:1; } }
1306
1424
 
1307
1425
  .glass { background:var(--card); backdrop-filter:blur(24px) saturate(140%); -webkit-backdrop-filter:blur(24px) saturate(140%);
1308
1426
  border:1px solid var(--line); border-radius:16px;
@@ -1344,8 +1462,8 @@ const html = `<!doctype html>
1344
1462
 
1345
1463
  .chips { display:flex; flex-wrap:wrap; gap:8px; margin-top:18px; }
1346
1464
  .chip { border:1px solid var(--line); background:var(--card); border-radius:99px;
1347
- padding:4px 12px; font:500 11.5px/1.5 var(--sans); color:var(--text); }
1348
- .chip-agent { background:var(--text); color:var(--bg); border-color:var(--text); font-weight:600; }
1465
+ padding:3px 8px; font:500 10.5px/1.5 var(--sans); color:var(--text); }
1466
+ .chip-agent { font-weight:600; }
1349
1467
  .coverage { margin-top:12px; }
1350
1468
  .cov-yes { color:var(--ok); font-weight:700; }
1351
1469
  .cov-no { color:var(--coral); font-weight:700; }
@@ -1681,32 +1799,20 @@ const html = `<!doctype html>
1681
1799
  </button>
1682
1800
  <script>try{var t=localStorage.getItem('roast-theme');if(t==='light'||t==='dark')document.documentElement.setAttribute('data-theme',t)}catch(e){}</script>
1683
1801
  <div class="wrap">
1684
-
1685
- <header>
1686
- <div class="hero">
1687
- <div>
1688
- <div class="kicker"><span class="dot"></span>Scan complete · ${h.tookMs}ms · ${n(h.files.code)} code files</div>
1689
- <h1>Design System Diagnosis</h1>
1690
- <div class="meta"><span class="mono">${esc(repoName)}</span> · scanned ${esc((h.harvestedAt ?? '').slice(0, 10))}${commissionedBy ? ` · commissioned by ${esc(commissionedBy)}` : ''}</div>
1691
- </div>
1692
- ${healthScore !== null ? `<div class="score${noSystemLikely ? ' muted' : ''}">${eyebrow('Health score')}<div class="val">${healthScore}<span class="slash">/</span><span class="of">100</span></div>${noSystemLikely ? '<div class="note">little here to score · see the note below</div>' : ''}</div>` : ''}
1693
- </div>
1694
- <div class="chips">${stack.map((s) => `<span class="chip">${esc(s)}</span>`).join('')}${dsUnrecognised ? '<span class="chip chip-dim">design system: unrecognised</span>' : ''}${legacyChip ? `<span class="chip chip-dim">${esc(legacyChip)}</span>` : ''}${agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)}</span>`).join('')}</div>
1695
- ${shadcnReceipt()}
1696
- ${exclusionsLine()}
1802
+ ${sidePanel()}
1803
+ <main class="main">
1697
1804
  ${noSystemLikely ? `<div class="nods">${ICONS.warn}<span>There is most likely <b>no design system in this repo</b>: almost no colour or spacing values were found. Styling may live outside this codebase (CDN stylesheets, a parent repo, or generated output).</span></div>` : ''}
1698
1805
  <div class="glass verdict-card">
1699
1806
  <div class="blob b1"></div><div class="blob b2"></div>
1700
1807
  ${eyebrow('Summary')}
1701
1808
  <div class="verdict">${esc(verdict)}</div>
1702
1809
  </div>
1703
- </header>
1704
1810
 
1705
1811
  ${notesSection()}
1706
1812
 
1707
1813
  ${extraSectionsHtml()}
1708
1814
 
1709
- ${whereToStartSection()}
1815
+ ${whereToStartHtml}
1710
1816
 
1711
1817
  ${agentSection()}
1712
1818
 
@@ -1737,6 +1843,7 @@ ${componentsSection()}
1737
1843
  </div>
1738
1844
  <span class="creds"><span>Non-destructive scan</span><span>Read-only</span><span>Paths are real</span></span>
1739
1845
  </footer>
1846
+ </main>
1740
1847
  <script>
1741
1848
  (function(){
1742
1849
  var gift=document.getElementById('gift'); if(!gift) return;
@@ -1831,7 +1938,7 @@ ${componentsSection()}
1831
1938
  </script>
1832
1939
  </div></body></html>`;
1833
1940
 
1834
- writeFileSync(outPath, html);
1941
+ writeFileSync(outPath, addIndex(html));
1835
1942
 
1836
1943
  // Machine-readable summary for wrappers (the npx CLI reads this instead of
1837
1944
  // parsing the HTML): --summary <path> writes score, verdict and per-tile health.
@@ -1849,6 +1956,7 @@ if (summaryPath) {
1849
1956
  ...(extraSections.length ? { sectionsEmbedded: extraSections.map((s) => s.title) } : {}),
1850
1957
  ...(h.exclusions ? { exclusions: h.exclusions } : {}),
1851
1958
  score: healthScore,
1959
+ ...(breakdown.ownScore !== null ? { ownCodeScore: breakdown.ownScore, installedPoints: breakdown.installedPoints } : {}),
1852
1960
  noSystemLikely,
1853
1961
  verdict,
1854
1962
  role: P.role,
@@ -26,7 +26,7 @@ import { isGrey } from '../lib/color.mjs';
26
26
  import { nearColorPairs } from '../lib/nearpairs.mjs';
27
27
  import { neverImportedComponents } from '../lib/neverimported.mjs';
28
28
  import { SCHEMA_VERSION } from '../lib/version.mjs';
29
- import { profileOf } from '../profiles/index.mjs';
29
+ import { profileOf, installedDirs, splitArbitrary } from '../profiles/index.mjs';
30
30
 
31
31
  export { SCHEMA_VERSION };
32
32
 
@@ -102,7 +102,7 @@ export function makeHealthOf(b) {
102
102
  export const PROFILE_TILES = {
103
103
  shadcn: [
104
104
  ['paintTin', 'off-theme colours per 100 files'],
105
- ['doorOverrides', 'components restyled from outside per 100 files'],
105
+ ['doorOverrides', 'components recoloured from outside per 100 files'],
106
106
  ],
107
107
  };
108
108
  export const tilesFor = (kind) => [...TILES, ...(PROFILE_TILES[kind] ?? [])];
@@ -124,7 +124,14 @@ export const TILES = [
124
124
  * The numbers every judgement rests on, read once from a harvest. Plain
125
125
  * integers and flags; the same formulas the per-package pass uses.
126
126
  */
127
- export function coreMetrics(h) {
127
+ export function coreMetrics(h, opts = {}) {
128
+ const P = profileOf(h);
129
+ // On a shadcn repo the bracket count is the team's own: brackets inside the
130
+ // catalogue, kit blocks and registries are installed choices, not drift.
131
+ const arbitraryEntries = P.isShadcn ? splitArbitrary(h.tokens?.tailwind?.arbitrary ?? [], installedDirs(P)).own : (h.tokens?.tailwind?.arbitrary ?? []);
132
+ // ownCode: the breakdown under the score. Same metrics, paint counted on
133
+ // own code only, so the difference is what installed registries cost.
134
+ const paint = opts.ownCode ? (P.shadcn?.paintOwn ?? P.shadcn?.paint) : P.shadcn?.paint;
128
135
  const colors = h.tokens?.colors ?? [];
129
136
  const greys = colors.filter((c) => isGrey(c.value));
130
137
  const colorTokens = colors.filter((c) => c.isToken).length;
@@ -151,7 +158,7 @@ export function coreMetrics(h) {
151
158
  nearPairs: nearColorPairs(colors).length,
152
159
  important: h.tokens?.important?.count ?? 0,
153
160
  neverImported: neverImportedComponents(h.components, profileOf(h).uiDir).length,
154
- arbitrary: (h.tokens?.tailwind?.arbitrary ?? []).reduce((sum, a) => sum + a.count, 0),
161
+ arbitrary: arbitraryEntries.reduce((sum, a) => sum + a.count, 0),
155
162
  // Measurability and kind, decided once in profiles/: an unreadable stack
156
163
  // takes no score credit; a library's orphans are shown, not judged; a
157
164
  // vendored shadcn catalogue is stock on a shelf, not abandonment.
@@ -163,11 +170,26 @@ export function coreMetrics(h) {
163
170
  // utility-class mode (components.json cssVariables: false): the palette IS
164
171
  // the theme by design, so a palette class is not paint from a tin
165
172
  utilityPalette: profileOf(h).designSystem?.cssVariables === false,
166
- paintTin: profileOf(h).shadcn?.paint?.tin?.per100 ?? 0,
167
- doorOverrides: profileOf(h).shadcn?.paint?.doors?.per100 ?? 0,
173
+ paintTin: paint?.tin?.per100 ?? 0,
174
+ doorOverrides: paint?.doors?.per100 ?? 0,
168
175
  };
169
176
  }
170
177
 
178
+ /**
179
+ * The score as the agent meets the repo, and the score own code alone would
180
+ * earn. The difference is what installed code the team did not write costs.
181
+ * ownScore is null when nothing installed carries any weight (no registries).
182
+ */
183
+ export function scoreBreakdown(h, bench = loadBenchmark()) {
184
+ const b = benchHelpers(bench, profileOf(h).kind);
185
+ const healthOf = makeHealthOf(b);
186
+ const all = scoreOfTiles(tileHealths(coreMetrics(h), healthOf));
187
+ const P = profileOf(h);
188
+ if (!P.isShadcn || !(P.shadcn?.registryDirs ?? []).length) return { score: all, ownScore: null, installedPoints: 0 };
189
+ const own = scoreOfTiles(tileHealths(coreMetrics(h, { ownCode: true }), healthOf));
190
+ return { score: all, ownScore: own, installedPoints: own === null || all === null ? 0 : own - all };
191
+ }
192
+
171
193
  /**
172
194
  * Judge the nine tiles from a metric set. Each tile carries the value shown,
173
195
  * the value judged (strays for token-led colour tiles), and its health.
@@ -16,9 +16,9 @@ export const WHY = {
16
16
  'The ideal is 25 per 100 files. That is where the tidiest third of 15 shadcn repos in the benchmark sit; the median is 62. shadcn\'s own rules for agents say it in 1 line: use semantic colours, never bg-blue-500.',
17
17
  ],
18
18
  doorOverrides: [
19
- 'A shadcn component ships with variants: outline, ghost, destructive, small, large. The variant decides how the component looks, once, in the component file you own. Passing bg-blue-100 or font-bold through className decides it again at the call site, where the component can not see it.',
19
+ 'A shadcn component ships with variants: outline, ghost, destructive, small, large. The variant decides how the component looks, once, in the component file you own. Passing bg-blue-100 through className decides it again at the call site, where the component can not see it.',
20
20
  'Do this in 5 places and 1 button has 6 designs. The next person can not tell which one is intended, and an agent copies whichever it finds first. shadcn\'s guidance gives 3 routes: use a variant that exists, add a variant to the component, or add a variable to the theme file. className is for layout: width, margin, position.',
21
- 'The ideal is 15 per 100 files, the tidiest third of the same 15 repos; the median is 22.',
21
+ 'The ideal is 2 per 100 files, the tidiest half of the 16 shadcn repos in the benchmark; the median is 1. Typography through className, a text-sm on an Input, is shown here and not scored: 12 of the 16 repos do it at the same rate, so it separates nothing, and nowhere else in this report does a text size cost points.',
22
22
  ],
23
23
  colors: [
24
24
  'Look, nobody consciously chooses 100 colours to start with. They were added with each change, and the change after that. No one was checking for the drift, because no one expected it. Then come the multipliers: a rebrand, a dark-mode pass, ad hoc files, each one multiplying the values again.',
@@ -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 } from '../profiles/index.mjs';
30
+ import { decideProfile, profileOf, installedDirs, splitArbitrary } from '../profiles/index.mjs';
31
31
  import { countPaint } from './paint.mjs';
32
32
 
33
33
  function arg(name, fallback) {
@@ -58,6 +58,7 @@ const exclusions = loadExclusions(target, argAll('exclude'));
58
58
  const files = walkRepo(target, 14, exclusions);
59
59
  const profile = profileRepo(target, files);
60
60
  const { components } = harvestComponents(target, files.code);
61
+ const tokens = harvestTokens(target, files.styles, files.code);
61
62
  // Repo kind and measurability, decided once in profiles/ and read everywhere
62
63
  // (a published components package with no app pages is a LIBRARY; a stack the
63
64
  // detector cannot read is NOT MEASURED, never scored as zeros). The decision
@@ -68,22 +69,30 @@ decideProfile(profile, components, files, target);
68
69
  {
69
70
  const P = profileOf(profile);
70
71
  if (P.isShadcn) {
71
- const installed = [...P.uiDirs, ...(profile.shadcn?.registryDirs ?? [])];
72
- const doorFiles = new Set([...installed.flatMap((d) => files.code.filter((f) => f.startsWith(`${d}/`))), ...(profile.shadcn?.blockFiles ?? [])]);
73
- const kitNames = new Set(components.filter((c) => doorFiles.has(c.file)).map((c) => c.name));
74
- profile.shadcn.paint = countPaint(target, files.code, { uiDirs: [...installed, ...(profile.shadcn.blockFiles ?? [])], kitNames });
75
- // what the installed registries carry themselves: kept out of the own-code
76
- // counts, shown in the header, because an agent reads them all the same
77
72
  const regDirs = profile.shadcn.registryDirs ?? [];
73
+ const blocks = profile.shadcn.blockFiles ?? [];
74
+ const allInstalled = installedDirs(P);
75
+ const doorFiles = new Set(allInstalled.flatMap((d) => files.code.filter((f) => f === d || f.startsWith(`${d}/`))));
76
+ const kitNames = new Set(components.filter((c) => doorFiles.has(c.file)).map((c) => c.name));
77
+ // The score reads the repo as the agent meets it: own code plus installed
78
+ // registries, because a palette colour in ai-elements teaches the same
79
+ // wrong lesson as one in own code. The catalogue and kit blocks stay out:
80
+ // editing them is the intended use.
81
+ profile.shadcn.paint = countPaint(target, files.code, { uiDirs: [...P.uiDirs, ...blocks], kitNames });
82
+ // Own code alone, for the breakdown line under the score.
83
+ profile.shadcn.paintOwn = regDirs.length ? countPaint(target, files.code, { uiDirs: allInstalled, kitNames }) : profile.shadcn.paint;
78
84
  if (regDirs.length) {
79
85
  const regFiles = files.code.filter((f) => regDirs.some((d) => f.startsWith(`${d}/`)));
80
86
  const rp = countPaint(target, regFiles, { uiDirs: [], kitNames });
81
- profile.shadcn.registryPaint = { files: rp.ownFiles, tinUses: rp.tin.uses, dirs: regDirs };
87
+ profile.shadcn.registryPaint = { files: rp.ownFiles, tinUses: rp.tin.uses, doorUses: rp.doors.uses, dirs: regDirs };
82
88
  }
89
+ // Bracket values inside installed code are shadcn's (or a registry's)
90
+ // choices: a true lesson, badly framed. Kept out of the count, named.
91
+ const split = splitArbitrary(tokens.tailwind?.arbitrary ?? [], allInstalled);
92
+ profile.shadcn.arbitraryInstalled = split.installed;
83
93
  }
84
94
  }
85
95
 
86
- const tokens = harvestTokens(target, files.styles, files.code);
87
96
  const duplicates = findDuplicates(components, profile.uiDir, target);
88
97
  const context = harvestContext(target);
89
98
  const staleRules = ruleStaleness(target, components,
@@ -6,10 +6,14 @@
6
6
  * row exists: `text-gray-500`, `bg-blue-100`, `text-emerald-600` on a
7
7
  * status, and the manual evening override `dark:bg-gray-950`. 1 sin, 3
8
8
  * spellings. The rule: "Semantic colors. Never `bg-blue-500`."
9
- * 2. Doors repainted from outside. A colour or typography class passed into
10
- * a kit component through className (`<Card className="bg-blue-100
11
- * font-bold">`) where a variant or a sheet row was the intended route.
12
- * The rule: "className for layout only."
9
+ * 2. Doors recoloured from outside. A colour class passed into a kit
10
+ * component through className (`<Card className="bg-blue-100">`) where
11
+ * a variant or a sheet row was the intended route. The rule: "className
12
+ * for layout only." Typography through className (`<Input
13
+ * className="text-sm">`) is counted separately as a receipt, never
14
+ * scored: the 2026-09-13 fleet audit found it on 12 of 16 shadcn repos at
15
+ * a flat rate, so it separates nothing, and nowhere else in the report
16
+ * does a text size cost points.
13
17
  *
14
18
  * Both are counted over OWN code only: never inside the catalogue (those are
15
19
  * kit doors, edited on purpose), never in exempt files (email, print,
@@ -30,9 +34,10 @@ const TIN_RE = new RegExp(`(?<![\\w-])(?:[\\w-]+:)*(?:bg|text|border|ring|outlin
30
34
  // Evening overrides painted by hand with white or black (the palette shades
31
35
  // are already caught above with their dark: prefix).
32
36
  const DARK_WB_RE = /(?<![\w-])dark:(?:bg|text|border)-(?:white|black)(?:\/\d+)?(?![\w-])/g;
33
- // Colour or typography passed into a kit door: palette colour, black/white,
34
- // weight, size.
35
- const DOOR_CLASS_RE = new RegExp(`(?<![\\w-])(?:[\\w-]+:)*(?:(?:bg|text|border)-(?:${PALETTE}|white|black)(?:-(?:50|[1-9]00|950))?(?:/\\d+)?|font-(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black)|text-(?:xs|sm|base|lg|xl|[2-9]xl))(?![\\w-])`, 'g');
37
+ // Colour passed into a kit door: palette colour, black, white, any variant prefix.
38
+ const DOOR_COLOR_RE = new RegExp(`(?<![\\w-])(?:[\\w-]+:)*(?:bg|text|border)-(?:${PALETTE}|white|black)(?:-(?:50|[1-9]00|950))?(?:/\\d+)?(?![\\w-])`, 'g');
39
+ // Typography passed into a kit door: weight or size. A receipt, not a score.
40
+ const DOOR_TYPO_RE = /(?<![\w-])(?:[\w-]+:)*(?:font-(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black)|text-(?:xs|sm|base|lg|xl|[2-9]xl))(?![\w-])/g;
36
41
 
37
42
  const DEMO_PATH_RE = /(^|\/)(stories|storybook|__stories__|examples?|demos?|templates?|playground|fixtures?|__tests__|__mocks__|e2e|cypress)\//i;
38
43
 
@@ -52,6 +57,7 @@ export function countPaint(root, codeFiles, { uiDirs = [], kitNames = new Set()
52
57
  let ownFiles = 0;
53
58
  const tin = { uses: 0, files: 0, top: [], samples: new Map() };
54
59
  const doors = { uses: 0, files: 0, top: [], samples: new Map() };
60
+ const typo = { uses: 0, files: 0, top: [], samples: new Map() };
55
61
  const bump = (bucket, file, count, sample) => {
56
62
  bucket.uses += count; bucket.files += 1;
57
63
  bucket.top.push({ file, count });
@@ -77,20 +83,28 @@ export function countPaint(root, codeFiles, { uiDirs = [], kitNames = new Set()
77
83
  }
78
84
 
79
85
  if (doorOpen) {
80
- let hits = 0; const seen = new Map();
86
+ let hits = 0, thits = 0; const seen = new Map(), tseen = new Map();
81
87
  for (const m of src.matchAll(doorOpen)) {
82
88
  const cls = m[2] ?? m[3] ?? '';
83
- const bad = cls.match(DOOR_CLASS_RE);
84
- if (!bad) continue;
85
- hits += 1;
86
- const key = `<${m[1]} className="${bad.slice(0, 3).join(' ')}">`;
87
- seen.set(key, (seen.get(key) ?? 0) + 1);
89
+ const colour = cls.match(DOOR_COLOR_RE);
90
+ if (colour) {
91
+ hits += 1;
92
+ const key = `<${m[1]} className="${colour.slice(0, 3).join(' ')}">`;
93
+ seen.set(key, (seen.get(key) ?? 0) + 1);
94
+ } else {
95
+ const t = cls.match(DOOR_TYPO_RE);
96
+ if (t) { thits += 1; const key = `<${m[1]} className="${t.slice(0, 2).join(' ')}">`; tseen.set(key, (tseen.get(key) ?? 0) + 1); }
97
+ }
88
98
  }
89
99
  if (hits) {
90
100
  bump(doors, f, hits, null);
91
101
  for (const [k, v] of seen) doors.samples.set(k, (doors.samples.get(k) ?? 0) + v);
92
102
  doors.top.at(-1).sample = [...seen.entries()].sort((a, b) => b[1] - a[1])[0][0];
93
103
  }
104
+ if (thits) {
105
+ bump(typo, f, thits, null);
106
+ for (const [k, v] of tseen) typo.samples.set(k, (typo.samples.get(k) ?? 0) + v);
107
+ }
94
108
  }
95
109
  }
96
110
 
@@ -101,5 +115,5 @@ export function countPaint(root, codeFiles, { uiDirs = [], kitNames = new Set()
101
115
  top: b.top.sort((a, b2) => b2.count - a.count).slice(0, 8),
102
116
  samples: [...b.samples.entries()].sort((a, b2) => b2[1] - a[1]).slice(0, 10).map(([value, count]) => ({ value, count })),
103
117
  });
104
- return { ownFiles, tin: finish(tin), doors: finish(doors) };
118
+ return { ownFiles, tin: finish(tin), doors: finish(doors), typo: finish(typo) };
105
119
  }
@@ -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.3.3';
4
+ export const VERSION = '7.4.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.
@@ -100,3 +100,41 @@ export function profileOf(h) {
100
100
  };
101
101
  }
102
102
 
103
+ /**
104
+ * Installed code: folders the team did not write. The catalogue, kit blocks
105
+ * installed into own code, and third-party registries. Under the agent-safety
106
+ * definition (2026-09-13) installed code splits in two: what teaches a wrong
107
+ * lesson stays in the score, attributed (registry palette colours); what
108
+ * teaches a true lesson or none is kept out of the count and named at the top
109
+ * (shadcn's own bracket values, unused stock).
110
+ */
111
+ export function installedDirs(P) {
112
+ const sc = P?.shadcn ?? {};
113
+ return [...(P?.uiDirs ?? []), ...(sc.registryDirs ?? []), ...(sc.blockFiles ?? [])];
114
+ }
115
+ const underAny = (file, dirs) => dirs.some((d) => file === d || file.startsWith(`${d}/`));
116
+
117
+ /**
118
+ * Split bracket-value entries ({value, count, files:[{file,count}]}) into the
119
+ * team's own uses and installed uses. Own entries keep only own files with
120
+ * counts re-summed; installed is a flat count with its top values.
121
+ */
122
+ export function splitArbitrary(entries, dirs) {
123
+ if (!dirs.length) return { own: entries, installed: { uses: 0, values: [] } };
124
+ const own = [];
125
+ const installedValues = new Map();
126
+ let installedUses = 0;
127
+ for (const e of entries) {
128
+ const ownFiles = (e.files ?? []).filter((f) => !underAny(f.file, dirs));
129
+ const instFiles = (e.files ?? []).filter((f) => underAny(f.file, dirs));
130
+ const instCount = instFiles.reduce((s, f) => s + f.count, 0);
131
+ if (instCount) { installedUses += instCount; installedValues.set(e.value, (installedValues.get(e.value) ?? 0) + instCount); }
132
+ // the file list under an entry is capped, so own is the remainder of the
133
+ // entry's total, never a sum of the files that happened to be listed
134
+ const ownCount = e.count - instCount;
135
+ if (ownCount > 0) own.push({ ...e, count: ownCount, files: ownFiles });
136
+ }
137
+ own.sort((a, b) => b.count - a.count);
138
+ const values = [...installedValues.entries()].sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
139
+ return { own, installed: { uses: installedUses, values } };
140
+ }
@@ -125,7 +125,7 @@ function rowsUnder(css, selector) {
125
125
  const norm = (v) => (v ?? '').replace(/\s+/g, ' ').trim();
126
126
 
127
127
  /** Read the sheet: the CSS file the config names. */
128
- function readSheet(root, wsRoot, cssPath) {
128
+ function readSheet(root, wsRoot, cssPath, files = null) {
129
129
  if (!cssPath) return null;
130
130
  const file = [join(wsRoot, cssPath), join(wsRoot, 'src', cssPath)].find((p) => existsSync(p));
131
131
  if (!file) return { file: cssPath, found: false };
@@ -146,8 +146,23 @@ function readSheet(root, wsRoot, cssPath) {
146
146
  const customUnregistered = themeVars.size
147
147
  ? custom.filter((r) => !/^(font|shadow|tracking|spacing|radius|ease|breakpoint|typeset)/.test(r) && !themeVars.has(`color-${r}`))
148
148
  : [];
149
+ // A custom row nothing references anywhere (no var(--x), no class built on
150
+ // it) is a leftover: the Next.js starter's --foreground-rgb next to the real
151
+ // --foreground. An agent can not tell which colour system is dead.
152
+ let customUnused = [];
153
+ if (files && custom.length) {
154
+ const rel = file.slice(root.length + 1);
155
+ const others = [...(files.code ?? []), ...(files.styles ?? [])].filter((f) => f !== rel).slice(0, 4000);
156
+ const pending = new Set(custom.filter((r) => !/^(font|shadow|tracking|spacing|radius|ease|breakpoint|typeset)/.test(r)));
157
+ for (const f of others) {
158
+ if (!pending.size) break;
159
+ const src = read(join(root, f));
160
+ for (const r of [...pending]) if (src.includes(`--${r}`) || new RegExp(`[-:]${r}(?![\\w-])`).test(src)) pending.delete(r);
161
+ }
162
+ customUnused = [...pending];
163
+ }
149
164
  return {
150
- file: file.slice(root.length + 1), found: true, hslEra,
165
+ file: file.slice(root.length + 1), found: true, hslEra, customUnused,
151
166
  lightRows: rows.length, darkRows: dark.size, shadcnPresent, shadcnMissing: SHADCN_ROWS.filter((r) => !light.has(r)),
152
167
  known: known.length, custom, missingDark, customMissingDark, customUnregistered, tweakcnPresent,
153
168
  spacing, spacingChanged: spacing !== null && norm(spacing) !== FACTORY_SPACING,
@@ -219,7 +234,7 @@ export default {
219
234
  }
220
235
  const pkg = readJSON(join(wsRoot, 'package.json')) ?? readJSON(join(root, 'package.json')) ?? {};
221
236
  const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
222
- const sheet = readSheet(root, wsRoot, cfg.tailwind?.css);
237
+ const sheet = readSheet(root, wsRoot, cfg.tailwind?.css, files);
223
238
  installs.push({ config: file, uiDir: uiAbs, catalogueNames, kit: readKit(cfg, sheet, deps), sheet });
224
239
  }
225
240
  const swept = catalogueSweep(root, files);
@@ -176,11 +176,16 @@ if (neverImported.length >= 3) {
176
176
  rule('Use the semantic classes the theme gives you (`bg-background`, `text-muted-foreground`, `border-border`), never a palette colour like `bg-blue-500` or `text-gray-600`, and never a hand-written `dark:` colour. The variables already carry both modes.');
177
177
  if (paint?.tin?.uses) lines.push(` (${paint.tin.uses} palette colour${paint.tin.uses === 1 ? '' : 's'} already sit in own code, ${paint.tin.samples.slice(0, 3).map((s) => `\`${s.value}\` ×${s.count}`).join(', ')}; do not add to them.)`);
178
178
  rule('Before adding classes to a shadcn component, use one of its variants (`variant="outline"`, `size="sm"`). `className` on a shadcn component is for layout only: width, margin, position. Never colour, never typography.');
179
- if (paint?.doors?.uses) lines.push(` (${paint.doors.uses} shadcn component${paint.doors.uses === 1 ? '' : 's'} already restyled through className, like ${paint.doors.samples.slice(0, 2).map((s) => `\`${s.value}\``).join(' and ')}; do not add to them.)`);
179
+ 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.)`);
180
+ 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.)`);
180
181
  if (sc.registryDirs?.length) rule(`${sc.registryDirs.map((d) => `\`${d}\``).join(', ')} ${sc.registryDirs.length === 1 ? 'is an installed registry' : 'are installed registries'} (added through the shadcn CLI, not written here). Treat ${sc.registryDirs.length === 1 ? 'it' : 'them'} like \`${P.uiDir ?? 'components/ui'}\`: reach for what is there before building your own${sc.registryPaint?.tinUses ? `, and do not copy the ${sc.registryPaint.tinUses} palette colour${sc.registryPaint.tinUses === 1 ? '' : 's'} inside into own code` : ''}.`);
181
182
  rule(`Edit the component you own in \`${P.uiDir ?? 'components/ui'}\`. Never build a second one beside it under another name. A wrapper that composes shadcn components is fine; a second implementation is not.`);
182
183
  rule('Merge classes with `cn()`. Never concatenate strings and never write a ternary inside a className string.');
183
184
  rule('Add a component with `npx shadcn@latest add <name>`, then edit it. To see what changed upstream, run `npx shadcn@latest add <name> --diff`.');
185
+ const ai = sc.arbitraryInstalled ?? null;
186
+ 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.`);
187
+ 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.');
188
+ 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.`);
184
189
  rule('Radius comes from the `--radius` variable and the scale derived from it; never a bracket value. Never change `--spacing`: it resizes every gap in the app.');
185
190
  if (sc.sheet?.customUnregistered?.length) rule(`Custom variables must be registered where Tailwind can see them: ${sc.sheet.customUnregistered.slice(0, 3).map((r) => `\`--${r}\``).join(', ')} ${sc.sheet.customUnregistered.length === 1 ? 'is' : 'are'} defined but never mapped in \`@theme inline\`, so \`bg-${sc.sheet.customUnregistered[0]}\` does nothing.`);
186
191
  rule('Small habits shadcn expects: `gap-*` not `space-x/y-*`; `size-4` not `w-4 h-4`; `truncate` for one-line clipping; no `z-index` on Dialog, Sheet, Popover or Tooltip, they stack themselves; icons from the project\'s own icon library only.');