roast-my-design-system 7.6.0 → 7.7.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 +5 -13
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/benchmark/benchmark.json +5 -1
- package/skills/roast-my-design-system/scripts/diagnose/index.mjs +22 -6
- package/skills/roast-my-design-system/scripts/diagnose/score.mjs +15 -5
- package/skills/roast-my-design-system/scripts/diagnose/why.mjs +5 -0
- package/skills/roast-my-design-system/scripts/harvest/index.mjs +35 -4
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/profiles/index.mjs +6 -2
- package/skills/roast-my-design-system/scripts/profiles/registry.mjs +76 -6
- package/skills/roast-my-design-system/scripts/rules/build.mjs +4 -1
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.7.0" width="72" alt="roast-my-design-system">
|
|
2
2
|
|
|
3
3
|
# roast-my-design-system
|
|
4
4
|
|
|
@@ -10,15 +10,7 @@
|
|
|
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.
|
|
14
|
-
|
|
15
|
-
> **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.
|
|
16
|
-
|
|
17
|
-
> **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.
|
|
18
|
-
|
|
19
|
-
> **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.
|
|
20
|
-
|
|
21
|
-
> **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.
|
|
13
|
+
> **New in 7.7: four repo profiles, and the fourth is `registry`.** The scanner decides once what kind of repo it is reading, `product`, `library`, `shadcn` or `registry`, and every count, ideal and benchmark line reads that decision. `registry` is new: a repo that publishes a shadcn registry (a `registry.json` with typed items, or a route that builds one from a packages folder) is counted on what it publishes and nothing else. The docs site, demos and examples are kept out and named in the header with file counts; the same components kept in several variants count once; a name repeated only inside published blocks is the range, not a duplicate; published components are the project's own work, with no installed-code exemption. Published themes get their own tile: every shadcn colour variable present for light and for dark, ideal 0. For a registry that publishes themes and no code, that check is the score. Covers shadcn's own source (84), magicui (69), kibo-ui (78) and tweakcn (100, 36 themes, all complete). No other kind of repo moves. `summary.json`: `kind: "registry"` with a `registry` block (`publishes`, `counted`, `showcase`, `variantsDropped`, `themes`); tiles gain `themesIncomplete` on registries.
|
|
22
14
|
|
|
23
15
|
> **New in 7.0.** The scoring code is now a separate file that other tools can import, so a CI check gets the same score as the report. Every `summary.json` now records its schema version and which benchmark it was scored against. Five counting bugs are fixed: a bracket spacing class like `p-[13px]` was counted twice, a hex colour inside a CSS comment was counted, and three smaller ones. Most repos lose a few counts. shadcn/ui goes from 325 colours to 323 and keeps its score of 65. Large monorepos scan about ten times faster. If you use the score as a CI threshold, run a fresh scan before comparing. If you read the JSON, a tile's `value` is now a number and the printed text is in `display`.
|
|
24
16
|
|
|
@@ -83,11 +75,11 @@ Not to be confused with each other: **"Why this matters"** is generic, ships wit
|
|
|
83
75
|
|
|
84
76
|
The full report for vercel/ai-chatbot, top to bottom, including "What the numbers mean", Claude's read of the scan, embedded right under the verdict:
|
|
85
77
|
|
|
86
|
-

|
|
87
79
|
|
|
88
80
|
The same report in light mode (one file, built-in toggle):
|
|
89
81
|
|
|
90
|
-

|
|
91
83
|
|
|
92
84
|
## What makes the numbers trustworthy
|
|
93
85
|
|
|
@@ -243,7 +235,7 @@ After the roast, the skill also offers to write `design-system-rules.md` to disk
|
|
|
243
235
|
- **[adobe/spectrum-web-components](https://gregkozakiewicz.github.io/roast-my-design-system/examples/adobe-spectrum.html)**: Lit, the `--spectrum-*` namespace named in the header
|
|
244
236
|
- **[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
|
|
245
237
|
- **[vercel/ai-chatbot](https://gregkozakiewicz.github.io/roast-my-design-system/examples/vercel-ai-chatbot.html)**: React on shadcn, with Claude's notes embedded
|
|
246
|
-
- **[magicuidesign/magicui](https://gregkozakiewicz.github.io/roast-my-design-system/examples/magicui.html)**: read as a shadcn registry,
|
|
238
|
+
- **[magicuidesign/magicui](https://gregkozakiewicz.github.io/roast-my-design-system/examples/magicui.html)**: read as a shadcn registry, counted on the 78 components it publishes
|
|
247
239
|
- **[excalidraw/excalidraw](https://gregkozakiewicz.github.io/roast-my-design-system/examples/excalidraw-excalidraw.html)**
|
|
248
240
|
- **[dubinc/dub](https://gregkozakiewicz.github.io/roast-my-design-system/examples/dubinc-dub.html)**
|
|
249
241
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.7.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": [
|
|
@@ -93,6 +93,10 @@
|
|
|
93
93
|
"doorOverrides": {
|
|
94
94
|
"value": 2,
|
|
95
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
|
+
},
|
|
97
|
+
"themesIncomplete": {
|
|
98
|
+
"value": 0,
|
|
99
|
+
"note": "published themes missing a shadcn colour variable in light or dark; a gap here ships into every repo that installs the theme"
|
|
96
100
|
}
|
|
97
101
|
},
|
|
98
102
|
"stats": {
|
|
@@ -2677,4 +2681,4 @@
|
|
|
2677
2681
|
]
|
|
2678
2682
|
}
|
|
2679
2683
|
}
|
|
2680
|
-
}
|
|
2684
|
+
}
|
|
@@ -368,7 +368,7 @@ function tile(t, pLabel, pMetric, pFallback) {
|
|
|
368
368
|
const { metric, label, value, healthValue, health } = t;
|
|
369
369
|
if (health === 'na') {
|
|
370
370
|
return { num: '—', label, health, metric, healthValue: null,
|
|
371
|
-
rows: [{ label: `not measured: ${notMeasuredReason}`, val: '', dir: '' }] };
|
|
371
|
+
rows: [{ label: t.naReason ? t.naReason : `not measured: ${notMeasuredReason}`, val: '', dir: '' }] };
|
|
372
372
|
}
|
|
373
373
|
const iv = ideal(metric), rm = refMedian(metric);
|
|
374
374
|
const pct = percentile(metric, value);
|
|
@@ -441,7 +441,7 @@ function projectedScore(applied) {
|
|
|
441
441
|
// A utility-class shadcn repo with no hardcoded colours has zero literal
|
|
442
442
|
// colours and a real system: the palette is bg-zinc-900 and friends.
|
|
443
443
|
const utilityPalette = ds.kind === 'shadcn' && ds.cssVariables === false && twColorUtils >= 5;
|
|
444
|
-
const noSystemLikely = !utilityPalette && (colors.length === 0 || (colors.length < 3 && spacingTotal === 0));
|
|
444
|
+
const noSystemLikely = !utilityPalette && !(P.isRegistry && P.registry?.themes?.total) && (colors.length === 0 || (colors.length < 3 && spacingTotal === 0));
|
|
445
445
|
|
|
446
446
|
// ---------- section renderers ----------
|
|
447
447
|
const DIR = {
|
|
@@ -619,6 +619,11 @@ function sheetSection() {
|
|
|
619
619
|
} else if (sheet) {
|
|
620
620
|
parts.push(`<div class="receipts">${eyebrow('the theme')}<p class="sub">components.json names ${esc(sheet.file)} as the theme file, and it was not found.</p></div>`);
|
|
621
621
|
}
|
|
622
|
+
if (P.isRegistry && P.registry?.themes?.total) {
|
|
623
|
+
const t = P.registry.themes;
|
|
624
|
+
const rows = t.worst.map((w) => `<div class="ledger-row"><span class="mono strong">${esc(w.name)}</span><span class="dim">${w.missingLight.length ? `light missing ${w.missingLight.slice(0, 4).map((r) => `--${esc(r)}`).join(', ')}${w.missingLight.length > 4 ? ` and ${w.missingLight.length - 4} more` : ''}` : ''}${w.missingLight.length && w.missingDark.length ? ' · ' : ''}${w.missingDark.length ? `dark missing ${w.missingDark.slice(0, 4).map((r) => `--${esc(r)}`).join(', ')}${w.missingDark.length > 4 ? ` and ${w.missingDark.length - 4} more` : ''}` : ''}</span></div>`).join('');
|
|
625
|
+
parts.push(`<div class="receipts">${eyebrow(`${n(t.total)} published theme${t.total === 1 ? '' : 's'} · ${n(t.incomplete)} incomplete`)}<p class="sub">${t.incomplete ? `A theme missing a variable ships that gap into every repo that installs it. ${t.incomplete === 1 ? 'The one' : `The ${t.incomplete}`} below ${t.incomplete === 1 ? 'is' : 'are'} short of the full set in at least one mode.` : 'Every published theme carries every shadcn colour variable, for light and for dark.'}</p>${rows ? `<div class="ledger">${rows}</div>` : ''}${whyToggle('themesIncomplete')}</div>`);
|
|
626
|
+
}
|
|
622
627
|
if (paint) {
|
|
623
628
|
const tinChips = (paint.tin.samples ?? []).slice(0, 8).map((s) => `<span class="vchip bad">${esc(s.value)} ×${s.count}</span>`).join('');
|
|
624
629
|
const tinFiles = (paint.tin.top ?? []).slice(0, 5).map((f) => `<span class="vchip" title="${esc(f.file)}">${esc(basename(f.file))} ×${f.count}</span>`).join('');
|
|
@@ -648,7 +653,7 @@ function duplicatesSection() {
|
|
|
648
653
|
</div>` : '';
|
|
649
654
|
const dupeCards = exactDupes.slice(0, 8).map((d) => `
|
|
650
655
|
<div class="fam">
|
|
651
|
-
${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`}`)}
|
|
656
|
+
${eyebrow(`<${esc(d.name)}> · ${d.blocks ? `defined in ${d.files.length} blocks that each install alone: the range, not counted` : d.upstream ? 'two shadcn components define it: upstream\'s overlap, not counted' : d.wrapped ? 'defined twice, one wraps the other' : `${d.files.length} implementations`}`)}
|
|
652
657
|
<div class="fam-rows">
|
|
653
658
|
${d.files.slice(0, 6).map((f) => `<div class="mini-card">${fileLink(f)}</div>`).join('')}
|
|
654
659
|
${d.files.length > 6 ? `<div class="mini-card dim">…and ${d.files.length - 6} more</div>` : ''}
|
|
@@ -1326,8 +1331,12 @@ function shadcnReceipt() {
|
|
|
1326
1331
|
if (P.isRegistry && P.registry) {
|
|
1327
1332
|
const r = P.registry;
|
|
1328
1333
|
const what = r.builtFrom ? `publishes ${r.items} component${r.items === 1 ? '' : 's'}, built from its packages folder` : `publishes ${esc(publishesLine(r))}`;
|
|
1329
|
-
const
|
|
1330
|
-
|
|
1334
|
+
const kept = (r.showcase ?? []).reduce((a, e) => a + e.files, 0);
|
|
1335
|
+
const th = r.themes?.total ? ` ${n(r.themes.total)} published theme${r.themes.total === 1 ? '' : 's'} checked, ${n(r.themes.incomplete)} incomplete.` : '';
|
|
1336
|
+
const counted = (r.counted?.code ?? 0) === 0 && r.themes?.total
|
|
1337
|
+
? `<b>It publishes no code, so the themes check is the score</b>: the ${n(kept)} files of the app that makes them are not counted.`
|
|
1338
|
+
: `<b>Only what it publishes is counted</b>: ${n(r.counted?.code ?? 0)} code file${(r.counted?.code ?? 0) === 1 ? '' : 's'}${kept ? `, with ${n(kept)} more kept out as its site, demos and examples` : ''}.`;
|
|
1339
|
+
return `<div class="excl fresh">Read as a shadcn registry: ${what}${r.variants.length ? `, the same components kept in ${r.variants.length} variants` : ''}. ${counted}${th}</div><div class="excl">Evidence: ${receipt}</div>`;
|
|
1331
1340
|
}
|
|
1332
1341
|
return `<div class="excl">Read as a shadcn install (${esc(P.confidence ?? 'medium')} confidence): ${receipt}</div>`;
|
|
1333
1342
|
}
|
|
@@ -1392,6 +1401,13 @@ function exceptionsBlock() {
|
|
|
1392
1401
|
const si = spacingOwn.installed;
|
|
1393
1402
|
const sv = si.values.length;
|
|
1394
1403
|
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.`);
|
|
1404
|
+
if (P.isRegistry && P.registry) {
|
|
1405
|
+
const r = P.registry;
|
|
1406
|
+
const sc = r.showcase ?? [];
|
|
1407
|
+
if (sc.length) lines.push(`${n(sc.reduce((a, e) => a + e.files, 0))} files outside what the registry publishes (${sc.slice(0, 4).map((e) => `${esc(e.dir)} ${n(e.files)}`).join(', ')}${sc.length > 4 ? ` and ${sc.length - 4} more folders` : ''}) are not counted: the docs site, demos and examples nobody installs. Your agent reads them like everything else.`);
|
|
1408
|
+
const vd = r.variantsDropped ?? [];
|
|
1409
|
+
if (vd.length) lines.push(`${vd.length} variant${vd.length === 1 ? '' : 's'} of the same components (${vd.map((e) => esc(basename(dirname(e.dir)) === 'bases' ? basename(dirname(e.dir)) + '/' + basename(e.dir) : e.dir)).join(', ')}, ${n(vd.reduce((a, e) => a + e.files, 0))} files) counted once, through the variant the registry file names.`);
|
|
1410
|
+
}
|
|
1395
1411
|
if (vendoredUi && neverImported.length) lines.push(`${n(neverImported.length)} catalogue component${neverImported.length === 1 ? '' : 's'} not used yet: stock on the shelf, not scored.`);
|
|
1396
1412
|
const rp = P.shadcn?.registryPaint;
|
|
1397
1413
|
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.`);
|
|
@@ -2039,7 +2055,7 @@ if (summaryPath) {
|
|
|
2039
2055
|
verdict,
|
|
2040
2056
|
role: P.role,
|
|
2041
2057
|
kind: P.kind,
|
|
2042
|
-
...(P.isRegistry && P.registry ? { registry: { source: P.registry.source, builtFrom: P.registry.builtFrom, items: P.registry.items, publishes: P.registry.publishes, variants: P.registry.variants } } : {}),
|
|
2058
|
+
...(P.isRegistry && P.registry ? { registry: { source: P.registry.source, builtFrom: P.registry.builtFrom, items: P.registry.items, publishes: P.registry.publishes, variants: P.registry.variants, counted: P.registry.counted ?? null, showcase: P.registry.showcase ?? [], variantsDropped: P.registry.variantsDropped ?? [], themes: P.registry.themes ? { total: P.registry.themes.total, incomplete: P.registry.themes.incomplete } : null } } : {}),
|
|
2043
2059
|
...(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 } } : {}),
|
|
2044
2060
|
componentsMeasured,
|
|
2045
2061
|
metrics: (({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }) =>
|
|
@@ -78,8 +78,8 @@ export function benchHelpers(bench, kind = 'product') {
|
|
|
78
78
|
return { percentile, cleanerPct, ideal, median, displayAvg, refMedian, sliceInfo };
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
export const ZERO_IDEAL = new Set(['exactDuplicates', 'inlineStyles', 'nearPairs', 'important', 'neverImported']);
|
|
82
|
-
export const WARN_TOLERANCE = { exactDuplicates: 2, inlineStyles: 10, nearPairs: 2, important: 5, neverImported: 2 };
|
|
81
|
+
export const ZERO_IDEAL = new Set(['exactDuplicates', 'inlineStyles', 'nearPairs', 'important', 'neverImported', 'themesIncomplete']);
|
|
82
|
+
export const WARN_TOLERANCE = { exactDuplicates: 2, inlineStyles: 10, nearPairs: 2, important: 5, neverImported: 2, themesIncomplete: 1 };
|
|
83
83
|
export const SCORE_OF = { good: 100, warn: 55, bad: 10 };
|
|
84
84
|
|
|
85
85
|
/** healthOf(metric, value) for one benchmark: 'good' | 'warn' | 'bad' | 'info'. */
|
|
@@ -107,7 +107,7 @@ export const PROFILE_TILES = {
|
|
|
107
107
|
],
|
|
108
108
|
};
|
|
109
109
|
// a registry measures what a shadcn repo measures (release (a), 2026-09-13)
|
|
110
|
-
PROFILE_TILES.registry = PROFILE_TILES.shadcn;
|
|
110
|
+
PROFILE_TILES.registry = [...PROFILE_TILES.shadcn, ['themesIncomplete', 'published themes incomplete']];
|
|
111
111
|
export const tilesFor = (kind) => [...TILES, ...(PROFILE_TILES[kind] ?? [])];
|
|
112
112
|
|
|
113
113
|
/** The tiles, in report order: metric key, the label the report prints. */
|
|
@@ -176,6 +176,11 @@ export function coreMetrics(h, opts = {}) {
|
|
|
176
176
|
utilityPalette: profileOf(h).designSystem?.cssVariables === false,
|
|
177
177
|
paintTin: paint?.tin?.per100 ?? 0,
|
|
178
178
|
doorOverrides: paint?.doors?.per100 ?? 0,
|
|
179
|
+
// a registry's published themes, checked for every variable in both modes
|
|
180
|
+
themesIncomplete: P.registry?.themes?.incomplete ?? 0,
|
|
181
|
+
themesPublished: P.registry?.themes?.total ?? 0,
|
|
182
|
+
// a registry that publishes themes and no code: the themes check is the score
|
|
183
|
+
stylesOnly: !!(P.isRegistry && P.registry?.themes?.total && (P.registry?.counted?.code ?? 0) === 0),
|
|
179
184
|
};
|
|
180
185
|
}
|
|
181
186
|
|
|
@@ -203,12 +208,14 @@ export function tileHealths(m, healthOf) {
|
|
|
203
208
|
colors: m.colors, greys: m.greys, spacing: m.spacing, exactDuplicates: m.exactDuplicates,
|
|
204
209
|
inlineStyles: m.inlineStyles, nearPairs: m.nearPairs, important: m.important,
|
|
205
210
|
neverImported: m.neverImported, arbitrary: m.arbitrary,
|
|
206
|
-
paintTin: m.paintTin ?? 0, doorOverrides: m.doorOverrides ?? 0,
|
|
211
|
+
paintTin: m.paintTin ?? 0, doorOverrides: m.doorOverrides ?? 0, themesIncomplete: m.themesIncomplete ?? 0,
|
|
207
212
|
};
|
|
208
213
|
const judged = { ...shown, colors: m.tokenLed ? m.colorStrays : m.colors, greys: m.tokenLed ? m.greyStrays : m.greys };
|
|
209
214
|
return tilesFor(m.kind ?? 'product').map(([metric, label]) => {
|
|
210
215
|
let health = healthOf(metric, judged[metric]);
|
|
211
216
|
let value = shown[metric], healthValue = judged[metric];
|
|
217
|
+
// a registry that publishes no themes has nothing to check here
|
|
218
|
+
if (metric === 'themesIncomplete' && !m.themesPublished) { health = 'na'; value = null; healthValue = null; }
|
|
212
219
|
if (!m.componentsMeasured && (metric === 'exactDuplicates' || metric === 'neverImported')) {
|
|
213
220
|
health = 'na'; value = null; healthValue = null;
|
|
214
221
|
} else if (metric === 'neverImported' && (m.isLibrary || (m.vendoredUi && m.neverImported > 0))) {
|
|
@@ -216,7 +223,10 @@ export function tileHealths(m, healthOf) {
|
|
|
216
223
|
} else if (metric === 'paintTin' && m.utilityPalette) {
|
|
217
224
|
health = 'info';
|
|
218
225
|
}
|
|
219
|
-
|
|
226
|
+
// a registry that publishes themes and no code: nothing else applies
|
|
227
|
+
let naReason = null;
|
|
228
|
+
if (m.stylesOnly && metric !== 'themesIncomplete') { health = 'na'; value = null; healthValue = null; naReason = 'this registry publishes themes and no code, so there is nothing here to measure'; }
|
|
229
|
+
return { metric, label, value, healthValue, health, ...(naReason ? { naReason } : {}) };
|
|
220
230
|
});
|
|
221
231
|
}
|
|
222
232
|
|
|
@@ -15,6 +15,11 @@ export const WHY = {
|
|
|
15
15
|
'A palette class such as text-gray-500 or bg-blue-100 skips that file. It works on the day. It stops working at the first theme change: the variables move, the palette colour stays, and the page shows 2 designs at once. Dark mode is where it shows first. A variable carries both values; a palette class carries one, so someone adds dark:bg-gray-900 next to it, and now there are 2 places to keep in step. An AI agent reading that file copies the pair.',
|
|
16
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
|
+
themesIncomplete: [
|
|
19
|
+
'A published theme is a set of CSS variables a repo installs with one command: background, foreground, primary, muted, border and the rest, each with a light value and a dark value. A component in that repo reads the variable; if the theme never set it, the component gets nothing.',
|
|
20
|
+
'A theme missing --sidebar-border in dark mode looks fine in the editor that made it and breaks in the first repo that uses the sidebar. The gap is invisible until then, and the person who hits it is not the person who can fix it.',
|
|
21
|
+
'The ideal is 0. Every theme a registry publishes carries every variable, in both modes, or says which component it does not support.',
|
|
22
|
+
],
|
|
18
23
|
doorOverrides: [
|
|
19
24
|
'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
25
|
'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.',
|
|
@@ -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, decideFresh, profileOf, installedDirs, splitArbitrary } from '../profiles/index.mjs';
|
|
30
|
+
import { decideProfile, decideFresh, profileOf, installedDirs, splitArbitrary, scopeFiles } from '../profiles/index.mjs';
|
|
31
31
|
import { countPaint } from './paint.mjs';
|
|
32
32
|
|
|
33
33
|
function arg(name, fallback) {
|
|
@@ -55,15 +55,35 @@ const outPath = resolve(arg('out', 'harvest.json'));
|
|
|
55
55
|
|
|
56
56
|
const t0 = Date.now();
|
|
57
57
|
const exclusions = loadExclusions(target, argAll('exclude'));
|
|
58
|
-
|
|
58
|
+
let files = walkRepo(target, 14, exclusions);
|
|
59
59
|
const profile = profileRepo(target, files);
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
let { components } = harvestComponents(target, files.code);
|
|
61
|
+
let tokens = harvestTokens(target, files.styles, files.code);
|
|
62
62
|
// Repo kind and measurability, decided once in profiles/ and read everywhere
|
|
63
63
|
// (a published components package with no app pages is a LIBRARY; a stack the
|
|
64
64
|
// detector cannot read is NOT MEASURED, never scored as zeros). The decision
|
|
65
65
|
// lands on the profile with its evidence, so the JSON says why.
|
|
66
66
|
decideProfile(profile, components, files, target);
|
|
67
|
+
// A registry is counted on what it publishes, one variant of it. The rest
|
|
68
|
+
// (docs site, demos, an installed catalogue for the site) is kept out and
|
|
69
|
+
// named in the header like any exclusion; secondary variants likewise.
|
|
70
|
+
if (profileOf(profile).isRegistry) {
|
|
71
|
+
const scope = scopeFiles(profile.registry, files);
|
|
72
|
+
profile.registry.showcase = scope.showcase;
|
|
73
|
+
profile.registry.variantsDropped = scope.variantsDropped;
|
|
74
|
+
for (const e of scope.showcase) exclusions.patterns.push({ pattern: e.dir, source: 'the registry profile (not published)', files: e.files });
|
|
75
|
+
for (const e of scope.variantsDropped) exclusions.patterns.push({ pattern: e.dir, source: 'the registry profile (a variant counted once)', files: e.files });
|
|
76
|
+
files = scope.files;
|
|
77
|
+
// the theme file the published components read stays in scope: it is the
|
|
78
|
+
// colour system they are judged against, wherever the repo keeps it
|
|
79
|
+
const sheetFile = profile.shadcn?.sheet?.found ? profile.shadcn.sheet.file : null;
|
|
80
|
+
if (sheetFile && !files.styles.includes(sheetFile)) files.styles.push(sheetFile);
|
|
81
|
+
({ components } = harvestComponents(target, files.code));
|
|
82
|
+
tokens = harvestTokens(target, files.styles, files.code);
|
|
83
|
+
// published components are the project's own work, not installed code
|
|
84
|
+
profile.uiDirs = []; profile.uiDir = null; profile.vendoredUi = false;
|
|
85
|
+
profile.registry.counted = { code: files.code.length, styles: files.styles.length };
|
|
86
|
+
}
|
|
67
87
|
// A shadcn kitchen gets the 2 paint checks from shadcn's own agent rules,
|
|
68
88
|
// counted over own code only (never the kit's doors, never exempt files).
|
|
69
89
|
{
|
|
@@ -97,6 +117,17 @@ decideProfile(profile, components, files, target);
|
|
|
97
117
|
}
|
|
98
118
|
|
|
99
119
|
const duplicates = findDuplicates(components, profile.uiDir, target, profile.uiDirs ?? null);
|
|
120
|
+
// A registry's blocks are self-contained kits, each installed alone
|
|
121
|
+
// (sidebar-01 to sidebar-16 every one with its own AppSidebar). A name whose
|
|
122
|
+
// every copy sits inside a block is the range, not confusion: listed, never
|
|
123
|
+
// counted, like a wrapped pair.
|
|
124
|
+
{
|
|
125
|
+
const blockDirs = profile.registry?.blockDirs ?? [];
|
|
126
|
+
if (blockDirs.length) {
|
|
127
|
+
const inBlock = (f) => blockDirs.some((d) => f === d || f.startsWith(`${d}/`));
|
|
128
|
+
for (const d of duplicates.exactDuplicates) if (!d.wrapped && d.files.every(inBlock)) { d.wrapped = true; d.blocks = true; }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
100
131
|
const context = harvestContext(target);
|
|
101
132
|
const staleRules = ruleStaleness(target, components,
|
|
102
133
|
new Set(neverImportedComponents(components, profile.uiDir).map((c) => c.name)),
|
|
@@ -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.7.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.
|
|
@@ -22,7 +22,8 @@ import { join, basename } from 'node:path';
|
|
|
22
22
|
* fixture's expected output is byte-identical before and after.
|
|
23
23
|
*/
|
|
24
24
|
import shadcn from './shadcn.mjs';
|
|
25
|
-
import { readRegistry, publishesLine, variantsFromDirs } from './registry.mjs';
|
|
25
|
+
import { readRegistry, publishesLine, variantsFromDirs, scopeFiles } from './registry.mjs';
|
|
26
|
+
export { scopeFiles };
|
|
26
27
|
import library from './library.mjs';
|
|
27
28
|
import product from './product.mjs';
|
|
28
29
|
|
|
@@ -64,16 +65,19 @@ export function decideProfile(profile, components, files, root = null) {
|
|
|
64
65
|
// A shadcn repo that PUBLISHES a registry is a registry: the fourth kind.
|
|
65
66
|
// Every count still reads the shadcn facts (release (a): zero score change);
|
|
66
67
|
// the kind, the receipt and the header line say what it is.
|
|
67
|
-
if (
|
|
68
|
+
if (root) {
|
|
68
69
|
const reg = readRegistry(root, files);
|
|
69
70
|
if (reg) {
|
|
70
71
|
if (!reg.variants.length) reg.variants = variantsFromDirs(reg, profile.uiDirs, files);
|
|
71
72
|
delete reg.itemNames;
|
|
72
73
|
profile.kind = 'registry';
|
|
74
|
+
// consumers of what it publishes live in other repos: library semantics
|
|
75
|
+
profile.role = 'library';
|
|
73
76
|
profile.registry = reg;
|
|
74
77
|
profile.kindEvidence = [
|
|
75
78
|
reg.builtFrom ? `registry built from ${reg.items} packages by ${reg.source}` : `${reg.source} publishes ${publishesLine(reg)}`,
|
|
76
79
|
...(reg.variants.length ? [`the same components kept in ${reg.variants.length} variants (${reg.variants.map((d) => basename(d)).join(', ')})`] : []),
|
|
80
|
+
...(reg.themes?.total ? [`${reg.themes.total} published theme${reg.themes.total === 1 ? '' : 's'} checked, ${reg.themes.incomplete} incomplete`] : []),
|
|
77
81
|
...decision.evidence,
|
|
78
82
|
];
|
|
79
83
|
}
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* (app/r/registry.json/route.ts, kibo-ui)
|
|
20
20
|
*/
|
|
21
21
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
22
|
-
import { join, dirname, basename } from 'node:path';
|
|
22
|
+
import { join, dirname, basename, relative } from 'node:path';
|
|
23
|
+
import { SHADCN_ROWS } from './shadcn-data.mjs';
|
|
23
24
|
|
|
24
25
|
const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
|
|
25
26
|
|
|
@@ -109,7 +110,18 @@ export function readRegistry(root, files) {
|
|
|
109
110
|
if (best) {
|
|
110
111
|
const publishes = tally(best.items);
|
|
111
112
|
const itemNames = best.items.filter((it) => /^registry:(ui|component)$/.test(String(it?.type ?? ''))).map((it) => it.name).filter(Boolean);
|
|
112
|
-
|
|
113
|
+
// item file paths are relative to the registry file's folder
|
|
114
|
+
const base = dirname(best.source);
|
|
115
|
+
const rel = (p) => (base === '.' ? p : `${base}/${p}`);
|
|
116
|
+
const dirsOf = (re) => [...new Set(best.items.filter((it) => re.test(String(it?.type ?? '')))
|
|
117
|
+
.flatMap((it) => (it.files ?? []).map((f) => (typeof f === 'string' ? f : f?.path)).filter(Boolean).map((p) => rel(dirname(p)))))].sort();
|
|
118
|
+
return {
|
|
119
|
+
source: best.source, builtFrom: null, items: best.items.length, publishes, variants: variantDirs(best.items, root), itemNames,
|
|
120
|
+
publishedDirs: dirsOf(/^registry:(ui|component|block|lib|hook|style|theme|file|page|internal)$/),
|
|
121
|
+
demoDirs: dirsOf(/^registry:example$/),
|
|
122
|
+
blockDirs: dirsOf(/^registry:block$/),
|
|
123
|
+
themes: themeCheck(best.items),
|
|
124
|
+
};
|
|
113
125
|
}
|
|
114
126
|
// 2. a route that builds the registry from packages/* (kibo-ui)
|
|
115
127
|
const route = look.routes[0] ?? null;
|
|
@@ -127,7 +139,9 @@ export function readRegistry(root, files) {
|
|
|
127
139
|
if (!pkgs && existsSync(join(root, 'packages'))) pkgs = join(root, 'packages');
|
|
128
140
|
if (pkgs) {
|
|
129
141
|
const names = readdirSync(pkgs, { withFileTypes: true }).filter((e) => e.isDirectory() && existsSync(join(pkgs, e.name, 'index.tsx'))).map((e) => e.name);
|
|
130
|
-
|
|
142
|
+
const pkgRel = relative(root, pkgs).replace(/\\/g, '/');
|
|
143
|
+
return { source: route, builtFrom: 'packages', items: names.length, publishes: { components: names.length, blocks: 0, styles: 0, demos: 0, other: 0 }, variants: [], itemNames: names,
|
|
144
|
+
publishedDirs: names.map((nm) => `${pkgRel}/${nm}`), demoDirs: [], blockDirs: [], themes: { total: 0, incomplete: 0, worst: [] } };
|
|
131
145
|
}
|
|
132
146
|
}
|
|
133
147
|
}
|
|
@@ -140,21 +154,77 @@ export function readRegistry(root, files) {
|
|
|
140
154
|
* registry.json lists one file per component (new-york-v4), while the same
|
|
141
155
|
* components sit again under bases/aria, bases/base and bases/radix.
|
|
142
156
|
*/
|
|
143
|
-
export function variantsFromDirs(reg,
|
|
157
|
+
export function variantsFromDirs(reg, _uiDirs, files) {
|
|
144
158
|
const names = new Set();
|
|
145
159
|
// names come from the registry items when it is a file, else from packages
|
|
146
160
|
if (reg.itemNames) for (const nm of reg.itemNames) names.add(nm);
|
|
147
161
|
if (names.size < 5) return [];
|
|
148
162
|
const stem = (f) => { const b = basename(f).replace(/\.[cm]?[jt]sx?$/, ''); return b === 'index' ? basename(dirname(f)) : b; };
|
|
163
|
+
// every folder holding most of the published names, catalogue or not
|
|
164
|
+
const byDir = new Map();
|
|
165
|
+
for (const f of files.code ?? []) {
|
|
166
|
+
if (!/\.[jt]sx$/.test(f)) continue;
|
|
167
|
+
const d = dirname(f);
|
|
168
|
+
if (!byDir.has(d)) byDir.set(d, new Set());
|
|
169
|
+
byDir.get(d).add(stem(f));
|
|
170
|
+
}
|
|
149
171
|
const hits = [];
|
|
150
|
-
for (const d of
|
|
151
|
-
const have = new Set((files.code ?? []).filter((f) => f.startsWith(`${d}/`) && /\.[jt]sx$/.test(f)).map(stem));
|
|
172
|
+
for (const [d, have] of byDir) {
|
|
152
173
|
const shared = [...names].filter((nm) => have.has(nm)).length;
|
|
153
174
|
if (shared >= Math.max(5, Math.ceil(names.size * 0.5))) hits.push(d);
|
|
154
175
|
}
|
|
155
176
|
return hits.length > 1 ? hits.sort() : [];
|
|
156
177
|
}
|
|
157
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Published themes, checked: every shadcn colour variable present for light
|
|
181
|
+
* and for dark. --radius is not per mode and is left out. A theme with a
|
|
182
|
+
* missing row ships that gap into every repo that installs it.
|
|
183
|
+
*/
|
|
184
|
+
const THEME_ROWS = SHADCN_ROWS.filter((r) => r !== 'radius');
|
|
185
|
+
function themeCheck(items) {
|
|
186
|
+
const worst = [];
|
|
187
|
+
let total = 0, incomplete = 0;
|
|
188
|
+
for (const it of items) {
|
|
189
|
+
if (!/^registry:(style|theme)$/.test(String(it?.type ?? ''))) continue;
|
|
190
|
+
const cv = it.cssVars ?? {};
|
|
191
|
+
if (!cv.light && !cv.dark) continue;
|
|
192
|
+
total += 1;
|
|
193
|
+
const has = (mode) => new Set(Object.keys(cv[mode] ?? {}).map((k) => k.replace(/^--/, '')));
|
|
194
|
+
const light = has('light'), dark = has('dark');
|
|
195
|
+
const missingLight = THEME_ROWS.filter((r) => !light.has(r));
|
|
196
|
+
const missingDark = THEME_ROWS.filter((r) => !dark.has(r));
|
|
197
|
+
if (missingLight.length || missingDark.length) {
|
|
198
|
+
incomplete += 1;
|
|
199
|
+
if (worst.length < 6) worst.push({ name: it.name, missingLight, missingDark });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { total, incomplete, worst };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* What is counted on a registry: the code it publishes, one variant of it.
|
|
207
|
+
* Everything else (the docs site, demos, examples, an installed catalogue
|
|
208
|
+
* for the site) is kept out and named, with file counts per folder.
|
|
209
|
+
*/
|
|
210
|
+
export function scopeFiles(reg, files) {
|
|
211
|
+
const under = (f, dirs) => dirs.some((d) => f === d || f.startsWith(`${d}/`));
|
|
212
|
+
const published = reg.publishedDirs ?? [];
|
|
213
|
+
const secondary = (reg.variants ?? []).filter((d) => !under(d, published) && !published.some((p) => p === d || p.startsWith(`${d}/`)));
|
|
214
|
+
const top = (f) => { const seg = f.split('/'); return (seg[0] === 'apps' || seg[0] === 'packages') && seg.length > 2 ? seg.slice(0, 2).join('/') : seg.length > 1 ? seg[0] : '(root)'; };
|
|
215
|
+
const out = { code: [], styles: [], other: files.other ?? [] };
|
|
216
|
+
const showcase = new Map(), variants = new Map();
|
|
217
|
+
for (const kind of ['code', 'styles']) {
|
|
218
|
+
for (const f of files[kind] ?? []) {
|
|
219
|
+
if (under(f, secondary)) { const d = secondary.find((v) => f.startsWith(`${v}/`)); variants.set(d, (variants.get(d) ?? 0) + 1); continue; }
|
|
220
|
+
if (under(f, published)) { out[kind].push(f); continue; }
|
|
221
|
+
showcase.set(top(f), (showcase.get(top(f)) ?? 0) + 1);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const list = (m) => [...m.entries()].sort((a, b) => b[1] - a[1]).map(([dir, n]) => ({ dir, files: n }));
|
|
225
|
+
return { files: out, showcase: list(showcase), variantsDropped: list(variants) };
|
|
226
|
+
}
|
|
227
|
+
|
|
158
228
|
/** The header sentence: what it publishes, in words. */
|
|
159
229
|
export function publishesLine(r) {
|
|
160
230
|
const parts = [];
|
|
@@ -121,7 +121,9 @@ const repoName = h.profile?.name ?? 'this repo';
|
|
|
121
121
|
const files = d.files.map((f) => (typeof f === 'string' ? f : f.file));
|
|
122
122
|
const ranked = [...files].sort((a, b) => sharedScore(b) - sharedScore(a));
|
|
123
123
|
const clear = sharedScore(ranked[0]) > sharedScore(ranked[1]);
|
|
124
|
-
if (d.
|
|
124
|
+
if (d.blocks) {
|
|
125
|
+
rule(`\`<${d.name}>\` is defined in ${files.length} published blocks, each a self-contained install. Copy the whole block or none of it; never import across blocks.`);
|
|
126
|
+
} else if (d.upstream) {
|
|
125
127
|
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
128
|
} else if (d.wrapped) {
|
|
127
129
|
rule(`\`<${d.name}>\` is defined twice and one wraps the other${clear ? `. Import \`${ranked[0]}\`` : ''}; do not create a third.`);
|
|
@@ -179,6 +181,7 @@ if (neverImported.length >= 3) {
|
|
|
179
181
|
const sheetFile = sc.sheet?.found ? sc.sheet.file : null;
|
|
180
182
|
const paint = sc.paint ?? null;
|
|
181
183
|
section('shadcn: the components and the theme');
|
|
184
|
+
if (P.isRegistry) rule(`This repo publishes a shadcn registry${P.registry?.publishes ? ` (${[['components', P.registry.publishes.components], ['blocks', P.registry.publishes.blocks], ['styles', P.registry.publishes.styles]].filter(([, v]) => v).map(([k, v]) => `${v} ${k}`).join(', ')})` : ''}. A palette colour, a bracket value or a hand-written dark: colour written here ships into every repo that installs it. Hold published code to the theme variables and the scale harder than app code, and keep demos and examples out of published files.`);
|
|
182
185
|
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.`);
|
|
183
186
|
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
187
|
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.`);
|