roast-my-design-system 9.3.1 → 9.3.3
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 +4 -4
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/harvest/tokens.mjs +16 -14
- package/skills/roast-my-design-system/scripts/harvest/walk.mjs +4 -1
- package/skills/roast-my-design-system/scripts/lib/exempt.mjs +68 -0
- package/skills/roast-my-design-system/scripts/lib/guard-api.mjs +5 -2
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/mcp/engine.mjs +5 -2
- package/skills/roast-my-design-system/scripts/profiles/index.mjs +10 -6
package/README.md
CHANGED
|
@@ -133,20 +133,20 @@ Eleven reports, hosted exactly as the tool writes them. Every number determinist
|
|
|
133
133
|
- **[Metabase](https://gregkozakiewicz.github.io/roast-my-design-system/examples/metabase-mantine.html)** (Mantine): its own wrapper over Mantine counts as the kit, so 2,679 files are read instead of 392; nothing written onto components, 2 spacings per 100 kit files. Score 47.
|
|
134
134
|
- **[SigNoz](https://gregkozakiewicz.github.io/roast-my-design-system/examples/signoz-antd.html)** (Ant Design): 2 colours and 7 spacings per 100 kit files written in style objects where a token exists. Score 51.
|
|
135
135
|
- **[Apache Airflow](https://gregkozakiewicz.github.io/roast-my-design-system/examples/airflow-chakra.html)** (Chakra UI): no colours written onto components, 4 spacings per 100 kit files as pixel strings where a space step exists. Score 43.
|
|
136
|
-
- **[vercel/ai-chatbot](https://gregkozakiewicz.github.io/roast-my-design-system/examples/vercel-ai-chatbot.html)** (shadcn install):
|
|
136
|
+
- **[vercel/ai-chatbot](https://gregkozakiewicz.github.io/roast-my-design-system/examples/vercel-ai-chatbot.html)** (shadcn install): 71 values like [13px] written outside the Tailwind scale, and 66 palette colours per 100 files where a theme variable exists; Claude's notes embedded. Score 80.
|
|
137
137
|
- **[excalidraw/excalidraw](https://gregkozakiewicz.github.io/roast-my-design-system/examples/excalidraw-excalidraw.html)**: 78 off-scale spacing values and 90 !important declarations. Score 55.
|
|
138
|
-
- **[dubinc/dub](https://gregkozakiewicz.github.io/roast-my-design-system/examples/dubinc-dub.html)**:
|
|
138
|
+
- **[dubinc/dub](https://gregkozakiewicz.github.io/roast-my-design-system/examples/dubinc-dub.html)**: 642 arbitrary bracket values, 21 duplicated components, and the chart-palette gap named. Score 20.
|
|
139
139
|
- **[telekom/scale](https://gregkozakiewicz.github.io/roast-my-design-system/examples/telekom-scale.html)** (Stencil): 95 Stencil components read by tag; 66 spacing values outside the scale where about 12 would do; Claude's notes embedded. Score 55.
|
|
140
140
|
- **[magicuidesign/magicui](https://gregkozakiewicz.github.io/roast-my-design-system/examples/magicui.html)** (registry): counted on the components it publishes, 52 off-theme colours per 100 files in the code it ships, its docs site kept out and named. Score 78.
|
|
141
141
|
- **[adobe/spectrum-web-components](https://gregkozakiewicz.github.io/roast-my-design-system/examples/adobe-spectrum.html)** (Lit): hardcoded colours sitting beside 744 colour tokens, and 37 !important declarations. Score 66.
|
|
142
142
|
|
|
143
143
|
The full report for vercel/ai-chatbot. The verdict answers what the agent will learn here, the gap section comes first, then "What the repo teaches the agent", Claude's read of the scan:
|
|
144
144
|
|
|
145
|
-

|
|
146
146
|
|
|
147
147
|
The same report in light mode (one file, built-in toggle):
|
|
148
148
|
|
|
149
|
-

|
|
150
150
|
|
|
151
151
|
## Why the numbers hold
|
|
152
152
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "9.3.
|
|
3
|
+
"version": "9.3.3",
|
|
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 112 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": [
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync } from 'node:fs';
|
|
10
10
|
import {
|
|
11
|
-
isEmail,
|
|
11
|
+
isEmail, foreignStylesheet, CRASH_PAGE_RE, exemptReason, isLibraryClass, WIDGET_CSS_RE, WIDGET_CONFIG_RE,
|
|
12
12
|
} from '../lib/exempt.mjs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { canonical, parseColor } from '../lib/color.mjs';
|
|
@@ -25,7 +25,10 @@ class Tally {
|
|
|
25
25
|
e.count++;
|
|
26
26
|
e.files.set(file, (e.files.get(file) ?? 0) + 1);
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
// `every` keeps the whole per-file tally beside the capped list shown in
|
|
29
|
+
// the report, for a caller that has to split the count by folder exactly
|
|
30
|
+
// (profiles/splitArbitrary: installed code against the team's own).
|
|
31
|
+
toJSON(limitFiles = 5, { every = false } = {}) {
|
|
29
32
|
return [...this.map.values()]
|
|
30
33
|
.sort((a, b) => b.count - a.count)
|
|
31
34
|
.map((e) => ({
|
|
@@ -33,6 +36,7 @@ class Tally {
|
|
|
33
36
|
count: e.count,
|
|
34
37
|
files: [...e.files.entries()].sort((a, b) => b[1] - a[1]).slice(0, limitFiles)
|
|
35
38
|
.map(([file, count]) => ({ file, count })),
|
|
39
|
+
...(every ? { every: [...e.files.entries()].map(([file, count]) => ({ file, count })) } : {}),
|
|
36
40
|
}));
|
|
37
41
|
}
|
|
38
42
|
}
|
|
@@ -500,7 +504,6 @@ export function harvestTokens(root, styleFiles, codeFiles, { email = null } = {}
|
|
|
500
504
|
// reason to discredit the whole report.
|
|
501
505
|
// Email, artwork, mostly-SVG files and pixel renderers: all imported from
|
|
502
506
|
// lib/exempt.mjs, the one list every checker reads.
|
|
503
|
-
const ARTWORK_RE = ARTWORK_NAME_RE;
|
|
504
507
|
// every file a check skipped, with the reason, so the report can name them
|
|
505
508
|
const exemptFiles = [];
|
|
506
509
|
const skip = (f, src) => { const r = exemptReason(f, src, { email }); if (r) exemptFiles.push({ file: f, reason: r }); return true; };
|
|
@@ -566,15 +569,14 @@ export function harvestTokens(root, styleFiles, codeFiles, { email = null } = {}
|
|
|
566
569
|
paletteFiles.add(f);
|
|
567
570
|
for (const m of src.matchAll(HEX_RE)) tokenDefined.add(normalizeHex(m[0]));
|
|
568
571
|
}
|
|
569
|
-
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
if (EMAIL_KIT_RE.test(src)) { skip(f, src); continue; }
|
|
572
|
+
// Artwork, renderers, render-to-image surfaces (OG cards, PDF invoices),
|
|
573
|
+
// pages a headless browser prints, emails written with a kit: one rule,
|
|
574
|
+
// lib/exempt.mjs, the same answer the live checks and the guard give.
|
|
575
|
+
// Until 9.3.3 this counter kept its own list, and the two had drifted: a
|
|
576
|
+
// file named Badge or IconButton walked free here on its name alone
|
|
577
|
+
// (2,321 files in 173 repos) while an icon in an icons folder was
|
|
578
|
+
// counted here and exempt everywhere else (likec4's 12,203 icon colours).
|
|
579
|
+
if (exemptReason(f, src, { email })) { skip(f, src); continue; }
|
|
578
580
|
|
|
579
581
|
// Tailwind classes
|
|
580
582
|
for (const cls of classStrings(src)) {
|
|
@@ -600,7 +602,7 @@ export function harvestTokens(root, styleFiles, codeFiles, { email = null } = {}
|
|
|
600
602
|
// length inside them is still a hardcoded value — harvest those from every
|
|
601
603
|
// non-trivial block; count only the all-literal blocks.
|
|
602
604
|
const allBlocks = inlineStyleBlocks(src).filter((b) => !TRIVIAL_INLINE_RE.test(b.trim()));
|
|
603
|
-
const staticBlocks =
|
|
605
|
+
const staticBlocks = allBlocks.filter(isStaticInline);
|
|
604
606
|
if (staticBlocks.length) {
|
|
605
607
|
inlineStyleCount += staticBlocks.length;
|
|
606
608
|
inlineStyleFiles.set(f, (inlineStyleFiles.get(f) ?? 0) + staticBlocks.length);
|
|
@@ -807,7 +809,7 @@ export function harvestTokens(root, styleFiles, codeFiles, { email = null } = {}
|
|
|
807
809
|
spacing: twSpacing.toJSON(),
|
|
808
810
|
radii: twRadii.toJSON(),
|
|
809
811
|
textSizes: twTextSizes.toJSON(),
|
|
810
|
-
arbitrary: twArbitrary.toJSON(),
|
|
812
|
+
arbitrary: twArbitrary.toJSON(5, { every: true }),
|
|
811
813
|
},
|
|
812
814
|
important: {
|
|
813
815
|
// aimed at a library's own class names: kept out, named at the top
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* stylesheets of any flavor, and config — not just a happy-path shadcn layout.
|
|
5
5
|
*/
|
|
6
6
|
import { CATALOGUE } from '../profiles/shadcn-data.mjs';
|
|
7
|
-
import { emailContextOf } from '../lib/exempt.mjs';
|
|
7
|
+
import { emailContextOf, printedFilesOf } from '../lib/exempt.mjs';
|
|
8
8
|
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
9
9
|
import { join, relative, extname } from 'node:path';
|
|
10
10
|
|
|
@@ -183,6 +183,9 @@ export function walkRepo(root, maxDepth = 14, exclusions = null, readAnyway = ne
|
|
|
183
183
|
// where the repo keeps its emails, read once here so every checker that
|
|
184
184
|
// is handed the walk judges an email the same way (lib/exempt.mjs)
|
|
185
185
|
files.email = emailContextOf(files, (f) => readSource(join(root, f)));
|
|
186
|
+
// and the files a headless browser prints to a PDF, which ride in the same
|
|
187
|
+
// context so every checker that honours the email rule honours this too
|
|
188
|
+
files.email.printed = printedFilesOf(files, (f) => readSource(join(root, f)));
|
|
186
189
|
return files;
|
|
187
190
|
}
|
|
188
191
|
|
|
@@ -116,6 +116,73 @@ export const ARTWORK_DIR_RE = /(^|\/)(icons?|logos?|illustrations?)\//i;
|
|
|
116
116
|
// medium: the tool reads the styling off the element (2026-09-17).
|
|
117
117
|
export const RENDER_TO_IMAGE_RE = /ImageResponse|from ['"]satori['"]|from ['"]@react-pdf|next\/og|from ['"](?:html-to-image|html2canvas(?:-pro)?|dom-to-image(?:-more)?|modern-screenshot)['"]/;
|
|
118
118
|
export const OG_ROUTE_RE = /(^|\/)api\/og\//;
|
|
119
|
+
|
|
120
|
+
// Printed by a headless browser: React turned into HTML (renderToStaticMarkup)
|
|
121
|
+
// and handed to puppeteer or playwright for a PDF or a screenshot. The page
|
|
122
|
+
// loads none of the app's stylesheets, so its styling is written inline, like
|
|
123
|
+
// an email's. The templates carry no marker of their own; the file that
|
|
124
|
+
// prints them does (rybbit's PDF reports: 31 of its 52 inline styles, score
|
|
125
|
+
// 40 against 50 without them). Found in 1 of 205 repos (2026-09-30), and
|
|
126
|
+
// nothing else has that shape: a file that renders React to HTML and drives
|
|
127
|
+
// a headless browser is always making a picture or a PDF.
|
|
128
|
+
const HEADLESS_DEP_RE = /^(?:puppeteer(?:-core)?|playwright(?:-core)?|@sparticuz\/chromium|chrome-aws-lambda)$/;
|
|
129
|
+
const HEADLESS_IMPORT_RE = /(?:from\s+|require\(\s*|import\(\s*)['"](?:puppeteer(?:-core)?|playwright(?:-core)?|@sparticuz\/chromium|chrome-aws-lambda)['"]/;
|
|
130
|
+
const SSR_RENDER_RE = /\brenderTo(?:StaticMarkup|String|StaticNodeStream)\s*\(/;
|
|
131
|
+
const REL_IMPORT_RE = /(?:from\s+|import\(\s*)['"](\.{1,2}\/[^'"]+)['"]/g;
|
|
132
|
+
|
|
133
|
+
/** A relative import, resolved against the walk's files, or null. */
|
|
134
|
+
function resolveRelative(fromDir, spec, code) {
|
|
135
|
+
const parts = fromDir ? fromDir.split('/') : [];
|
|
136
|
+
for (const s of spec.split('/')) {
|
|
137
|
+
if (s === '.' || s === '') continue;
|
|
138
|
+
if (s === '..') parts.pop(); else parts.push(s);
|
|
139
|
+
}
|
|
140
|
+
const p = parts.join('/');
|
|
141
|
+
const stem = p.replace(/\.[cm]?[jt]sx?$/, '');
|
|
142
|
+
const exts = ['.tsx', '.ts', '.jsx', '.js'];
|
|
143
|
+
for (const c of [p, ...exts.map((e) => stem + e), ...exts.map((e) => `${stem}/index${e}`)]) if (code.has(c)) return c;
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The component files a headless browser prints, for the exemption context.
|
|
149
|
+
* Only packages with a headless browser among their runtime dependencies are
|
|
150
|
+
* read (a test setup lists it as a devDependency), and only the printer's own
|
|
151
|
+
* folder is followed: a shared Button it imports from elsewhere stays judged.
|
|
152
|
+
* @param files the walk's { code, other } (repo-relative paths)
|
|
153
|
+
* @param read (file) => text or null
|
|
154
|
+
*/
|
|
155
|
+
export function printedFilesOf(files, read) {
|
|
156
|
+
const homes = [];
|
|
157
|
+
for (const f of (files.other ?? []).filter((x) => base(x) === 'package.json')) {
|
|
158
|
+
let pkg; try { pkg = JSON.parse(read(f) ?? ''); } catch { continue; }
|
|
159
|
+
if (Object.keys(pkg?.dependencies ?? {}).some((d) => HEADLESS_DEP_RE.test(d))) homes.push(dir(f));
|
|
160
|
+
}
|
|
161
|
+
if (!homes.length) return [];
|
|
162
|
+
const code = new Set(files.code ?? []);
|
|
163
|
+
const under = (f, h) => h === '' || f.startsWith(`${h}/`);
|
|
164
|
+
const printed = new Set();
|
|
165
|
+
for (const f of files.code ?? []) {
|
|
166
|
+
if (!/\.[cm]?[jt]sx?$/.test(f) || !homes.some((h) => under(f, h))) continue;
|
|
167
|
+
const text = read(f) ?? '';
|
|
168
|
+
if (!HEADLESS_IMPORT_RE.test(text) || !SSR_RENDER_RE.test(text)) continue;
|
|
169
|
+
if (/\.(tsx|jsx)$/.test(f)) printed.add(f);
|
|
170
|
+
const home = dir(f);
|
|
171
|
+
const seen = new Set([f]);
|
|
172
|
+
const queue = [[f, text]];
|
|
173
|
+
while (queue.length) {
|
|
174
|
+
const [from, src] = queue.shift();
|
|
175
|
+
for (const m of src.matchAll(REL_IMPORT_RE)) {
|
|
176
|
+
const target = resolveRelative(dir(from), m[1], code);
|
|
177
|
+
if (!target || seen.has(target) || !under(target, home)) continue;
|
|
178
|
+
seen.add(target);
|
|
179
|
+
if (/\.(tsx|jsx)$/.test(target)) printed.add(target);
|
|
180
|
+
queue.push([target, read(target) ?? '']);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return [...printed].sort();
|
|
185
|
+
}
|
|
119
186
|
export const RENDERER_PATH_RE = /(^|\/)(renderers?|scene|canvas)\/|renderElement|DebugCanvas/i;
|
|
120
187
|
|
|
121
188
|
/** Mostly drawing rather than styling, whatever the file is called. */
|
|
@@ -144,6 +211,7 @@ export function exemptReason(file, text = '', { email = null } = {}) {
|
|
|
144
211
|
if (CRASH_PAGE_RE.test(file)) return 'the crash page replaces the root layout, so the stylesheet never loads there and its styling has to be inline';
|
|
145
212
|
if (RENDERER_PATH_RE.test(file)) return 'a renderer draws pixels, so its colours are the picture rather than the interface';
|
|
146
213
|
if (OG_ROUTE_RE.test(file) || RENDER_TO_IMAGE_RE.test(text)) return 'a render-to-image surface accepts nothing but inline styling, so there is no on-system way to write one';
|
|
214
|
+
if (email?.printed?.includes(file)) return 'pages a headless browser prints to a PDF or an image load none of the app\'s stylesheets, so their styling has to be inline';
|
|
147
215
|
if (svgHeavy(text)) return 'the file is mostly drawing rather than styling';
|
|
148
216
|
if ((ARTWORK_NAME_RE.test(file) || ARTWORK_DIR_RE.test(file)) && SVG_MARKUP_RE.test(text)) return 'the colours belong to the artwork, not to the interface';
|
|
149
217
|
return null;
|
|
@@ -17,7 +17,7 @@ export { designGaps } from './gaps.mjs';
|
|
|
17
17
|
import { decideProfile, profileOf, installedDirs } from '../profiles/index.mjs';
|
|
18
18
|
import { KITS } from '../profiles/kit-common.mjs';
|
|
19
19
|
import { kitPaintFindings } from './kitpaint.mjs';
|
|
20
|
-
import { PALETTE_CLASS_RE, blankComments } from '../harvest/paint.mjs';
|
|
20
|
+
import { PALETTE_CLASS_RE, DEMO_PATH_RE, blankComments } from '../harvest/paint.mjs';
|
|
21
21
|
import { harvestTokens, extractStyling, normalizeHex, isGrey } from '../harvest/tokens.mjs';
|
|
22
22
|
import { harvestComponents, definedComponents, tsconfigAliases } from '../harvest/components.mjs';
|
|
23
23
|
import { findDuplicates } from '../harvest/duplicates.mjs';
|
|
@@ -63,7 +63,10 @@ export { WIDGET_CSS_RE, WIDGET_CONFIG_RE, LIBRARY_CLASS_RE, isLibraryClass } fro
|
|
|
63
63
|
// blankComments (8.4.5) is what the report and the live checks run on a file
|
|
64
64
|
// before matching, so a class named in a comment paints nothing; a guard that
|
|
65
65
|
// matches the raw text counts it, and disagrees with the report.
|
|
66
|
-
|
|
66
|
+
// DEMO_PATH_RE (9.3.2) is the folders the palette count leaves out, stories,
|
|
67
|
+
// examples and demos: a guard that skips them flags palette classes in the
|
|
68
|
+
// same files the report counts.
|
|
69
|
+
export { PALETTE_CLASS_RE, DEMO_PATH_RE, blankComments };
|
|
67
70
|
// A product built on a kit (MUI, Mantine, Chakra UI, Ant Design), 8.4.6: a
|
|
68
71
|
// colour or a pixel size written onto a kit component where the theme has a
|
|
69
72
|
// value. kitPaintFindings(fileText, system.profile.kit, { file }) returns the
|
|
@@ -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 = '9.3.
|
|
4
|
+
export const VERSION = '9.3.3';
|
|
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.
|
|
@@ -188,8 +188,11 @@ export function validateContent(content, k) {
|
|
|
188
188
|
// (editing them is the intended use) and a demo folder is not own code,
|
|
189
189
|
// exactly the files the tile leaves out.
|
|
190
190
|
const inShadcnDoors = (f) => !!f && k.shadcn.doors.some((d) => f === d || f.startsWith(`${d}/`));
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
// A demo folder is left out on a Tailwind theme too: the report's tile never
|
|
192
|
+
// counted one, and the live check flagged it (2026-09-30).
|
|
193
|
+
const demo = !!file && DEMO_PATH_RE.test(file);
|
|
194
|
+
const paletteRule = k.tailwind ? (demo ? null : 'tailwind')
|
|
195
|
+
: k.shadcn && !(file && (inShadcnDoors(file) || demo)) ? 'shadcn'
|
|
193
196
|
: null;
|
|
194
197
|
if (paletteRule && !css) {
|
|
195
198
|
const tw = k.tailwind ?? {};
|
|
@@ -197,14 +197,18 @@ export function splitArbitrary(entries, dirs) {
|
|
|
197
197
|
const installedValues = new Map();
|
|
198
198
|
let installedUses = 0;
|
|
199
199
|
for (const e of entries) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
// the whole per-file tally when the harvest kept one (every), else the
|
|
201
|
+
// capped list: a use in a file that fell off the list of five was counted
|
|
202
|
+
// as the team's own, so ai-chatbot's installed badge put one of shadcn's
|
|
203
|
+
// [3px] values in the headline (2026-09-30)
|
|
204
|
+
const all = e.every ?? e.files ?? [];
|
|
205
|
+
const ownFiles = all.filter((f) => !underAny(f.file, dirs)).sort((a, b) => b.count - a.count).slice(0, 5);
|
|
206
|
+
const instCount = all.filter((f) => underAny(f.file, dirs)).reduce((s, f) => s + f.count, 0);
|
|
203
207
|
if (instCount) { installedUses += instCount; installedValues.set(e.value, (installedValues.get(e.value) ?? 0) + instCount); }
|
|
204
|
-
// the
|
|
205
|
-
//
|
|
208
|
+
// own is the remainder of the entry's total, never a sum of the files
|
|
209
|
+
// that happened to be listed
|
|
206
210
|
const ownCount = e.count - instCount;
|
|
207
|
-
if (ownCount > 0) own.push({ ...
|
|
211
|
+
if (ownCount > 0) { const { every, ...rest } = e; own.push({ ...rest, count: ownCount, files: ownFiles }); }
|
|
208
212
|
}
|
|
209
213
|
own.sort((a, b) => b.count - a.count);
|
|
210
214
|
const values = [...installedValues.entries()].sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
|