roast-my-design-system 9.2.3 → 9.2.5

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 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, 22 duplicated components, and the chart-palette gap named. Score 20.
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
- ![The full diagnosis report for vercel/ai-chatbot in dark mode: a fixed side panel with the health score and what it measures, the stack, how the repo was read as a shadcn install, an index of every section and what is not the team's and not counted; then where the agent will have to guess, the fixes you can make right now, each with what it is worth and its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, 3-yardstick tiles including the 2 shadcn tiles, the adoption map treemap, palette forensics, the shadcn theme variable by variable, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](assets/report-full-dark.png?v=9.2.2)
145
+ ![The full diagnosis report for vercel/ai-chatbot in dark mode: a fixed side panel with the health score and what it measures, the stack, how the repo was read as a shadcn install, an index of every section and what is not the team's and not counted; then where the agent will have to guess, the fixes you can make right now, each with what it is worth and its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, 3-yardstick tiles including the 2 shadcn tiles, the adoption map treemap, palette forensics, the shadcn theme variable by variable, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](assets/report-full-dark.png?v=9.2.4)
146
146
 
147
147
  The same report in light mode (one file, built-in toggle):
148
148
 
149
- ![The diagnosis report in light mode](assets/report-light-hero.png?v=9.2.2)
149
+ ![The diagnosis report in light mode](assets/report-light-hero.png?v=9.2.4)
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",
3
+ "version": "9.2.5",
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": [
@@ -1066,8 +1066,9 @@ function whereToStartSection() {
1066
1066
  c.push({ score: 40, metric: null, title: `Delete the ${dead.length} theme variable${dead.length === 1 ? '' : 's'} nothing uses`,
1067
1067
  sub: `${dead.slice(0, 3).map((r) => `--${esc(r)}`).join(', ')}${dead.length > 3 ? ' and more' : ''} sit in ${esc(P.shadcn.sheet.file)} next to the real theme, and nothing in the repo reads them. An agent opening that file sees 2 colour systems and can not tell which one is dead. ${dead.length * 2} lines to delete; the cheapest fix on this page.` });
1068
1068
  }
1069
- if (agentFiles.length === 0) c.push({ score: 60, metric: null, title: 'Write the agent rules file',
1070
- sub: `No CLAUDE.md, no AGENTS.md. One page naming the canonical components and the tokens file stops your agent guessing on every UI change. Cheapest fix on this list.` });
1069
+ // The agent rules file is not a fix here: it has its own section ("Give the
1070
+ // agent the answers") and must not compete with fixes for the three places
1071
+ // (Greg, 2026-09-30).
1071
1072
  if (componentsMeasured && hardDupes.length > 0) {
1072
1073
  // Pick the pair with the strongest copy-paste evidence: sibling files in
1073
1074
  // app code (same basename, neither in a shared package) are true
@@ -1206,6 +1207,9 @@ function whereToStartSection() {
1206
1207
  // already green, or the move lands inside the band it started in.
1207
1208
  for (const item of c) {
1208
1209
  item.delta = 0; item.target = null; item.why = 'not part of the score';
1210
+ // No score, no points: an unscored report promised "+10" and "raises the
1211
+ // score, by 5" on commerce, strapi and three more (2026-09-30).
1212
+ if (healthScore === null) { item.why = 'no score here'; continue; }
1209
1213
  if (!item.metric) continue;
1210
1214
  const tile = bigStats.find((st) => st.metric === item.metric);
1211
1215
  const now = tile?.healthValue;
@@ -1218,7 +1222,12 @@ function whereToStartSection() {
1218
1222
  if (nb && nb.gain > 0) item.target = nb;
1219
1223
  else item.why = healthOf(item.metric, now) === 'good' ? 'already green' : 'no points on its own';
1220
1224
  }
1221
- const top3 = c.sort((a, b) => (b.delta - a.delta) || (b.score - a.score)).slice(0, 3);
1225
+ // A fix that pays only once finished still outranks one that never pays:
1226
+ // sorting on delta alone put shadcn-admin's one paying fix second, behind a
1227
+ // fix worth nothing (2026-09-30).
1228
+ const tier = (item) => (item.delta > 0 ? 2 : item.target ? 1 : 0);
1229
+ const top3 = c.sort((a, b) => (tier(b) - tier(a)) || (b.delta - a.delta)
1230
+ || ((b.target?.gain ?? 0) - (a.target?.gain ?? 0)) || (b.score - a.score)).slice(0, 3);
1222
1231
  if (!top3.length) return '';
1223
1232
  // The headline is the score with every move applied together, not the sum of
1224
1233
  // the parts: two moves on the same tile must not be counted twice.
@@ -1244,7 +1253,9 @@ function whereToStartSection() {
1244
1253
  ? `${ofThese(paying.length)} could raise the design system health score from <b class="proj">${healthScore} to ${after}</b>.${later.length ? ` ${cap(COUNT[later.length - 1])} more ${later.length === 1 ? 'pays' : 'pay'} ${inFull(later.length)}.` : ''}${rest}`
1245
1254
  : later.length
1246
1255
  ? `${ofThese(later.length)} ${later.length === 1 ? `raises the score, by ${later[0].target.gain},` : 'raise the score'} ${inFull(later.length)}.${rest}`
1247
- : `${total === 1 ? 'It does' : 'They do'} not move the score. ${total === 1 ? 'It is' : 'They are'} here because ${total === 1 ? 'it stops' : 'each one stops'} the agent guessing.`;
1256
+ : healthScore === null
1257
+ ? `This repo gets no score, so there is no score to raise. ${total === 1 ? 'This fix is' : 'These fixes are'} here because ${total === 1 ? 'it stops' : 'each one stops'} the agent guessing.`
1258
+ : `${total === 1 ? 'It does' : 'They do'} not move the score. ${total === 1 ? 'It is' : 'They are'} here because ${total === 1 ? 'it stops' : 'each one stops'} the agent guessing.`;
1248
1259
  const title = paying.length || later.length ? 'Fixes you can make right now to increase the health score' : 'Fixes you can make right now';
1249
1260
  const chip = (item) => item.delta > 0
1250
1261
  ? `<span class="delta" title="once all of them are done">+${item.delta}</span>`
@@ -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 others = (copies ?? []).filter((c) => !same(c.file, file) && canBeDuplicate({ ...c, name }));
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 iconCollisions = exactDuplicates.filter((d) => d.files.every((f) => ICON_DIR_RE.test(f)));
138
- exactDuplicates = exactDuplicates.filter((d) => !iconCollisions.includes(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
@@ -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.3';
4
+ export const VERSION = '9.2.5';
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;