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 +7 -5
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/benchmark/benchmark.json +31 -31
- package/skills/roast-my-design-system/scripts/diagnose/index.mjs +85 -19
- package/skills/roast-my-design-system/scripts/diagnose/score.mjs +5 -4
- package/skills/roast-my-design-system/scripts/harvest/context.mjs +32 -1
- package/skills/roast-my-design-system/scripts/harvest/duplicates.mjs +12 -2
- package/skills/roast-my-design-system/scripts/harvest/index.mjs +5 -2
- package/skills/roast-my-design-system/scripts/harvest/tokens.mjs +60 -5
- package/skills/roast-my-design-system/scripts/lib/exempt.mjs +16 -0
- package/skills/roast-my-design-system/scripts/lib/fixprompt.mjs +13 -2
- package/skills/roast-my-design-system/scripts/lib/staleness.mjs +19 -5
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/profiles/index.mjs +73 -0
- package/skills/roast-my-design-system/scripts/profiles/shadcn.mjs +45 -10
- package/skills/roast-my-design-system/scripts/rules/build.mjs +15 -7
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<img src="https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/roaster_logo_300px.png?v=7.
|
|
1
|
+
<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
|
-

|
|
83
85
|
|
|
84
86
|
The same report in light mode (one file, built-in toggle):
|
|
85
87
|
|
|
86
|
-

|
|
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.
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
93,
|
|
2140
|
-
157
|
|
2136
|
+
11,
|
|
2137
|
+
14,
|
|
2138
|
+
34,
|
|
2139
|
+
65,
|
|
2140
|
+
126
|
|
2141
2141
|
],
|
|
2142
2142
|
"p25": 0,
|
|
2143
|
-
"median":
|
|
2144
|
-
"p75":
|
|
2145
|
-
"p90":
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
2428
|
-
"neverImported":
|
|
2427
|
+
"important": 34,
|
|
2428
|
+
"neverImported": 3,
|
|
2429
2429
|
"components": 1085,
|
|
2430
|
-
"paintTin":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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 =
|
|
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(`<${esc(d.name)}> · ${d.wrapped ? 'defined twice, one wraps the other' : `${d.files.length} implementations`}`)}
|
|
645
|
+
${eyebrow(`<${esc(d.name)}> · ${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
|
-
${
|
|
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) => `<${esc(c.name)}>`).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
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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(/</g, '<').replace(/>/g, '>').replace(/→/g, '->').replace(/&/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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
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 =
|
|
51
|
-
if (!CATALOGUE.has(
|
|
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
|
-
|
|
110
|
-
if (
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 = (
|
|
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.)`);
|