roast-my-design-system 6.0.1 → 7.1.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 +8 -5
- package/bin/roast.mjs +5 -1
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/diagnose/index.mjs +67 -146
- package/skills/roast-my-design-system/scripts/diagnose/score.mjs +215 -0
- package/skills/roast-my-design-system/scripts/harvest/components.mjs +48 -32
- package/skills/roast-my-design-system/scripts/harvest/duplicates.mjs +7 -3
- package/skills/roast-my-design-system/scripts/harvest/index.mjs +10 -33
- package/skills/roast-my-design-system/scripts/harvest/tokens.mjs +69 -21
- package/skills/roast-my-design-system/scripts/harvest/walk.mjs +19 -2
- package/skills/roast-my-design-system/scripts/lib/color.mjs +1 -1
- package/skills/roast-my-design-system/scripts/lib/guard-api.mjs +3 -3
- package/skills/roast-my-design-system/scripts/lib/version.mjs +5 -1
- package/skills/roast-my-design-system/scripts/lib/workspaces.mjs +21 -1
- package/skills/roast-my-design-system/scripts/mcp/engine.mjs +5 -2
- package/skills/roast-my-design-system/scripts/mcp/knowledge.mjs +6 -2
- package/skills/roast-my-design-system/scripts/mcp/server.mjs +3 -0
- package/skills/roast-my-design-system/scripts/mcp/tools.mjs +2 -0
- package/skills/roast-my-design-system/scripts/profiles/index.mjs +90 -0
- package/skills/roast-my-design-system/scripts/profiles/library.mjs +29 -0
- package/skills/roast-my-design-system/scripts/profiles/product.mjs +21 -0
- package/skills/roast-my-design-system/scripts/rules/build.mjs +8 -5
- package/skills/roast-my-design-system/scripts/sarif/index.mjs +3 -2
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=
|
|
1
|
+
<img src="https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/roaster_logo_300px.png?v=7.1.0" width="72" alt="roast-my-design-system">
|
|
2
2
|
|
|
3
3
|
# roast-my-design-system
|
|
4
4
|
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
A free CLI tool (and Claude Code skill) that roasts your repo's design system with real data, then generates the rules that keep your AI agent on-system.
|
|
12
12
|
|
|
13
|
+
> **New in 7.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`.
|
|
14
|
+
|
|
13
15
|
> **New in 6.0: a dark mode is not sprawl, and the ruler has been rebuilt.** A system with a dark theme states most of its colours twice. One with a density switch states its spacing twice again. Every restatement was counted, so the systems doing the most work scored the worst. Now only a token's first statement counts. A factory-fresh shadcn install goes from 20 colours to 16, and Shoelace from 420 to 219. Two related faults went with it. `border-radius: var(--radius)` was counted as a radius value, so Polaris showed 49 radii of which 36 were references to its own tokens, and Telekom showed 52 font sizes of which 48 were. A new finding names one token holding two different colours in two packages of a monorepo, which fires 8 times across 19 real repos. The benchmark was rebuilt with all of it, so every score is measured against a ruler that counts the same way: cal.com 15 to 20, Cloudscape 35 to 40, MUI 50 to 55. If you have a score from 5.x, take a fresh one before you compare.
|
|
14
16
|
|
|
15
17
|
Run it on your codebase and get, in about a second:
|
|
@@ -43,7 +45,7 @@ Type these in a terminal and you get a result.
|
|
|
43
45
|
| `... --sarif` | `design-system-roast.sarif` for GitHub code scanning: upload it in CI and findings appear in the Security tab, annotated on files |
|
|
44
46
|
| `... --check` | The working tree's changed files checked against the design system, in the terminal. Exits 1 on findings, so it slots into scripts |
|
|
45
47
|
| <code>... --exclude lab/</code> | Leave a folder out of the scan (repeat the flag or comma-separate). Or list folders in a `.roastignore` file at the repo root. Either way the report says so in the header; see [Scoping the scan](#scoping-the-scan) |
|
|
46
|
-
| `... --json` | The scan summary as JSON on stdout, for scripts and pipelines |
|
|
48
|
+
| `... --json` | The scan summary as JSON on stdout, for scripts and pipelines. Includes `schemaVersion`, the benchmark used, and every metric as a number, so two scans can be compared |
|
|
47
49
|
| <code>... --by "Dwayne Hicks"</code> | Puts a name in the report header, for when you ran it for someone else |
|
|
48
50
|
| <code>... --theme light</code> / <code>--out <file></code> / <code>--no-open</code> / <code>--open</code> | Light report, custom report path, never open the browser, always open it |
|
|
49
51
|
|
|
@@ -71,11 +73,11 @@ Not to be confused with each other: **"Why this matters"** is generic, ships wit
|
|
|
71
73
|
|
|
72
74
|
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:
|
|
73
75
|
|
|
74
|
-

|
|
75
77
|
|
|
76
78
|
The same report in light mode (one file, built-in toggle):
|
|
77
79
|
|
|
78
|
-

|
|
79
81
|
|
|
80
82
|
## What makes the numbers trustworthy
|
|
81
83
|
|
|
@@ -86,7 +88,8 @@ The same report in light mode (one file, built-in toggle):
|
|
|
86
88
|
- **Honest gaps.** When a repo's components register in a pattern the scan can not read, the component tiles say "not measured" and drop out of the score. A zero the scanner never earned is presented as blindness, not discipline.
|
|
87
89
|
- **Honest exclusions.** Test files, Storybook stories, docs sites, example apps, SVG artwork, and email templates (which *must* inline styles) are excluded, so you can't discredit the numbers on a technicality. Your own exclusions (`.roastignore`, `--exclude`) are printed in the report header with file counts, so a scoped scan can never pass itself off as the whole repo.
|
|
88
90
|
- **Intent-aware counting (v3).** Runtime-computed inline styles, compound-component APIs and wrapper components are not crimes and are not counted as ones. Token-led repos are judged on their hardcoded strays, not their token architecture. Repeated arbitrary values are read as decisions without names, not drift.
|
|
89
|
-
- **
|
|
91
|
+
- **The scoring code can be imported.** `scoreHarvest(harvest)` in `diagnose/score.mjs` returns the score, the nine tile results and the metrics as plain data. The report uses the same function. A CI check gets the same numbers as the page, and every `summary.json` records its schema version and the benchmark used.
|
|
92
|
+
- **A real benchmark.** The "Avg Design System" yardstick comes from scanning 34 public React repos (cal.com, excalidraw, supabase, grafana, twenty, dub, langfuse…). Median: 130 colours, 17 greys, 20 duplicated components, 49 inline style blocks, 70 arbitrary Tailwind values. The builder and the repo list are in `tools/benchmark/`, so the ruler can be checked, not just quoted.
|
|
90
93
|
- **A second yardstick: reputable systems.** Curated, scoped scans of 10 well-known design systems (shadcn/ui, Primer, Polaris, Carbon, Material UI, Chakra, Ant Design, GOV.UK, Spectrum, Cloudscape) show what disciplined looks like at scale.
|
|
91
94
|
|
|
92
95
|
## Scoping the scan
|
package/bin/roast.mjs
CHANGED
|
@@ -171,12 +171,16 @@ for (const [flagName, file] of [
|
|
|
171
171
|
process.exit(1);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// Every value-taking flag is consumed before the path is looked for, so
|
|
175
|
+
// `--out report.html .` no longer reads report.html as the repo (it did,
|
|
176
|
+
// until 6.0.1: flags in the "wrong" order died with "Not a directory").
|
|
177
|
+
const outOpt = opt('out', null);
|
|
174
178
|
const target = resolve(argv.find((a) => !a.startsWith('--')) || process.cwd());
|
|
175
179
|
if (!existsSync(target) || !statSync(target).isDirectory()) {
|
|
176
180
|
console.error(`Not a directory: ${target}`);
|
|
177
181
|
process.exit(1);
|
|
178
182
|
}
|
|
179
|
-
const outPath = resolve(
|
|
183
|
+
const outPath = resolve(outOpt ?? join(target, 'design-system-roast.html'));
|
|
180
184
|
|
|
181
185
|
const tmp = mkdtempSync(join(tmpdir(), 'roast-'));
|
|
182
186
|
const harvestPath = join(tmp, 'harvest.json');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.1.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": [
|
|
@@ -31,11 +31,15 @@ import { feedbackUrl, FEEDBACK_ASK, FEEDBACK_CTA } from '../lib/feedback.mjs';
|
|
|
31
31
|
import { fixPrompt } from '../lib/fixprompt.mjs';
|
|
32
32
|
import { WHY } from './why.mjs';
|
|
33
33
|
import { parseColor, luminance, isGrey } from '../lib/color.mjs';
|
|
34
|
+
import { loadBenchmark, benchHelpers, makeHealthOf, coreMetrics, tileHealths, scoreOfTiles, scorePackage as scorePackageOf, ZERO_IDEAL, WARN_TOLERANCE, SCORE_OF, SCHEMA_VERSION } from './score.mjs';
|
|
35
|
+
import { profileOf } from '../profiles/index.mjs';
|
|
34
36
|
|
|
35
|
-
// The benchmark
|
|
36
|
-
//
|
|
37
|
-
const
|
|
38
|
-
const
|
|
37
|
+
// The benchmark and every judgement made against it live in score.mjs; this
|
|
38
|
+
// file only draws. The same numbers reach summary.json through scoreHarvest.
|
|
39
|
+
const bench = loadBenchmark();
|
|
40
|
+
const B = benchHelpers(bench);
|
|
41
|
+
const { percentile, cleanerPct, ideal, median, displayAvg, refMedian } = B;
|
|
42
|
+
const healthOf = makeHealthOf(B);
|
|
39
43
|
|
|
40
44
|
function arg(name, fallback) {
|
|
41
45
|
const i = process.argv.indexOf(`--${name}`);
|
|
@@ -46,6 +50,7 @@ if (!inPath) { console.error('Usage: node src/diagnose/index.mjs <harvest.json>
|
|
|
46
50
|
const outPath = resolve(arg('out', 'diagnosis.html'));
|
|
47
51
|
|
|
48
52
|
const h = JSON.parse(readFileSync(inPath, 'utf8'));
|
|
53
|
+
const M = coreMetrics(h);
|
|
49
54
|
|
|
50
55
|
// The GK mark (32px favicon, also gregkozakiewicz.com). Reused in the footer
|
|
51
56
|
// as a CSS mask filled with currentColor so it works in both themes.
|
|
@@ -201,9 +206,8 @@ const nonGreys = colors.filter((c) => !isGrey(c.value));
|
|
|
201
206
|
const twColorUtils = (h.tokens.tailwind?.colors ?? []).length;
|
|
202
207
|
const spacing = h.tokens.spacing ?? [];
|
|
203
208
|
const twSpacing = h.tokens.tailwind?.spacing ?? [];
|
|
204
|
-
// Off-scale spacing only
|
|
205
|
-
|
|
206
|
-
const spacingTotal = spacing.length + twSpacing.filter((v) => v.value.startsWith('[')).length;
|
|
209
|
+
// Off-scale spacing only (formula in score.mjs, shared with the packages pass).
|
|
210
|
+
const spacingTotal = M.spacing;
|
|
207
211
|
const exactDupes = h.duplicates.exactDuplicates ?? [];
|
|
208
212
|
// A wrapped pair (one file imports the name from the other) is composition,
|
|
209
213
|
// not competition — listed with a badge, but not counted as a duplicate.
|
|
@@ -224,14 +228,11 @@ const typefaces = distinctTypefaces(fontFamilies);
|
|
|
224
228
|
// Token-led repos (shadcn/Tailwind semantic setups) hold most of their colours
|
|
225
229
|
// as deliberate CSS-variable tokens; judging them on the total punishes the
|
|
226
230
|
// exact architecture the ideal recommends. Health rides on the strays instead.
|
|
227
|
-
const colorTokens
|
|
228
|
-
const colorStrays = colors.length - colorTokens;
|
|
229
|
-
const tokenLed = colorTokens >= colorStrays && colorTokens > 0;
|
|
230
|
-
const greyStrays = greys.filter((c) => !c.isToken).length;
|
|
231
|
+
const { colorTokens, colorStrays, tokenLed, greyStrays } = M;
|
|
231
232
|
|
|
232
233
|
// Arbitrary bracket values (p-[13px], text-[10px]) — scale erosion, counted.
|
|
233
234
|
const arbitrary = h.tokens.tailwind?.arbitrary ?? [];
|
|
234
|
-
const arbitraryCount = arbitrary
|
|
235
|
+
const arbitraryCount = M.arbitrary;
|
|
235
236
|
|
|
236
237
|
// !important declarations: the cascade admitting defeat.
|
|
237
238
|
const important = h.tokens.important ?? { count: 0, files: [] };
|
|
@@ -243,52 +244,21 @@ const nearPairs = nearColorPairs(colors);
|
|
|
243
244
|
// rather than wallpaper. Theme variants are not in here; those are the system.
|
|
244
245
|
const collisions = h.tokens.tokenCollisions ?? [];
|
|
245
246
|
|
|
246
|
-
const
|
|
247
|
+
const P = profileOf(h);
|
|
248
|
+
const neverImported = neverImportedComponents(h.components, P.uiDir);
|
|
247
249
|
|
|
248
|
-
// Measurability and
|
|
250
|
+
// Measurability and kind, decided once in profiles/ (telekom/scale, 2026-09-01):
|
|
249
251
|
// when the component detector could not read this repo's stack, the component
|
|
250
252
|
// metrics say so and take no score credit; when the repo is a published
|
|
251
253
|
// library, usage means composition (how the system uses itself), never
|
|
252
254
|
// adoption, and every accusation about orphans is softened to match.
|
|
253
|
-
const componentsMeasured
|
|
254
|
-
const notMeasuredReason =
|
|
255
|
-
const isLibrary = h.profile?.role === 'library';
|
|
255
|
+
const { componentsMeasured, isLibrary, vendoredUi } = M;
|
|
256
|
+
const notMeasuredReason = P.notMeasuredReason;
|
|
256
257
|
// A shadcn ui folder is a vendored catalogue: `shadcn add` copies the source
|
|
257
258
|
// in, people take the whole set at once, and an unused component there is
|
|
258
259
|
// stock on a shelf rather than something a team built and abandoned. It is
|
|
259
260
|
// also protective — the agent reaches for it instead of writing its own worse
|
|
260
261
|
// version. So it is counted and shown, never scored and never a fix to make.
|
|
261
|
-
const vendoredUi = h.profile?.vendoredUi === true;
|
|
262
|
-
|
|
263
|
-
// Benchmark helpers: for a metric value, where does this repo sit among the
|
|
264
|
-
// scanned fleet? ("more colours than 90% of scanned repos")
|
|
265
|
-
function percentile(metric, value) {
|
|
266
|
-
const vals = bench?.stats?.[metric]?.values;
|
|
267
|
-
if (!vals?.length) return null;
|
|
268
|
-
const below = vals.filter((v) => v < value).length;
|
|
269
|
-
return Math.round((below / vals.length) * 100);
|
|
270
|
-
}
|
|
271
|
-
// The flattering twin: what share of the scanned fleet is messier than you.
|
|
272
|
-
function cleanerPct(metric, value) {
|
|
273
|
-
const vals = bench?.stats?.[metric]?.values;
|
|
274
|
-
if (!vals?.length) return null;
|
|
275
|
-
const above = vals.filter((v) => v > value).length;
|
|
276
|
-
return Math.round((above / vals.length) * 100);
|
|
277
|
-
}
|
|
278
|
-
const ideal = (metric) => bench?.ideal2026?.[metric]?.value ?? null;
|
|
279
|
-
const median = (metric) => bench?.stats?.[metric]?.median ?? null;
|
|
280
|
-
// A zero median reads as broken data ("Avg: 0 typefaces"), when it really
|
|
281
|
-
// means "the median repo declares none". Fall back to the fleet mean there;
|
|
282
|
-
// it stays an honest "Avg" and only zeroes out if literally every repo does.
|
|
283
|
-
function displayAvg(metric) {
|
|
284
|
-
const mv = median(metric);
|
|
285
|
-
if (mv === null) return null;
|
|
286
|
-
if (mv !== 0) return mv;
|
|
287
|
-
const vals = bench?.stats?.[metric]?.values ?? [];
|
|
288
|
-
const mean = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
|
|
289
|
-
return mean >= 0.5 ? Math.round(mean) : null;
|
|
290
|
-
}
|
|
291
|
-
const refMedian = (metric) => bench?.referenceSystems?.stats?.[metric]?.median ?? null;
|
|
292
262
|
|
|
293
263
|
// Clickable file paths — vscode:// opens the file straight in the editor.
|
|
294
264
|
const fileLink = (f) => `<a class="path" href="vscode://file/${encodeURI(`${h.repo}/${f}`)}">${esc(f)}</a>`;
|
|
@@ -336,36 +306,8 @@ if (bench && findings.length) {
|
|
|
336
306
|
}
|
|
337
307
|
|
|
338
308
|
// ---------- health scoring ----------
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
// median. Health drives the animated icon and colours:
|
|
342
|
-
// good = at or under the ideal → green checkmark (draws in)
|
|
343
|
-
// warn = over the ideal but better than the 27-repo median: "over the
|
|
344
|
-
// target, better than the average repo" → amber circle (pulses)
|
|
345
|
-
// bad = worse than the median (and the median is already a mess)
|
|
346
|
-
// → coral cross (shakes)
|
|
347
|
-
// Where the median is missing or sits at/below ideal, warn caps at 1.5x ideal.
|
|
348
|
-
// Zero-ideal metrics (duplicates, inline styles): 0 is good; a small tolerance
|
|
349
|
-
// is warn; beyond that bad.
|
|
350
|
-
// There used to be an absence rule here (basically nothing found = bad, on
|
|
351
|
-
// the theory that an empty count means no design system). The noSystemLikely
|
|
352
|
-
// guard now owns that judgement at report level, and for a repo with a real
|
|
353
|
-
// system a zero is discipline, not absence — all spacing on tokens scored red.
|
|
354
|
-
const ZERO_IDEAL = new Set(['exactDuplicates', 'inlineStyles', 'nearPairs', 'important', 'neverImported']);
|
|
355
|
-
const WARN_TOLERANCE = { exactDuplicates: 2, inlineStyles: 10, nearPairs: 2, important: 5, neverImported: 2 };
|
|
356
|
-
function healthOf(metric, value) {
|
|
357
|
-
const iv = ideal(metric);
|
|
358
|
-
if (iv === null) return 'info';
|
|
359
|
-
if (ZERO_IDEAL.has(metric)) {
|
|
360
|
-
if (value === 0) return 'good';
|
|
361
|
-
return value <= WARN_TOLERANCE[metric] ? 'warn' : 'bad';
|
|
362
|
-
}
|
|
363
|
-
if (value <= iv) return 'good';
|
|
364
|
-
const mv = median(metric);
|
|
365
|
-
const warnCap = mv && mv > iv ? mv : iv * 1.5;
|
|
366
|
-
if (value <= warnCap) return 'warn';
|
|
367
|
-
return 'bad';
|
|
368
|
-
}
|
|
309
|
+
// The bands, the tolerances and healthOf itself live in score.mjs (imported
|
|
310
|
+
// above), so a guard or a CI check judges with the same ruler as this page.
|
|
369
311
|
// Our own marks, drawn by hand for this report: a tick that starts at the
|
|
370
312
|
// left foot, a full-bleed cross, a round exclamation, a quiet dash. Generic
|
|
371
313
|
// glyphs on purpose, and our own coordinates on purpose: nothing here is
|
|
@@ -390,59 +332,49 @@ function row(label, valText, refVal, value) {
|
|
|
390
332
|
if (value === refVal) return { label, val: valText, dir: 'eq' };
|
|
391
333
|
return { label, val: valText, dir: value < refVal ? 'down' : 'up' };
|
|
392
334
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
335
|
+
const FALLBACK_TARGET = {
|
|
336
|
+
colors: 'a system needs ~24', greys: 'a scale has up to 13', spacing: 'a dozen deliberate exceptions',
|
|
337
|
+
exactDuplicates: 'should be 0', inlineStyles: 'invisible to any system', nearPairs: 'copy-paste, not decisions',
|
|
338
|
+
important: 'the cascade admitting defeat', neverImported: 'the system nobody found', arbitrary: 'a handful of deliberate exceptions',
|
|
339
|
+
};
|
|
340
|
+
// Dress one judged tile for the page: formatted number, comparison rows.
|
|
341
|
+
// Accepts a judged tile from score.mjs, or the positional form the
|
|
342
|
+
// typography section uses for its unscored tiles: (value, label, metric, fallback).
|
|
343
|
+
function tile(t, pLabel, pMetric, pFallback) {
|
|
344
|
+
if (typeof t === 'number') {
|
|
345
|
+
return tile({ metric: pMetric, label: pLabel, value: t, healthValue: t, health: healthOf(pMetric, t), fallbackTarget: pFallback });
|
|
346
|
+
}
|
|
347
|
+
const { metric, label, value, healthValue, health } = t;
|
|
348
|
+
if (health === 'na') {
|
|
349
|
+
return { num: '—', label, health, metric, healthValue: null,
|
|
350
|
+
rows: [{ label: `not measured: ${notMeasuredReason}`, val: '', dir: '' }] };
|
|
351
|
+
}
|
|
352
|
+
const iv = ideal(metric), rm = refMedian(metric);
|
|
396
353
|
const pct = percentile(metric, value);
|
|
397
354
|
const rows = [];
|
|
398
355
|
if (iv !== null) rows.push(row('Ideal Design System', ZERO_IDEAL.has(metric) ? String(iv) : `~${n(iv)}`, iv, value));
|
|
399
|
-
else rows.push({ label: fallbackTarget, val: '', dir: '' });
|
|
356
|
+
else rows.push({ label: t.fallbackTarget ?? FALLBACK_TARGET[metric], val: '', dir: '' });
|
|
400
357
|
const clean = cleanerPct(metric, value);
|
|
401
358
|
const avgNote = pct !== null && pct >= 60 && health !== 'good' ? ` · messier than ${pct}%`
|
|
402
359
|
: clean !== null && clean >= 60 ? ` · cleaner than ${clean}%` : '';
|
|
403
360
|
const av = displayAvg(metric);
|
|
404
361
|
if (av !== null) rows.push(row('Avg Design System', `${n(av)}${avgNote}`, av, value));
|
|
405
362
|
if (rm !== null && (rm > 0 || ZERO_IDEAL.has(metric) || metric === 'arbitrary')) rows.push(row('Reputable systems', n(rm), rm, value));
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
tile(hardDupes.length, 'duplicated components', 'exactDuplicates', 'should be 0'),
|
|
413
|
-
tile(inline.count, 'inline style blocks', 'inlineStyles', 'invisible to any system'),
|
|
414
|
-
tile(nearPairs.length, 'near-identical colour pairs', 'nearPairs', 'copy-paste, not decisions'),
|
|
415
|
-
tile(important.count, '!important declarations', 'important', 'the cascade admitting defeat'),
|
|
416
|
-
tile(neverImported.length, 'components never imported', 'neverImported', 'the system nobody found'),
|
|
417
|
-
tile(arbitraryCount, 'arbitrary bracket values', 'arbitrary', 'a handful of deliberate exceptions'),
|
|
418
|
-
];
|
|
419
|
-
|
|
420
|
-
// Component metrics the detector could not measure render as such and drop
|
|
421
|
-
// out of the score ('na' is not a scored health); a library's never-imported
|
|
422
|
-
// count stays visible but unscored, with the reason on the tile.
|
|
423
|
-
if (!componentsMeasured) {
|
|
424
|
-
for (const m of ['exactDuplicates', 'neverImported']) {
|
|
425
|
-
const i = bigStats.findIndex((st) => st.metric === m);
|
|
426
|
-
bigStats[i] = { num: '—', label: bigStats[i].label, health: 'na', metric: m, healthValue: null,
|
|
427
|
-
rows: [{ label: `not measured: ${notMeasuredReason}`, val: '', dir: '' }] };
|
|
363
|
+
// A vendored catalogue is never judged for what it left on the shelf, and a
|
|
364
|
+
// library's internal use says nothing about adoption: shown, not scored.
|
|
365
|
+
if (health === 'info' && metric === 'neverImported') {
|
|
366
|
+
rows.push({ label: isLibrary
|
|
367
|
+
? 'library: internal use only, downstream consumers invisible'
|
|
368
|
+
: 'catalogue stock: installed by the shadcn CLI, not used yet', val: '', dir: '' });
|
|
428
369
|
}
|
|
429
|
-
|
|
430
|
-
// but a repo that used everything it installed keeps the credit it earned, so
|
|
431
|
-
// the tile only drops out of the score when it would otherwise accuse.
|
|
432
|
-
} else if (isLibrary || (vendoredUi && neverImported.length > 0)) {
|
|
433
|
-
const t = bigStats.find((st) => st.metric === 'neverImported');
|
|
434
|
-
t.health = 'info';
|
|
435
|
-
t.rows.push({ label: isLibrary
|
|
436
|
-
? 'library: internal use only, downstream consumers invisible'
|
|
437
|
-
: 'catalogue stock: installed by the shadcn CLI, not used yet', val: '', dir: '' });
|
|
370
|
+
return { num: n(value), label, health, rows, metric, healthValue };
|
|
438
371
|
}
|
|
372
|
+
const judgedTiles = tileHealths(M, healthOf);
|
|
373
|
+
const bigStats = judgedTiles.map(tile);
|
|
439
374
|
|
|
440
375
|
// Health score for the hero: the scored tiles averaged (good 100 / warn 55 / bad 10).
|
|
441
|
-
const SCORE_OF = { good: 100, warn: 55, bad: 10 };
|
|
442
376
|
const scoredTiles = bigStats.filter((s) => s.health in SCORE_OF);
|
|
443
|
-
const healthScore =
|
|
444
|
-
? Math.round(scoredTiles.reduce((sum, s) => sum + SCORE_OF[s.health], 0) / scoredTiles.length)
|
|
445
|
-
: null;
|
|
377
|
+
const healthScore = scoreOfTiles(judgedTiles);
|
|
446
378
|
|
|
447
379
|
// ---------- what a fix is worth ----------
|
|
448
380
|
// The score is the average of the scored tiles, so moving one tile across a
|
|
@@ -522,12 +454,12 @@ function paletteSection() {
|
|
|
522
454
|
const rest = sorted.slice(60);
|
|
523
455
|
const cells = shown.map((c) => {
|
|
524
456
|
const src = c.files?.[0] ? ` in ${c.files[0].file}` : '';
|
|
525
|
-
return `<div class="uc${c.isToken ? '' : ' stray'}" style="background:${
|
|
457
|
+
return `<div class="uc${c.isToken ? '' : ' stray'}" style="background:${swatch(c.value)};flex-grow:${Math.max(c.count, 1)}" title="${esc(c.value)} ×${c.count}${esc(src)}">${c.isToken ? '' : '<i></i>'}</div>`;
|
|
526
458
|
}).join('');
|
|
527
459
|
const restCell = rest.length ? `<div class="uc uc-rest" style="flex-grow:${Math.max(rest.reduce((s, c) => s + c.count, 0), 1)}" title="${rest.length} more colours">+${rest.length}</div>` : '';
|
|
528
460
|
|
|
529
461
|
const rampFor = (list) => `
|
|
530
|
-
<div class="grey-strip">${list.map((c) => `<div class="grey-cell" style="background:${
|
|
462
|
+
<div class="grey-strip">${list.map((c) => `<div class="grey-cell" style="background:${swatch(c.value)}" title="${esc(c.value)} ×${c.count}"></div>`).join('')}</div>
|
|
531
463
|
${list.length <= 8 ? `<div class="ramp-labels">${list.map((c) => `<span>${esc(c.value)}</span>`).join('')}</div>` : ''}`;
|
|
532
464
|
const ramp = greys.length >= 4 ? `
|
|
533
465
|
<div class="ramp-head">${eyebrow('Grey ramp · sorted dark → light · how many can you tell apart?')}</div>
|
|
@@ -557,7 +489,7 @@ function paletteSection() {
|
|
|
557
489
|
${ramp}
|
|
558
490
|
${nearPairs.length ? `
|
|
559
491
|
<div class="receipts">${eyebrow(`${nearPairs.length} nearly identical pair${nearPairs.length === 1 ? '' : 's'} · copy-paste, not decisions`)}
|
|
560
|
-
<div class="chips-row">${nearPairs.slice(0, 8).map((pr) => `<span class="vchip" title="every channel within ${pr.d} of its twin"><i class="ndot" style="background:${
|
|
492
|
+
<div class="chips-row">${nearPairs.slice(0, 8).map((pr) => `<span class="vchip" title="every channel within ${pr.d} of its twin"><i class="ndot" style="background:${swatch(pr.a.value)}"></i>${esc(pr.a.value)} ×${pr.a.count} <b class="nsim">≈</b> <i class="ndot" style="background:${swatch(pr.b.value)}"></i>${esc(pr.b.value)} ×${pr.b.count}</span>`).join('')}${nearPairs.length > 8 ? `<span class="vchip dim">+${nearPairs.length - 8} more</span>` : ''}</div>
|
|
561
493
|
${whyToggle('nearPairs')}</div>` : ''}
|
|
562
494
|
</div>
|
|
563
495
|
</section>`;
|
|
@@ -676,6 +608,9 @@ function duplicatesSection() {
|
|
|
676
608
|
// a font size from someone else's repo). Allow only a conservative CSS charset
|
|
677
609
|
// so a malformed or hostile value cannot break out of the attribute.
|
|
678
610
|
const cssSafe = (v) => (typeof v === 'string' && /^[-#0-9a-z%.,()\s\/]+$/i.test(v) && !/[;{}<>"']/.test(v) ? v.trim() : null);
|
|
611
|
+
// A colour swatch is painted with the value itself, so the value must look
|
|
612
|
+
// like a colour and nothing else; anything odd paints grey rather than the page.
|
|
613
|
+
const swatch = (v) => cssSafe(v) ?? '#888';
|
|
679
614
|
// px equivalent of a length, for sorting and for rendering type at real size.
|
|
680
615
|
function toPx(v) {
|
|
681
616
|
const m = /^(-?\d*\.?\d+)\s*(px|rem|em|pt)?$/i.exec(String(v).trim());
|
|
@@ -1151,29 +1086,7 @@ const PKG_LABELS = {
|
|
|
1151
1086
|
nearPairs: 'near-identical colour pairs', important: '!important declarations',
|
|
1152
1087
|
neverImported: 'components nobody imports', arbitrary: 'arbitrary bracket values',
|
|
1153
1088
|
};
|
|
1154
|
-
|
|
1155
|
-
const tokenLed = m.colorTokens >= m.colorStrays && m.colorTokens > 0;
|
|
1156
|
-
const vals = {
|
|
1157
|
-
colors: tokenLed ? m.colorStrays : m.colors,
|
|
1158
|
-
greys: tokenLed ? m.greyStrays : m.greys,
|
|
1159
|
-
spacing: m.spacing,
|
|
1160
|
-
exactDuplicates: m.exactDuplicates,
|
|
1161
|
-
inlineStyles: m.inlineStyles,
|
|
1162
|
-
nearPairs: m.nearPairs,
|
|
1163
|
-
important: m.important,
|
|
1164
|
-
neverImported: m.neverImported,
|
|
1165
|
-
arbitrary: m.arbitrary,
|
|
1166
|
-
};
|
|
1167
|
-
const rows = Object.entries(vals).map(([metric, v]) => ({ metric, value: v, health: healthOf(metric, v) }));
|
|
1168
|
-
const scored = rows.filter((r) => r.health in SCORE_OF);
|
|
1169
|
-
if (!scored.length) return null;
|
|
1170
|
-
const score = Math.round(scored.reduce((sum, r) => sum + SCORE_OF[r.health], 0) / scored.length);
|
|
1171
|
-
// the worst finding: furthest past its ideal among the failing tiles
|
|
1172
|
-
const over = (r) => { const iv = ideal(r.metric); return iv ? r.value / Math.max(iv, 1) : r.value; };
|
|
1173
|
-
const worst = scored.filter((r) => r.health === 'bad').sort((a, b) => over(b) - over(a))[0]
|
|
1174
|
-
?? scored.filter((r) => r.health === 'warn').sort((a, b) => over(b) - over(a))[0];
|
|
1175
|
-
return { score, worst };
|
|
1176
|
-
}
|
|
1089
|
+
const scorePackage = (m) => scorePackageOf(m, healthOf, B);
|
|
1177
1090
|
function packagesSection() {
|
|
1178
1091
|
const pkgs = (h.packages ?? []).filter((p) => p.scored && p.metrics);
|
|
1179
1092
|
const rated = pkgs.map((p) => ({ ...p, ...scorePackage(p.metrics) })).filter((p) => p.score !== undefined && p.score !== null);
|
|
@@ -1737,7 +1650,7 @@ ${componentsSection()}
|
|
|
1737
1650
|
|
|
1738
1651
|
<footer>
|
|
1739
1652
|
<div class="foot-left">
|
|
1740
|
-
<div>Generated by <a class="brand" href="https://github.com/gregkozakiewicz/roast-my-design-system">roast-my-design-system</a> <span class="ver">ver. ${VERSION}</span> · free to run: <
|
|
1653
|
+
<div>Generated by <a class="brand" href="https://github.com/gregkozakiewicz/roast-my-design-system">roast-my-design-system</a> <span class="ver">ver. ${VERSION}</span> · free to run: <span class="mono" id="npx-cmd">npx roast-my-design-system@latest</span> <button type="button" class="copy-pill" id="npx-copy">copy</button> · also a Claude Code + Codex skill and a local MCP server</div>
|
|
1741
1654
|
<div class="foot-sub">Designed and built by <a class="author" href="https://gregkozakiewicz.com/?utm_source=roast-report"><span class="gk-mark"></span>Greg Kozakiewicz</a> · <span class="scan-id" title="scan id">${scanId}</span> · ${FEEDBACK_ASK} <a class="feedback" href="${feedbackUrl(VERSION).replace(/&/g, '&')}">${PULSE_ICON}${FEEDBACK_CTA}</a></div>
|
|
1742
1655
|
</div>
|
|
1743
1656
|
<span class="creds"><span>Non-destructive scan</span><span>Read-only</span><span>Paths are real</span></span>
|
|
@@ -1845,6 +1758,10 @@ if (summaryPath) {
|
|
|
1845
1758
|
writeFileSync(resolve(summaryPath), JSON.stringify({
|
|
1846
1759
|
repo: repoName,
|
|
1847
1760
|
version: VERSION,
|
|
1761
|
+
// The contract two scans are compared by: schemaVersion for the
|
|
1762
|
+
// shape, benchmark for the ruler each scan was measured against.
|
|
1763
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1764
|
+
benchmark: bench ? { builtAt: bench.builtAt, repoCount: bench.repoCount, referenceSystems: bench.referenceSystems?.count ?? 0 } : null,
|
|
1848
1765
|
...(commissionedBy ? { commissionedBy } : {}),
|
|
1849
1766
|
...(notesText ? { notesEmbedded: true } : {}),
|
|
1850
1767
|
...(extraSections.length ? { sectionsEmbedded: extraSections.map((s) => s.title) } : {}),
|
|
@@ -1852,9 +1769,13 @@ if (summaryPath) {
|
|
|
1852
1769
|
score: healthScore,
|
|
1853
1770
|
noSystemLikely,
|
|
1854
1771
|
verdict,
|
|
1855
|
-
role:
|
|
1772
|
+
role: P.kind,
|
|
1856
1773
|
componentsMeasured,
|
|
1857
|
-
|
|
1774
|
+
metrics: (({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }) =>
|
|
1775
|
+
({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }))(M),
|
|
1776
|
+
// value is the number as counted (null when not measured); display is
|
|
1777
|
+
// what the page prints. Until 7.0.0 value was the formatted string.
|
|
1778
|
+
tiles: judgedTiles.map((t, i) => ({ metric: t.metric, label: t.label, value: t.value, display: bigStats[i].num, health: t.health })),
|
|
1858
1779
|
...(startMoves.length ? { moves: startMoves } : {}),
|
|
1859
1780
|
packages: (h.packages ?? []).filter((p) => p.scored && p.metrics)
|
|
1860
1781
|
.map((p) => ({ dir: p.dir, name: p.name, ...(scorePackage(p.metrics) ?? {}) }))
|