roast-my-design-system 9.2.4 → 9.2.6
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 +3 -3
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/diagnose/index.mjs +1 -1
- package/skills/roast-my-design-system/scripts/harvest/components.mjs +5 -0
- package/skills/roast-my-design-system/scripts/harvest/duplicates.mjs +26 -4
- package/skills/roast-my-design-system/scripts/harvest/walk.mjs +11 -1
- package/skills/roast-my-design-system/scripts/lib/exempt.mjs +18 -2
- package/skills/roast-my-design-system/scripts/lib/guard-api.mjs +1 -1
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/mcp/engine.mjs +2 -2
package/README.md
CHANGED
|
@@ -135,18 +135,18 @@ Eleven reports, hosted exactly as the tool writes them. Every number determinist
|
|
|
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
136
|
- **[vercel/ai-chatbot](https://gregkozakiewicz.github.io/roast-my-design-system/examples/vercel-ai-chatbot.html)** (shadcn install): 74 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)**: 624 arbitrary bracket values,
|
|
138
|
+
- **[dubinc/dub](https://gregkozakiewicz.github.io/roast-my-design-system/examples/dubinc-dub.html)**: 624 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.2.
|
|
3
|
+
"version": "9.2.6",
|
|
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": [
|
|
@@ -1534,7 +1534,7 @@ function skippedLine() {
|
|
|
1534
1534
|
const parts = [];
|
|
1535
1535
|
if (skippedDirs.length) {
|
|
1536
1536
|
const list = skippedDirs.slice(0, 6).map((e) => `<span class="mono">${esc(e.dir)}/</span> (${n(e.files)} UI file${e.files === 1 ? '' : 's'})`).join(', ');
|
|
1537
|
-
parts.push(`<div class="excl">Left out by design: ${list}${skippedDirs.length > 6 ? ` and ${n(skippedDirs.length - 6)} more` : ''}. A docs site, a demo, an examples folder
|
|
1537
|
+
parts.push(`<div class="excl">Left out by design: ${list}${skippedDirs.length > 6 ? ` and ${n(skippedDirs.length - 6)} more` : ''}. A docs site, a demo, an examples folder, a replaced app or the templates a project generator copies out carries its own styling, which is not the product's design language.</div>`);
|
|
1538
1538
|
}
|
|
1539
1539
|
const ra = h.files?.readAnyway;
|
|
1540
1540
|
if (ra) parts.push(`<div class="excl"><span class="mono">${esc(ra.dir)}/</span> is normally left out, but it holds ${n(ra.files)} UI files, more than the rest of the repo, so it was read as the product.</div>`);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { join, basename, posix } from 'node:path';
|
|
8
8
|
import { readSource } from './walk.mjs';
|
|
9
9
|
import { resolveWorkspaces } from '../lib/workspaces.mjs';
|
|
10
|
+
import { isDrawing, SVG_MARKUP_RE } from '../lib/exempt.mjs';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Import aliases a tsconfig declares: "@sentry/scraps/*": ["./static/app/
|
|
@@ -285,11 +286,15 @@ export function harvestComponents(root, codeFiles) {
|
|
|
285
286
|
for (const [file, src] of sources) {
|
|
286
287
|
if (!looksLikeJSXFile(src)) continue;
|
|
287
288
|
const isPage = isPageFile(file);
|
|
289
|
+
const drawing = isDrawing(file, src);
|
|
290
|
+
const draws = SVG_MARKUP_RE.test(src);
|
|
288
291
|
for (const name of definedComponents(src)) {
|
|
289
292
|
components.push({
|
|
290
293
|
name,
|
|
291
294
|
file,
|
|
292
295
|
isPage,
|
|
296
|
+
drawing,
|
|
297
|
+
draws,
|
|
293
298
|
variants: extractVariants(src),
|
|
294
299
|
propsHint: extractPropsHint(src, name),
|
|
295
300
|
usageCount: 0,
|
|
@@ -69,6 +69,23 @@ const EMAIL_PATH_RE = /(^|\/)(?:[a-z0-9]+[-_.])*emails?(?:[-_][a-z0-9]+)*(?:\/|\
|
|
|
69
69
|
// Same-name components living in different icons/ directories are ONE
|
|
70
70
|
// problem — two icon libraries colliding — not N separate duplicates.
|
|
71
71
|
const ICON_DIR_RE = /(^|\/)icons?\//i;
|
|
72
|
+
// The icon folder a file belongs to, up to and including icons/. Names that
|
|
73
|
+
// repeat inside ONE icon folder are variants of one set (likec4's aws/, gcp/
|
|
74
|
+
// and tech/ batch icons; nodejs.org's Logo and Favicon for each partner), not
|
|
75
|
+
// two sets colliding: 121 of 180 icon collisions on 205 repos (2026-09-30).
|
|
76
|
+
const iconSetOf = (f) => { const m = f.match(/^(.*?(?:^|\/)icons?\/)/i); return m ? m[1] : null; };
|
|
77
|
+
// An icon is left out of a name's copies when nothing else of that name
|
|
78
|
+
// draws: a Switch icon is not a second Switch component (teable), a Setting
|
|
79
|
+
// icon not a second settings page (cloudreve). When another copy draws too, the
|
|
80
|
+
// icon stays in: the same checkmark or logo drawn twice is two copies
|
|
81
|
+
// (remotion's Checkmark, Typebot's logo in four places). Measured 2026-09-30.
|
|
82
|
+
// A copy with no answer (a guard that does not say) leaves the set as it is.
|
|
83
|
+
export function competing(defs) {
|
|
84
|
+
if (defs.some((d) => d.drawing === undefined)) return defs;
|
|
85
|
+
const icons = defs.filter((d) => d.drawing);
|
|
86
|
+
const rest = defs.filter((d) => !d.drawing);
|
|
87
|
+
return icons.length && rest.length && !rest.some((d) => d.draws) ? rest : defs;
|
|
88
|
+
}
|
|
72
89
|
|
|
73
90
|
/**
|
|
74
91
|
* Whether a definition can be a duplicate at all. The report's finder and the
|
|
@@ -99,9 +116,12 @@ export function canBeDuplicate({ name, file = '', isPage = false }) {
|
|
|
99
116
|
* or null when it counts none
|
|
100
117
|
* @param same how two paths are compared (a guard matches on path tails)
|
|
101
118
|
*/
|
|
102
|
-
export function duplicateCopies({ name, file = '', isPage = false }, copies, counted = null, same = (a, b) => a === b) {
|
|
119
|
+
export function duplicateCopies({ name, file = '', isPage = false, drawing, draws }, copies, counted = null, same = (a, b) => a === b) {
|
|
103
120
|
if (!canBeDuplicate({ name, file, isPage })) return [];
|
|
104
|
-
const
|
|
121
|
+
const self = { name, file, isPage, drawing, draws };
|
|
122
|
+
const pool = competing([self, ...(copies ?? []).filter((c) => !same(c.file, file) && canBeDuplicate({ ...c, name }))]);
|
|
123
|
+
if (!pool.includes(self)) return [];
|
|
124
|
+
const others = pool.filter((c) => c !== self);
|
|
105
125
|
if (!others.length) return [];
|
|
106
126
|
if ((copies ?? []).some((c) => same(c.file, file))) return (counted ?? []).some((f) => same(f, file)) ? others : [];
|
|
107
127
|
if (ICON_DIR_RE.test(file) && others.every((c) => ICON_DIR_RE.test(c.file))) return [];
|
|
@@ -126,6 +146,7 @@ export function findDuplicates(components, uiDir = null, root = null, uiDirs = n
|
|
|
126
146
|
byName.get(c.name).push(c);
|
|
127
147
|
}
|
|
128
148
|
let exactDuplicates = [...byName.entries()]
|
|
149
|
+
.map(([name, defs]) => [name, competing(defs)])
|
|
129
150
|
.filter(([, defs]) => new Set(defs.map((d) => d.file)).size > 1)
|
|
130
151
|
.map(([name, defs]) => ({
|
|
131
152
|
name,
|
|
@@ -134,8 +155,9 @@ export function findDuplicates(components, uiDir = null, root = null, uiDirs = n
|
|
|
134
155
|
}))
|
|
135
156
|
.sort((a, b) => b.files.length - a.files.length);
|
|
136
157
|
|
|
137
|
-
const
|
|
138
|
-
exactDuplicates = exactDuplicates.filter((d) => !
|
|
158
|
+
const inIcons = exactDuplicates.filter((d) => d.files.every((f) => ICON_DIR_RE.test(f)));
|
|
159
|
+
exactDuplicates = exactDuplicates.filter((d) => !inIcons.includes(d));
|
|
160
|
+
const iconCollisions = inIcons.filter((d) => new Set(d.files.map(iconSetOf)).size > 1);
|
|
139
161
|
|
|
140
162
|
// A "duplicate" where one file imports the same identifier from the other is
|
|
141
163
|
// a wrapper/composition (memoized variant, styled passthrough) — still an
|
|
@@ -36,7 +36,7 @@ const SKIP_DIRS = new Set([
|
|
|
36
36
|
'examples', 'example', 'demos', 'demo', 'playground', 'fixtures',
|
|
37
37
|
// Test/fixture surfaces are not the product's design language — counting a
|
|
38
38
|
// story's `export const Default` 36× would poison the diagnosis numbers.
|
|
39
|
-
'__tests__', '__mocks__', '__fixtures__', '__snapshots__',
|
|
39
|
+
'__tests__', '__mocks__', '__fixtures__', '__testfixtures__', '__snapshots__',
|
|
40
40
|
'cypress', 'e2e', 'playwright', 'test', 'tests', '.storybook',
|
|
41
41
|
]);
|
|
42
42
|
|
|
@@ -78,6 +78,15 @@ const STYLE_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
|
|
|
78
78
|
// (2026-09-17). Named in the report like any other skipped folder.
|
|
79
79
|
const LEGACY_DIR_RE = /^(?:old|legacy|deprecated|archive|archived)$|[-_](?:old|legacy|deprecated)$|^(?:old|legacy)[-_]/i;
|
|
80
80
|
|
|
81
|
+
// Scaffolding: the code a project generator copies into someone else's new
|
|
82
|
+
// repo, not the product. A starters/ folder, or a templates folder kept by a
|
|
83
|
+
// generator (create-app, a cli), a script or a dev tool: backstage's
|
|
84
|
+
// create-app/templates, the dashboard starter's scripts/cleanup-templates,
|
|
85
|
+
// BuilderIO's starters (133 duplicate copies). The name alone is not enough:
|
|
86
|
+
// backstage's scaffolder form templates, OpenCTI's case templates and
|
|
87
|
+
// nocobase's block templates are screens of the product (2026-09-30).
|
|
88
|
+
const SCAFFOLD_DIR_RE = /(^|\/)starters$|(^|\/)(?:create-[\w-]+|cli|scripts|dev|generators?)\/(?:[^/]+\/)*[\w-]*templates?$/i;
|
|
89
|
+
|
|
81
90
|
export function walkRepo(root, maxDepth = 14, exclusions = null, readAnyway = new Set()) {
|
|
82
91
|
const files = { code: [], styles: [], other: [] };
|
|
83
92
|
const excluded = exclusions?.match ?? (() => null);
|
|
@@ -135,6 +144,7 @@ export function walkRepo(root, maxDepth = 14, exclusions = null, readAnyway = ne
|
|
|
135
144
|
const p = join(dir, e.name);
|
|
136
145
|
if (e.name === 'public' && e.isDirectory() && !hasComponentSource(p)) continue;
|
|
137
146
|
const rel = relative(root, p).replaceAll('\\', '/');
|
|
147
|
+
if (e.isDirectory() && SCAFFOLD_DIR_RE.test(rel)) { countSkipped(p, rel); continue; }
|
|
138
148
|
const hit = excluded(rel);
|
|
139
149
|
if (hit) {
|
|
140
150
|
if (e.isDirectory()) countExcluded(p, depth + 1, hit);
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* enough though, or every Badge component would walk free: the contents must
|
|
16
16
|
* actually draw SVG. A Badge that is plain styled UI gets judged like anything
|
|
17
17
|
* else. A file that is mostly SVG earns it on contents alone, whatever it is
|
|
18
|
-
* called.
|
|
18
|
+
* called. So does a file that draws SVG inside an icons, logos or
|
|
19
|
+
* illustrations folder: an icon set names its files after what they show
|
|
20
|
+
* (ActionSendEmail.tsx, Server.tsx), not after being an icon.
|
|
19
21
|
*
|
|
20
22
|
* Render-to-image: an OG card or a PDF invoice is a picture drawn with code.
|
|
21
23
|
* satori and react-pdf accept nothing but inline styles, so there is no
|
|
@@ -105,6 +107,11 @@ export function isEmail(file, text = '', email = null) {
|
|
|
105
107
|
export const CRASH_PAGE_RE = /(^|\/)global-error\.(tsx|jsx)$/;
|
|
106
108
|
export const ARTWORK_NAME_RE = /(^|\/)[\w.-]*(icon|logo|badge|illustration|shield|artwork|graphic|background)[\w.-]*\.(tsx|jsx)$/i;
|
|
107
109
|
export const SVG_MARKUP_RE = /<(svg|path|rect|circle|ellipse|polygon|defs|mask)\b/i;
|
|
110
|
+
// An icon set's folder. Measured on 205 repos (2026-09-30): 1,252 files freed
|
|
111
|
+
// in 20 repos, every one an icon, a logo or a loader; none held state or a
|
|
112
|
+
// click handler. teable fell 28 to 24: its 250 icons had padded the files
|
|
113
|
+
// that the per-100-files rates divide by.
|
|
114
|
+
export const ARTWORK_DIR_RE = /(^|\/)(icons?|logos?|illustrations?)\//i;
|
|
108
115
|
// A picture drawn from the DOM (a shareable card, a receipt) is the same
|
|
109
116
|
// medium: the tool reads the styling off the element (2026-09-17).
|
|
110
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)['"]/;
|
|
@@ -115,6 +122,15 @@ export const RENDERER_PATH_RE = /(^|\/)(renderers?|scene|canvas)\/|renderElement
|
|
|
115
122
|
export const svgHeavy = (text) =>
|
|
116
123
|
(text.match(/<(?:svg|path|rect|circle|ellipse|polygon|mask|defs)\b/g) ?? []).length >= 15;
|
|
117
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Whether a file is a drawing kept in an icons, logos or illustrations folder.
|
|
127
|
+
* The duplicate finder uses it: a Switch icon is not a second Switch component
|
|
128
|
+
* (teable, 2026-09-30). The folder, not the name: two files called Logo or
|
|
129
|
+
* Badge are two versions of one thing (dub, cal.com), and not "mostly SVG": a
|
|
130
|
+
* demo page full of drawings still defines real Buttons (json-render).
|
|
131
|
+
*/
|
|
132
|
+
export const isDrawing = (file, text = '') => ARTWORK_DIR_RE.test(file) && SVG_MARKUP_RE.test(text);
|
|
133
|
+
|
|
118
134
|
/**
|
|
119
135
|
* Why this file is exempt from judgement, as a sentence, or null when it is
|
|
120
136
|
* fair game. Needs the text as well as the name: the artwork exemption is
|
|
@@ -129,7 +145,7 @@ export function exemptReason(file, text = '', { email = null } = {}) {
|
|
|
129
145
|
if (RENDERER_PATH_RE.test(file)) return 'a renderer draws pixels, so its colours are the picture rather than the interface';
|
|
130
146
|
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';
|
|
131
147
|
if (svgHeavy(text)) return 'the file is mostly drawing rather than styling';
|
|
132
|
-
if (ARTWORK_NAME_RE.test(file) && SVG_MARKUP_RE.test(text)) return 'the colours belong to the artwork, not to the interface';
|
|
148
|
+
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';
|
|
133
149
|
return null;
|
|
134
150
|
}
|
|
135
151
|
|
|
@@ -53,7 +53,7 @@ export { isPageFile, looksLikeJSXFile, componentNamesIn } from '../harvest/compo
|
|
|
53
53
|
|
|
54
54
|
// The files no checker should judge. Exported so a guard reads the same list
|
|
55
55
|
// as the engine rather than keeping a copy that drifts.
|
|
56
|
-
export { EMAIL_PRINT_RE, ARTWORK_NAME_RE, SVG_MARKUP_RE, exemptReason, isEmail } from './exempt.mjs';
|
|
56
|
+
export { EMAIL_PRINT_RE, ARTWORK_NAME_RE, ARTWORK_DIR_RE, SVG_MARKUP_RE, exemptReason, isEmail, isDrawing } from './exempt.mjs';
|
|
57
57
|
// !important as the medium (7.5): a widget stylesheet that must beat its host
|
|
58
58
|
// page, and a selector aimed at a library's own class names. The guard reads
|
|
59
59
|
// the same patterns, so the two checkers agree about the same declaration.
|
|
@@ -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.2.
|
|
4
|
+
export const VERSION = '9.2.6';
|
|
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.
|
|
@@ -14,7 +14,7 @@ import { extractStyling } from '../harvest/tokens.mjs';
|
|
|
14
14
|
import { componentNamesIn, isPageFile } from '../harvest/components.mjs';
|
|
15
15
|
import { duplicateCopies } from '../harvest/duplicates.mjs';
|
|
16
16
|
import { hexRgb } from '../lib/nearpairs.mjs';
|
|
17
|
-
import { exemptReason } from '../lib/exempt.mjs';
|
|
17
|
+
import { exemptReason, isDrawing, SVG_MARKUP_RE } from '../lib/exempt.mjs';
|
|
18
18
|
import { extraDeclarations, fontDeclarations } from '../lib/declarations.mjs';
|
|
19
19
|
import { typefaceOf, GENERIC_FONTS } from '../lib/typefaces.mjs';
|
|
20
20
|
import { kitPaintFindings } from '../lib/kitpaint.mjs';
|
|
@@ -94,7 +94,7 @@ function duplicateFindings({ text, file, k, add }) {
|
|
|
94
94
|
for (const name of componentNamesIn(text, file)) {
|
|
95
95
|
const dupe = k.dupeByName.get(name);
|
|
96
96
|
const counted = dupe ? dupe.files.map((f) => (typeof f === 'string' ? f : f.file)) : null;
|
|
97
|
-
const existing = duplicateCopies({ name, file: file ?? '', isPage: !!file && isPageFile(file) }, k.byName.get(name), counted);
|
|
97
|
+
const existing = duplicateCopies({ name, file: file ?? '', isPage: !!file && isPageFile(file), drawing: isDrawing(file ?? '', text), draws: SVG_MARKUP_RE.test(text) }, k.byName.get(name), counted);
|
|
98
98
|
if (!existing.length) continue;
|
|
99
99
|
// the scan may already include this very file — count the OTHER copies
|
|
100
100
|
const otherCopies = counted ? counted.filter((f) => f !== file) : null;
|