roast-my-design-system 7.5.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 +6 -11
- 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 +35 -6
- package/skills/roast-my-design-system/scripts/diagnose/score.mjs +18 -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 +27 -2
- package/skills/roast-my-design-system/scripts/profiles/registry.mjs +234 -0
- package/skills/roast-my-design-system/scripts/profiles/shadcn.mjs +3 -0
- 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,13 +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.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.
|
|
16
|
-
|
|
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.
|
|
18
|
-
|
|
19
|
-
> **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.
|
|
20
14
|
|
|
21
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`.
|
|
22
16
|
|
|
@@ -81,11 +75,11 @@ Not to be confused with each other: **"Why this matters"** is generic, ships wit
|
|
|
81
75
|
|
|
82
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:
|
|
83
77
|
|
|
84
|
-

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

|
|
89
83
|
|
|
90
84
|
## What makes the numbers trustworthy
|
|
91
85
|
|
|
@@ -235,12 +229,13 @@ After the roast, the skill also offers to write `design-system-rules.md` to disk
|
|
|
235
229
|
|
|
236
230
|
## Live examples
|
|
237
231
|
|
|
238
|
-
|
|
232
|
+
7 real roasts of public repos, hosted as-is (the same self-contained HTML the skill generates), spanning React, Stencil and Lit:
|
|
239
233
|
|
|
240
234
|
- **[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
|
|
241
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
|
|
242
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
|
|
243
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
|
|
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
|
|
244
239
|
- **[excalidraw/excalidraw](https://gregkozakiewicz.github.io/roast-my-design-system/examples/excalidraw-excalidraw.html)**
|
|
245
240
|
- **[dubinc/dub](https://gregkozakiewicz.github.io/roast-my-design-system/examples/dubinc-dub.html)**
|
|
246
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
|
+
}
|
|
@@ -33,6 +33,7 @@ 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
35
|
import { ownSpacing, profileOf, installedDirs, splitArbitrary } from '../profiles/index.mjs';
|
|
36
|
+
import { publishesLine } from '../profiles/registry.mjs';
|
|
36
37
|
|
|
37
38
|
// The benchmark and every judgement made against it live in score.mjs; this
|
|
38
39
|
// file only draws. The same numbers reach summary.json through scoreHarvest.
|
|
@@ -285,7 +286,12 @@ const offenders = h.tokens.offenders ?? [];
|
|
|
285
286
|
const candidates = [];
|
|
286
287
|
const effColors = tokenLed ? colorStrays : colors.length;
|
|
287
288
|
const effGreys = tokenLed ? greyStrays : greys.length;
|
|
288
|
-
|
|
289
|
+
// Every typeface declared in one file is a picker (a theme editor's font
|
|
290
|
+
// list, shadcn's docs site): one choice offered, one in use at a time.
|
|
291
|
+
const fontFiles = new Set(fontFamilies.flatMap((f) => (f.files ?? []).map((x) => x.file)));
|
|
292
|
+
const fontPicker = typefaces.length > 3 && fontFiles.size === 1 ? [...fontFiles][0] : null;
|
|
293
|
+
if (fontPicker) candidates.push({ ratio: 1.05, text: `${typefaces.length} typefaces offered by a picker in one file (${basename(fontPicker)}), one in use at a time` });
|
|
294
|
+
else if (typefaces.length > 3) candidates.push({ ratio: typefaces.length / 3, text: `${typefaces.length} typefaces. Most products use 2 or 3` });
|
|
289
295
|
else if (typefaces.length && fontFamilies.length > 6) candidates.push({ ratio: fontFamilies.length / 6, text: `${typefaces.length} typeface${typefaces.length > 1 ? 's' : ''} declared ${fontFamilies.length} different ways` });
|
|
290
296
|
if (effColors > 24 * 1.25) candidates.push({ ratio: effColors / 24, text: tokenLed
|
|
291
297
|
? `${n(colorStrays)} hardcoded colours outside the token set, your agent will happily copy them at random`
|
|
@@ -362,7 +368,7 @@ function tile(t, pLabel, pMetric, pFallback) {
|
|
|
362
368
|
const { metric, label, value, healthValue, health } = t;
|
|
363
369
|
if (health === 'na') {
|
|
364
370
|
return { num: '—', label, health, metric, healthValue: null,
|
|
365
|
-
rows: [{ label: `not measured: ${notMeasuredReason}`, val: '', dir: '' }] };
|
|
371
|
+
rows: [{ label: t.naReason ? t.naReason : `not measured: ${notMeasuredReason}`, val: '', dir: '' }] };
|
|
366
372
|
}
|
|
367
373
|
const iv = ideal(metric), rm = refMedian(metric);
|
|
368
374
|
const pct = percentile(metric, value);
|
|
@@ -435,7 +441,7 @@ function projectedScore(applied) {
|
|
|
435
441
|
// A utility-class shadcn repo with no hardcoded colours has zero literal
|
|
436
442
|
// colours and a real system: the palette is bg-zinc-900 and friends.
|
|
437
443
|
const utilityPalette = ds.kind === 'shadcn' && ds.cssVariables === false && twColorUtils >= 5;
|
|
438
|
-
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));
|
|
439
445
|
|
|
440
446
|
// ---------- section renderers ----------
|
|
441
447
|
const DIR = {
|
|
@@ -607,12 +613,17 @@ function sheetSection() {
|
|
|
607
613
|
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' : ''}`);
|
|
608
614
|
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(', ')})`);
|
|
609
615
|
if (sheet.custom?.length) bits.push(`${sheet.custom.length} custom variable${sheet.custom.length === 1 ? '' : 's'} of your own (${sheet.custom.slice(0, 5).map((r) => `--${esc(r)}`).join(', ')}${sheet.custom.length > 5 ? '…' : ''})${sheet.customMissingDark?.length ? `, ${sheet.customMissingDark.length} of them light only` : ''}${sheet.customUnregistered?.length ? `, ${sheet.customUnregistered.length} never mapped in @theme inline` : ''}`);
|
|
610
|
-
if (sheet.tweakcnPresent >= 10) bits.push(
|
|
616
|
+
if (sheet.tweakcnPresent >= 10) bits.push(`a tweakcn theme: ${sheet.tweakcnPresent} of the rows tweakcn adds to every theme it exports are here (shadows, letter-spacing, spacing), so the theme came from a theme editor or copied its shape, and updates will come from there too`);
|
|
611
617
|
if (sheet.spacingChanged) bits.push(`<b>--spacing is ${esc(sheet.spacing)}</b>, not the default 0.25rem: this resizes every gap in the app at once, which shadcn\'s own changelog says never to do`);
|
|
612
618
|
parts.push(`<div class="receipts">${eyebrow('the theme file, variable by variable')}<p class="sub">${bits.join(' · ')}.</p></div>`);
|
|
613
619
|
} else if (sheet) {
|
|
614
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>`);
|
|
615
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
|
+
}
|
|
616
627
|
if (paint) {
|
|
617
628
|
const tinChips = (paint.tin.samples ?? []).slice(0, 8).map((s) => `<span class="vchip bad">${esc(s.value)} ×${s.count}</span>`).join('');
|
|
618
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('');
|
|
@@ -642,7 +653,7 @@ function duplicatesSection() {
|
|
|
642
653
|
</div>` : '';
|
|
643
654
|
const dupeCards = exactDupes.slice(0, 8).map((d) => `
|
|
644
655
|
<div class="fam">
|
|
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`}`)}
|
|
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`}`)}
|
|
646
657
|
<div class="fam-rows">
|
|
647
658
|
${d.files.slice(0, 6).map((f) => `<div class="mini-card">${fileLink(f)}</div>`).join('')}
|
|
648
659
|
${d.files.length > 6 ? `<div class="mini-card dim">…and ${d.files.length - 6} more</div>` : ''}
|
|
@@ -1317,6 +1328,16 @@ function shadcnReceipt() {
|
|
|
1317
1328
|
const pages = comps.filter((c) => c.isPage && f.ownFiles.includes(c.file)).length;
|
|
1318
1329
|
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
1330
|
}
|
|
1331
|
+
if (P.isRegistry && P.registry) {
|
|
1332
|
+
const r = P.registry;
|
|
1333
|
+
const what = r.builtFrom ? `publishes ${r.items} component${r.items === 1 ? '' : 's'}, built from its packages folder` : `publishes ${esc(publishesLine(r))}`;
|
|
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>`;
|
|
1340
|
+
}
|
|
1320
1341
|
return `<div class="excl">Read as a shadcn install (${esc(P.confidence ?? 'medium')} confidence): ${receipt}</div>`;
|
|
1321
1342
|
}
|
|
1322
1343
|
|
|
@@ -1350,7 +1371,7 @@ function sidePanel() {
|
|
|
1350
1371
|
? `<div class="bd">Of which <b>${breakdown.installedPoints} point${breakdown.installedPoints === 1 ? '' : 's'}</b> come from installed code you did not write: ${esc(rp.dirs.map((d) => basename(d)).join(', '))} (${n(rp.files)} file${rp.files === 1 ? '' : 's'}, ${n(rp.tinUses)} palette colour${rp.tinUses === 1 ? '' : 's'}). Your own code alone would score <b>${breakdown.ownScore}</b>. Kept in the score because your agent reads those files like everything else; left out of the fixes because they are not yours to edit.</div>` : '';
|
|
1351
1372
|
const scoreBlock = healthScore !== null
|
|
1352
1373
|
? `<div class="score${noSystemLikely ? ' muted' : ''}">${eyebrow('Health score')}<div class="val">${healthScore}<span class="slash">/</span><span class="of">100</span></div>${noSystemLikely ? '<div class="note">little here to score · see the note</div>' : ''}<div class="def">${def}${lift}</div>${bd}</div>` : '';
|
|
1353
|
-
const chips = `<div class="chips">${stack.map((c) => `<span class="chip">${esc(c)}</span>`).join('')}${dsUnrecognised ? '<span class="chip chip-dim">design system: unrecognised</span>' : ''}${legacyChip ? `<span class="chip chip-dim">${esc(legacyChip)}</span>` : ''}${agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)}</span>`).join('')}</div>`;
|
|
1374
|
+
const chips = `<div class="chips">${stack.map((c) => `<span class="chip">${esc(c)}</span>`).join('')}${(P.shadcn?.sheet?.tweakcnPresent ?? 0) >= 10 ? '<span class="chip">tweakcn theme</span>' : ''}${dsUnrecognised ? '<span class="chip chip-dim">design system: unrecognised</span>' : ''}${legacyChip ? `<span class="chip chip-dim">${esc(legacyChip)}</span>` : ''}${agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)}</span>`).join('')}</div>`;
|
|
1354
1375
|
const facts = [
|
|
1355
1376
|
shadcnReceipt(),
|
|
1356
1377
|
exclusionsLine(),
|
|
@@ -1380,6 +1401,13 @@ function exceptionsBlock() {
|
|
|
1380
1401
|
const si = spacingOwn.installed;
|
|
1381
1402
|
const sv = si.values.length;
|
|
1382
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
|
+
}
|
|
1383
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.`);
|
|
1384
1412
|
const rp = P.shadcn?.registryPaint;
|
|
1385
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.`);
|
|
@@ -2027,6 +2055,7 @@ if (summaryPath) {
|
|
|
2027
2055
|
verdict,
|
|
2028
2056
|
role: P.role,
|
|
2029
2057
|
kind: P.kind,
|
|
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 } } : {}),
|
|
2030
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 } } : {}),
|
|
2031
2060
|
componentsMeasured,
|
|
2032
2061
|
metrics: (({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }) =>
|
|
@@ -45,7 +45,8 @@ export function loadBenchmark() {
|
|
|
45
45
|
* ideals and the reputable-systems line are the same for every kind.
|
|
46
46
|
*/
|
|
47
47
|
export function benchHelpers(bench, kind = 'product') {
|
|
48
|
-
|
|
48
|
+
// registries are compared with the shadcn repos, labelled as such (Greg, 2026-09-13)
|
|
49
|
+
const slice = bench?.slices?.[kind === 'registry' ? 'shadcn' : kind] ?? null;
|
|
49
50
|
if (slice?.stats) bench = { ...bench, stats: { ...(bench?.stats ?? {}), ...slice.stats } };
|
|
50
51
|
const sliceInfo = slice ? { kind, repoCount: slice.repoCount, builtAt: slice.builtAt } : null;
|
|
51
52
|
// where does this value sit among the scanned fleet? ("more colours than 90%")
|
|
@@ -77,8 +78,8 @@ export function benchHelpers(bench, kind = 'product') {
|
|
|
77
78
|
return { percentile, cleanerPct, ideal, median, displayAvg, refMedian, sliceInfo };
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
export const ZERO_IDEAL = new Set(['exactDuplicates', 'inlineStyles', 'nearPairs', 'important', 'neverImported']);
|
|
81
|
-
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 };
|
|
82
83
|
export const SCORE_OF = { good: 100, warn: 55, bad: 10 };
|
|
83
84
|
|
|
84
85
|
/** healthOf(metric, value) for one benchmark: 'good' | 'warn' | 'bad' | 'info'. */
|
|
@@ -105,6 +106,8 @@ export const PROFILE_TILES = {
|
|
|
105
106
|
['doorOverrides', 'components recoloured from outside per 100 files'],
|
|
106
107
|
],
|
|
107
108
|
};
|
|
109
|
+
// a registry measures what a shadcn repo measures (release (a), 2026-09-13)
|
|
110
|
+
PROFILE_TILES.registry = [...PROFILE_TILES.shadcn, ['themesIncomplete', 'published themes incomplete']];
|
|
108
111
|
export const tilesFor = (kind) => [...TILES, ...(PROFILE_TILES[kind] ?? [])];
|
|
109
112
|
|
|
110
113
|
/** The tiles, in report order: metric key, the label the report prints. */
|
|
@@ -173,6 +176,11 @@ export function coreMetrics(h, opts = {}) {
|
|
|
173
176
|
utilityPalette: profileOf(h).designSystem?.cssVariables === false,
|
|
174
177
|
paintTin: paint?.tin?.per100 ?? 0,
|
|
175
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),
|
|
176
184
|
};
|
|
177
185
|
}
|
|
178
186
|
|
|
@@ -200,12 +208,14 @@ export function tileHealths(m, healthOf) {
|
|
|
200
208
|
colors: m.colors, greys: m.greys, spacing: m.spacing, exactDuplicates: m.exactDuplicates,
|
|
201
209
|
inlineStyles: m.inlineStyles, nearPairs: m.nearPairs, important: m.important,
|
|
202
210
|
neverImported: m.neverImported, arbitrary: m.arbitrary,
|
|
203
|
-
paintTin: m.paintTin ?? 0, doorOverrides: m.doorOverrides ?? 0,
|
|
211
|
+
paintTin: m.paintTin ?? 0, doorOverrides: m.doorOverrides ?? 0, themesIncomplete: m.themesIncomplete ?? 0,
|
|
204
212
|
};
|
|
205
213
|
const judged = { ...shown, colors: m.tokenLed ? m.colorStrays : m.colors, greys: m.tokenLed ? m.greyStrays : m.greys };
|
|
206
214
|
return tilesFor(m.kind ?? 'product').map(([metric, label]) => {
|
|
207
215
|
let health = healthOf(metric, judged[metric]);
|
|
208
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; }
|
|
209
219
|
if (!m.componentsMeasured && (metric === 'exactDuplicates' || metric === 'neverImported')) {
|
|
210
220
|
health = 'na'; value = null; healthValue = null;
|
|
211
221
|
} else if (metric === 'neverImported' && (m.isLibrary || (m.vendoredUi && m.neverImported > 0))) {
|
|
@@ -213,7 +223,10 @@ export function tileHealths(m, healthOf) {
|
|
|
213
223
|
} else if (metric === 'paintTin' && m.utilityPalette) {
|
|
214
224
|
health = 'info';
|
|
215
225
|
}
|
|
216
|
-
|
|
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 } : {}) };
|
|
217
230
|
});
|
|
218
231
|
}
|
|
219
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.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
3
|
/**
|
|
4
4
|
* Profiles — what kind of repo is this, decided once and read everywhere.
|
|
5
5
|
*
|
|
@@ -22,6 +22,8 @@ import { join } 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, scopeFiles } from './registry.mjs';
|
|
26
|
+
export { scopeFiles };
|
|
25
27
|
import library from './library.mjs';
|
|
26
28
|
import product from './product.mjs';
|
|
27
29
|
|
|
@@ -60,6 +62,26 @@ export function decideProfile(profile, components, files, root = null) {
|
|
|
60
62
|
profile.kind = picked.kind;
|
|
61
63
|
profile.kindConfidence = decision.confidence;
|
|
62
64
|
profile.kindEvidence = decision.evidence;
|
|
65
|
+
// A shadcn repo that PUBLISHES a registry is a registry: the fourth kind.
|
|
66
|
+
// Every count still reads the shadcn facts (release (a): zero score change);
|
|
67
|
+
// the kind, the receipt and the header line say what it is.
|
|
68
|
+
if (root) {
|
|
69
|
+
const reg = readRegistry(root, files);
|
|
70
|
+
if (reg) {
|
|
71
|
+
if (!reg.variants.length) reg.variants = variantsFromDirs(reg, profile.uiDirs, files);
|
|
72
|
+
delete reg.itemNames;
|
|
73
|
+
profile.kind = 'registry';
|
|
74
|
+
// consumers of what it publishes live in other repos: library semantics
|
|
75
|
+
profile.role = 'library';
|
|
76
|
+
profile.registry = reg;
|
|
77
|
+
profile.kindEvidence = [
|
|
78
|
+
reg.builtFrom ? `registry built from ${reg.items} packages by ${reg.source}` : `${reg.source} publishes ${publishesLine(reg)}`,
|
|
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`] : []),
|
|
81
|
+
...decision.evidence,
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
63
85
|
|
|
64
86
|
// Measurability. The gate fires on the situation, never a list of known
|
|
65
87
|
// frameworks: a repo with real code volume where the detector found almost
|
|
@@ -90,7 +112,10 @@ export function profileOf(h) {
|
|
|
90
112
|
confidence: p.kindConfidence ?? null,
|
|
91
113
|
evidence: p.kindEvidence ?? [],
|
|
92
114
|
isLibrary: role === 'library',
|
|
93
|
-
|
|
115
|
+
// a registry is read with the shadcn facts, plus what it publishes
|
|
116
|
+
isShadcn: kind === 'shadcn' || kind === 'registry',
|
|
117
|
+
isRegistry: kind === 'registry',
|
|
118
|
+
registry: p.registry ?? null,
|
|
94
119
|
shadcn: p.shadcn ?? null,
|
|
95
120
|
uiDirs: p.uiDirs ?? (p.uiDir ? [p.uiDir] : []),
|
|
96
121
|
// A vendored shadcn catalogue is stock on a shelf, not abandonment.
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry: a project that publishes components or themes for other repos
|
|
3
|
+
* to install with the shadcn CLI (shadcn's own source, magicui, kibo-ui,
|
|
4
|
+
* tweakcn). Read as an ordinary shadcn install, the scanner counts its
|
|
5
|
+
* product range as sprawl (shadcn's source: 132 duplicates, one per base
|
|
6
|
+
* library; 29 typefaces from a font picker) and files what it publishes
|
|
7
|
+
* under "installed, not yours". Both wrong: what a registry publishes ships
|
|
8
|
+
* into every repo that installs it.
|
|
9
|
+
*
|
|
10
|
+
* Release (a), 2026-09-13: recognition and the header line only. Every count
|
|
11
|
+
* still reads the shadcn profile, so no score moves. Release (b) brings the
|
|
12
|
+
* counting rules (variants once, published code as own code, the theme
|
|
13
|
+
* check, the demo zone out). Brief: DOCs/proffer-2-notes/docs/registry-profile-brief-2026-09-13.md
|
|
14
|
+
*
|
|
15
|
+
* Recognised by either:
|
|
16
|
+
* 1. a registry.json whose items carry registry:* types (the source file,
|
|
17
|
+
* never the built copies under public/ nor test fixtures or templates)
|
|
18
|
+
* 2. a route that builds the registry from a packages folder
|
|
19
|
+
* (app/r/registry.json/route.ts, kibo-ui)
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
22
|
+
import { join, dirname, basename, relative } from 'node:path';
|
|
23
|
+
import { SHADCN_ROWS } from './shadcn-data.mjs';
|
|
24
|
+
|
|
25
|
+
const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
|
|
26
|
+
|
|
27
|
+
// registry item types → the words the report uses
|
|
28
|
+
const KINDS = [
|
|
29
|
+
['components', /^registry:(ui|component)$/],
|
|
30
|
+
['blocks', /^registry:block$/],
|
|
31
|
+
['styles', /^registry:(style|theme)$/],
|
|
32
|
+
['demos', /^registry:example$/],
|
|
33
|
+
['other', /^registry:(lib|hook|file|page|internal)$/],
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function tally(items) {
|
|
37
|
+
const out = { components: 0, blocks: 0, styles: 0, demos: 0, other: 0 };
|
|
38
|
+
for (const it of items) {
|
|
39
|
+
const t = String(it?.type ?? '');
|
|
40
|
+
const k = KINDS.find(([, re]) => re.test(t));
|
|
41
|
+
if (k) out[k[0]] += 1;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Sibling folders holding the same published component names: the variants of one range. */
|
|
47
|
+
function variantDirs(items, root) {
|
|
48
|
+
const byName = new Map();
|
|
49
|
+
for (const it of items) {
|
|
50
|
+
if (!/^registry:(ui|component)$/.test(String(it?.type ?? ''))) continue;
|
|
51
|
+
for (const f of it.files ?? []) {
|
|
52
|
+
const p = typeof f === 'string' ? f : f?.path;
|
|
53
|
+
if (!p) continue;
|
|
54
|
+
const dir = dirname(p);
|
|
55
|
+
if (!byName.has(it.name)) byName.set(it.name, new Set());
|
|
56
|
+
byName.get(it.name).add(dir);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const dirs = new Map();
|
|
60
|
+
for (const set of byName.values()) if (set.size > 1) for (const d of set) dirs.set(d, (dirs.get(d) ?? 0) + 1);
|
|
61
|
+
return [...dirs.entries()].filter(([, n]) => n >= 5).map(([d]) => d).sort();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The facts a registry publishes, or null when the repo publishes nothing.
|
|
66
|
+
* @returns {{ source: string, builtFrom: string|null, items: number, publishes, variants: string[] } | null}
|
|
67
|
+
*/
|
|
68
|
+
// The main walk skips docs/, public/ and test folders on purpose (they are
|
|
69
|
+
// not the product's design language). A registry's paperwork lives exactly
|
|
70
|
+
// there (kibo's route under apps/docs, tweakcn's built JSON under public), so
|
|
71
|
+
// recognition takes its own small look: depth-limited, noise skipped.
|
|
72
|
+
const LOOK_SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', 'coverage', '.turbo', 'test', 'tests', '__tests__', 'fixtures', 'templates']);
|
|
73
|
+
function lookFor(root, maxDepth = 6) {
|
|
74
|
+
const found = { registries: [], routes: [] };
|
|
75
|
+
const walk = (dir, rel, depth) => {
|
|
76
|
+
if (depth > maxDepth) return;
|
|
77
|
+
let es; try { es = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
78
|
+
for (const e of es) {
|
|
79
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
80
|
+
if (e.isDirectory()) {
|
|
81
|
+
if (LOOK_SKIP.has(e.name) || (e.name.startsWith('.') && e.name !== '.')) continue;
|
|
82
|
+
if (e.name === 'registry.json') {
|
|
83
|
+
const route = ['route.ts', 'route.js', 'route.tsx', 'route.mjs'].find((n) => existsSync(join(dir, e.name, n)));
|
|
84
|
+
if (route && basename(dir) === 'r') found.routes.push(`${r}/${route}`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
walk(join(dir, e.name), r, depth + 1);
|
|
88
|
+
} else if (e.name === 'registry.json') found.registries.push(r);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
walk(root, '', 0);
|
|
92
|
+
return found;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function readRegistry(root, files) {
|
|
96
|
+
const look = lookFor(root);
|
|
97
|
+
// 1. a registry.json with typed items: the source file first (the biggest
|
|
98
|
+
// wins), else the built copy under public/ (tweakcn generates its registry
|
|
99
|
+
// from a TypeScript file and only the built JSON exists)
|
|
100
|
+
let best = null;
|
|
101
|
+
const consider = (f) => {
|
|
102
|
+
const j = readJSON(join(root, f));
|
|
103
|
+
const items = Array.isArray(j?.items) ? j.items : null;
|
|
104
|
+
if (!items || !items.some((it) => /^registry:/.test(String(it?.type ?? '')))) return;
|
|
105
|
+
if (!best || items.length > best.items.length) best = { source: f, items };
|
|
106
|
+
};
|
|
107
|
+
const candidates = look.registries;
|
|
108
|
+
for (const f of candidates.filter((f) => !/(^|\/)public\//.test(f))) consider(f);
|
|
109
|
+
if (!best) for (const f of candidates.filter((f) => /(^|\/)public\//.test(f))) consider(f);
|
|
110
|
+
if (best) {
|
|
111
|
+
const publishes = tally(best.items);
|
|
112
|
+
const itemNames = best.items.filter((it) => /^registry:(ui|component)$/.test(String(it?.type ?? ''))).map((it) => it.name).filter(Boolean);
|
|
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
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// 2. a route that builds the registry from packages/* (kibo-ui)
|
|
127
|
+
const route = look.routes[0] ?? null;
|
|
128
|
+
if (route) {
|
|
129
|
+
const src = readFileSync(join(root, route), 'utf8');
|
|
130
|
+
if (/packages/.test(src)) {
|
|
131
|
+
// the packages folder nearest the route's app, walking up
|
|
132
|
+
let dir = dirname(route);
|
|
133
|
+
let pkgs = null;
|
|
134
|
+
while (dir && dir !== '.') {
|
|
135
|
+
const cand = join(root, dir, 'packages');
|
|
136
|
+
if (existsSync(cand)) { pkgs = cand; break; }
|
|
137
|
+
dir = dirname(dir);
|
|
138
|
+
}
|
|
139
|
+
if (!pkgs && existsSync(join(root, 'packages'))) pkgs = join(root, 'packages');
|
|
140
|
+
if (pkgs) {
|
|
141
|
+
const names = readdirSync(pkgs, { withFileTypes: true }).filter((e) => e.isDirectory() && existsSync(join(pkgs, e.name, 'index.tsx'))).map((e) => e.name);
|
|
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: [] } };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Variants found on disk: catalogue folders (the shadcn profile's uiDirs)
|
|
153
|
+
* that each hold most of the published component names. shadcn's own
|
|
154
|
+
* registry.json lists one file per component (new-york-v4), while the same
|
|
155
|
+
* components sit again under bases/aria, bases/base and bases/radix.
|
|
156
|
+
*/
|
|
157
|
+
export function variantsFromDirs(reg, _uiDirs, files) {
|
|
158
|
+
const names = new Set();
|
|
159
|
+
// names come from the registry items when it is a file, else from packages
|
|
160
|
+
if (reg.itemNames) for (const nm of reg.itemNames) names.add(nm);
|
|
161
|
+
if (names.size < 5) return [];
|
|
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
|
+
}
|
|
171
|
+
const hits = [];
|
|
172
|
+
for (const [d, have] of byDir) {
|
|
173
|
+
const shared = [...names].filter((nm) => have.has(nm)).length;
|
|
174
|
+
if (shared >= Math.max(5, Math.ceil(names.size * 0.5))) hits.push(d);
|
|
175
|
+
}
|
|
176
|
+
return hits.length > 1 ? hits.sort() : [];
|
|
177
|
+
}
|
|
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
|
+
|
|
228
|
+
/** The header sentence: what it publishes, in words. */
|
|
229
|
+
export function publishesLine(r) {
|
|
230
|
+
const parts = [];
|
|
231
|
+
const n = (k, word) => { const v = r.publishes[k]; if (v) parts.push(`${v} ${word}${v === 1 ? '' : 's'}`); };
|
|
232
|
+
n('components', 'component'); n('blocks', 'block'); n('styles', 'style'); n('demos', 'demo');
|
|
233
|
+
return parts.length ? parts.join(', ').replace(/, ([^,]*)$/, ' and $1') : `${r.items} items`;
|
|
234
|
+
}
|
|
@@ -303,6 +303,9 @@ export default {
|
|
|
303
303
|
}
|
|
304
304
|
evidence.push(contract ? `${contract} of shadcn's ${CONTRACT_ROWS.length} theme variables defined` : 'no shadcn theme variables found in any stylesheet');
|
|
305
305
|
if (kit?.style) evidence.push(`style ${kit.style}${kit.baseColor ? `, base colour ${kit.baseColor}` : ''}${kit.tailwind ? `, Tailwind ${kit.tailwind}` : ''}`);
|
|
306
|
+
// ten or more of the rows tweakcn adds to every theme it exports: a
|
|
307
|
+
// fingerprint of a theme editor, not a signature, so "tweakcn-style"
|
|
308
|
+
if ((sheet?.tweakcnPresent ?? 0) >= 10) evidence.push(`tweakcn theme (${sheet.tweakcnPresent} of its rows present)`);
|
|
306
309
|
|
|
307
310
|
// 4. write the facts every consumer reads
|
|
308
311
|
profile.uiDirs = installs.map((i) => i.uiDir).filter(Boolean);
|
|
@@ -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.`);
|