roast-my-design-system 9.7.0 → 9.7.1

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
@@ -17,7 +17,7 @@ npx roast-my-design-system@latest
17
17
  Run it at the root of a UI repo. One second later, a report opens.
18
18
  No account. No network. No telemetry. Nothing in your repo changes.
19
19
 
20
- **Current release: 9.3.** Version history in [CHANGELOG.md](CHANGELOG.md).
20
+ Version history is in [CHANGELOG.md](CHANGELOG.md).
21
21
 
22
22
  ## The idea
23
23
 
@@ -142,11 +142,11 @@ Eleven reports, hosted exactly as the tool writes them. Every number determinist
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.7.0)
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.7.1)
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.7.0)
149
+ ![The diagnosis report in light mode](assets/report-light-hero.png?v=9.7.1)
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.7.0",
3
+ "version": "9.7.1",
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": [
@@ -1352,10 +1352,10 @@ function whereToStartSection() {
1352
1352
  else c.push(gate === 'repaint'
1353
1353
  ? { score: 20 + pt.tin.per100 / 4, metric: 'paintTin', after: 0,
1354
1354
  title: `Repaint the ${n(pt.tin.uses)} colours from outside the theme`,
1355
- sub: `${esc(s0.value)} appears ${s0.count} times${f0 ? `, ${esc(basename(f0.file))} alone carries ${f0.count}` : ''}. The theme file already has a variable for each (a grey is <code>text-muted-foreground</code>, a status colour is a Badge variant or a variable you add). Swap the class, not the value.` }
1355
+ sub: `${esc(s0.value)} appears ${s0.count} times.${f0 ? ` ${esc(basename(f0.file))} alone has ${f0.count} palette classes.` : ''} The theme file already has a variable for each (a grey is <code>text-muted-foreground</code>, a status colour is a Badge variant or a variable you add). Swap the class, not the value.` }
1356
1356
  : { score: 20 + pt.tin.per100 / 4, metric: 'paintTin', after: 0,
1357
1357
  title: `Decide what the product paints from`,
1358
- sub: `${esc(s0.value)} appears ${s0.count} times${f0 ? `, ${esc(basename(f0.file))} alone carries ${f0.count}` : ''}, and there is no theme file to point them at: ${P.shadcn?.sheet?.file ? `${esc(P.shadcn.sheet.file)} defines` : 'the theme file defines'} none of shadcn's colour variables, so the palette is the system today. The first move is a decision, not a swap: adopt the theme variables (define them, map the palette onto them), then repaint file by file. Swapping <code>text-slate-500</code> for <code>text-muted-foreground</code> before that leaves the text with no colour.` });
1358
+ sub: `${esc(s0.value)} appears ${s0.count} times.${f0 ? ` ${esc(basename(f0.file))} alone has ${f0.count} palette classes.` : ''} There is no theme file to point them at: ${P.shadcn?.sheet?.file ? `${esc(P.shadcn.sheet.file)} defines` : 'the theme file defines'} none of shadcn's colour variables, so the palette is the system today. The first move is a decision, not a swap: adopt the theme variables (define them, map the palette onto them), then repaint file by file. Swapping <code>text-slate-500</code> for <code>text-muted-foreground</code> before that leaves the text with no colour.` });
1359
1359
  }
1360
1360
  if (pt.doors.uses >= 5) {
1361
1361
  const s0 = pt.doors.samples[0];
@@ -128,6 +128,16 @@ const COLOUR_PROP_RE = /^(?:color|background(?:-color)?|border(?:-(?:top|right|b
128
128
  const DARK_CHAIN_RE = /(^|[^a-z])dark(?![a-z])/i;
129
129
  const DARK_FILE_RE = /(^|[/._-])dark([/._-]|$)/i;
130
130
  const ROOT_RE = /(^|[\s,(])(?::root|html|:host|body)\b|@theme/;
131
+ // A selector list with a bare root selector in it is a root statement
132
+ // whatever its other members say: documenso's `:root, .dark-mode-disabled`
133
+ // was ranked dark for the word in its second selector and the sheet earned
134
+ // no receipt line (9.7.1). `:root.dark` and `html[data-theme=dark]` are not
135
+ // bare, and stay dark, and so does a :root under a dark at-rule.
136
+ const bareRoot = (chain) => {
137
+ const parts = chain.split(' > ');
138
+ if (DARK_CHAIN_RE.test(parts.slice(0, -1).join(' > '))) return false;
139
+ return parts[parts.length - 1].split(',').some((sel) => /^(?::root|html|:host|body)$/.test(sel.trim()));
140
+ };
131
141
  // theme reads this pass cannot follow (D4): a JavaScript theme object outside
132
142
  // the four kits (emotion, styled-components), and Sass or Less variables in
133
143
  // a colour property. Read by the colour words a theme object uses, at any
@@ -195,7 +205,7 @@ function cssStatements(text) {
195
205
  /** A Tailwind v3 config's colours, flattened: primary.DEFAULT -> primary,
196
206
  * brand[500] -> brand-500. String values only; spreads and computed values
197
207
  * are skipped, so a palette assembled in code reads as not defined. */
198
- function configColours(src) {
208
+ export function configColours(src) {
199
209
  const out = new Map();
200
210
  const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:'"`])\/\/[^\n]*/g, '$1');
201
211
  const re = /\bcolou?rs\s*:\s*\{/g;
@@ -392,7 +402,7 @@ export function colourUse(root, files, tokens, { email = null, P = null, palette
392
402
  const addStatements = (st, f, pkg) => {
393
403
  const fileDark = DARK_FILE_RE.test(f);
394
404
  for (const d of st.defs) {
395
- const rank = fileDark || DARK_CHAIN_RE.test(d.chain) ? 2 : ROOT_RE.test(d.chain) ? 0 : 1;
405
+ const rank = fileDark ? 2 : bareRoot(d.chain) ? 0 : DARK_CHAIN_RE.test(d.chain) ? 2 : ROOT_RE.test(d.chain) ? 0 : 1;
396
406
  const list = defs.get(d.name) ?? [];
397
407
  list.push({ pkg, value: d.value, rank, order: order++ });
398
408
  defs.set(d.name, list);
@@ -182,15 +182,22 @@ const hueDistance = (x, y) => Math.hypot(x.L - y.L, 2 * (x.a - y.a), 2 * (x.b -
182
182
  // Past this, the nearest theme colour is a different colour, not a stand-in
183
183
  // (bg-blue-600 against a theme of greys), and the pick by name answers.
184
184
  const CLOSE = 0.2;
185
+ // A grey class stands in for a grey only. Tailwind's greys sit at a chroma
186
+ // of 0.01 to 0.045 (slate is the most tinted); a brand teal at the same
187
+ // lightness sits at 0.095 and was within CLOSE of slate-500, so formbricks'
188
+ // text-slate-500 was sent to text-brandnew (2026-10-01).
189
+ const GREY_CHROMA = 0.06;
185
190
  /** The suitable theme name nearest to a palette shade's Tailwind value, or null. */
186
191
  function nearestThemeName(shade, role, values) {
187
192
  const from = oklab(TAILWIND_DEFAULTS[shade] ?? '');
188
193
  if (!from) return null;
194
+ const grey = GREY_HUE_RE.test(`-${shade}`);
189
195
  let best = null;
190
196
  for (const [name, value] of Object.entries(values)) {
191
197
  if (!suits(name, role)) continue;
192
198
  const to = oklab(value);
193
199
  if (!to) continue;
200
+ if (grey && Math.hypot(to.a, to.b) > GREY_CHROMA) continue;
194
201
  const d = hueDistance(from, to);
195
202
  if (!best || d < best.d) best = { name, d };
196
203
  }
@@ -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.7.0';
4
+ export const VERSION = '9.7.1';
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.
@@ -22,7 +22,7 @@ import { join, basename, dirname } from 'node:path';
22
22
  * fixture's expected output is byte-identical before and after.
23
23
  */
24
24
  import shadcn from './shadcn.mjs';
25
- import tailwind, { readTailwindTheme } from './tailwind.mjs';
25
+ import tailwind, { readTailwindTheme, v3Retuned } from './tailwind.mjs';
26
26
  import { decidePalette } from '../lib/palette.mjs';
27
27
  import { installedFile, componentName } from './installed.mjs';
28
28
  import mui from './mui.mjs';
@@ -94,6 +94,12 @@ export function decideProfile(profile, components, files, root = null, { themeRe
94
94
  let ownTheme;
95
95
  const readOwnTheme = () => (ownTheme === undefined ? (ownTheme = readTailwindTheme({ ...profile }, ctx)?.facts ?? null) : ownTheme);
96
96
  profile.palette = decidePalette(profile, profileOf(profile), readOwnTheme);
97
+ // a palette name a v3 config gave the repo's own colour is the theme too,
98
+ // whichever vocabulary judges the rest (9.7.1)
99
+ if (profile.palette && root) {
100
+ const tuned = v3Retuned(root, files.code);
101
+ if (tuned.length) profile.palette.retuned = [...new Set([...(profile.palette.retuned ?? []), ...tuned])].sort();
102
+ }
97
103
  // shadcn in utility-class mode (components.json: cssVariables false) paints
98
104
  // with Tailwind classes by design. A theme of the repo's own beside it
99
105
  // (rybbit retunes the whole neutral scale) is what the rules file points
@@ -124,10 +124,12 @@ function themeContract(root, files) {
124
124
  return seen.size;
125
125
  }
126
126
 
127
- /** Rows declared under a selector in a CSS text: name -> value. */
127
+ /** Rows declared under a selector in a CSS text: name -> value. The
128
+ * selector may head a list (documenso: `:root, .dark-mode-disabled`) or
129
+ * carry a :not() (`.dark:not(.dark-mode-disabled)`), 9.7.1. */
128
130
  function rowsUnder(css, selector) {
129
131
  const out = new Map();
130
- const re = new RegExp(`(^|[\\s,}])${selector}\\s*\\{([^}]*)\\}`, 'g');
132
+ const re = new RegExp(`(^|[\\s,}])${selector}(?::not\\([^)]*\\))?\\s*(?:,[^{}]*)?\\{([^}]*)\\}`, 'g');
131
133
  for (const m of css.matchAll(re)) {
132
134
  for (const d of m[2].matchAll(/--([a-z0-9-]+)\s*:\s*([^;]+);/gi)) out.set(d[1], d[2].trim());
133
135
  }
@@ -150,7 +152,7 @@ function sheetRows(css) {
150
152
  if (!/--background\s*:/.test(m[2]) || sel.startsWith('@')) continue;
151
153
  const rows = new Map();
152
154
  for (const d of m[2].matchAll(/--([a-z0-9-]+)\s*:\s*([^;]+);/gi)) rows.set(d[1], d[2].trim());
153
- if (/\.dark\b/.test(sel)) { if (!dark.has('background')) dark = rows; }
155
+ if (/\.dark(?![\w-])/.test(sel)) { if (!dark.has('background')) dark = rows; }
154
156
  else if (!light.has('background')) { light = rows; scope = sel; }
155
157
  if (light.has('background') && dark.has('background')) break;
156
158
  }
@@ -199,6 +201,10 @@ function readSheet(root, wsRoot, cssPath, files = null) {
199
201
  const rel = file.slice(root.length + 1);
200
202
  const others = [...(files.code ?? []), ...(files.styles ?? [])].filter((f) => f !== rel).slice(0, 4000);
201
203
  const pending = new Set(custom.filter((r) => !/^(font|shadow|tracking|spacing|radius|ease|breakpoint|typeset)/.test(r)));
204
+ // a read inside the sheet itself counts: --sidebar: var(--surface-2)
205
+ // makes --surface-2 part of the theme, whether or not a component names
206
+ // it (9.7.1; its own rows, light and dark, are definitions, not reads)
207
+ for (const r of [...pending]) if (new RegExp(`var\\(\\s*--${r}(?![\\w-])`).test(css)) pending.delete(r);
202
208
  for (const f of others) {
203
209
  if (!pending.size) break;
204
210
  const src = read(join(root, f));
@@ -20,7 +20,10 @@ import { cachedSource } from '../harvest/walk.mjs';
20
20
  import { join } from 'node:path';
21
21
  import { PALETTE } from './shadcn-data.mjs';
22
22
  import { TAILWIND_DEFAULTS } from './tailwind-defaults.mjs';
23
- import { canonical, parseColor } from '../lib/color.mjs';
23
+ import { canonical, parseColor, oklab } from '../lib/color.mjs';
24
+ import { configColours } from '../harvest/coloruse.mjs';
25
+ import { existsSync } from 'node:fs';
26
+ import { posix } from 'node:path';
24
27
 
25
28
  // the scan's own copy when it has one (harvest/walk.mjs), the disk otherwise
26
29
  const read = (p) => { const t = cachedSource(p); if (t !== undefined) return t; try { return readFileSync(p, 'utf8'); } catch { return ''; } };
@@ -40,6 +43,58 @@ function retuned(name, value) {
40
43
  return !(cv && cd && near(cv, cd));
41
44
  }
42
45
 
46
+ // A v3 config can retune a scale too: Novu points every grey at
47
+ // hsl(var(--gray-N)), workout-cool gives green its own values. The theme
48
+ // itself stays unread (a v3 config imports, spreads and computes; Greg,
49
+ // 2026-09-16), but a palette name it gives a new colour is the repo's own
50
+ // wherever it is read, so the live checks and the colour bar stop calling
51
+ // the class drift (9.7.1). Only a config the product reads counts: the root
52
+ // config, or one in a package holding a tenth of the code files (unkey
53
+ // retunes grey in an email package of 20 files, and the web app must not
54
+ // inherit it).
55
+ const TW_CONFIG_RE = /(^|\/)tailwind\.config\.[mc]?[jt]s$/;
56
+ const PALETTE_SHADE_NAME_RE = new RegExp(`^(?:${PALETTE})-(?:50|[1-9]00|950)$`);
57
+ const CONFIG_SHARE = 0.1;
58
+ // The defaults the engine keeps are v4's. v3's own values sit within 0.04
59
+ // of them in OKLab (blue-500 #3b82f6 against oklch(62.3% 0.214 259.815):
60
+ // 0.019; the widest, fuchsia-500, 0.036), while a scale a team retuned sits
61
+ // at 0.07 or more (workout-cool's green-500 at 0.076). A literal this close
62
+ // is Tailwind's own, whichever major it was copied from.
63
+ const V3_SAME = 0.05;
64
+ const v3Retune = (name, value) => {
65
+ if (!retuned(name, value)) return false;
66
+ const a = oklab(value.trim()), b = oklab(TAILWIND_DEFAULTS[name]);
67
+ return !(a && b && Math.hypot(a.L - b.L, a.a - b.a, a.b - b.b) < V3_SAME);
68
+ };
69
+ /** Palette names a v3 config the product reads gave colours of its own. */
70
+ export function v3Retuned(root, codeFiles) {
71
+ if (!root || !codeFiles?.length) return [];
72
+ const configs = codeFiles.filter((f) => TW_CONFIG_RE.test(f));
73
+ if (!configs.length) return [];
74
+ const pkgOf = (file) => {
75
+ let dir = posix.dirname(file);
76
+ while (dir && dir !== '.') {
77
+ if (existsSync(join(root, dir, 'package.json'))) return dir;
78
+ dir = posix.dirname(dir);
79
+ }
80
+ return '';
81
+ };
82
+ let sizes = null;
83
+ const sizeOf = (pkg) => {
84
+ if (!sizes) { sizes = new Map(); for (const f of codeFiles) { const p = pkgOf(f); sizes.set(p, (sizes.get(p) ?? 0) + 1); } }
85
+ return sizes.get(pkg) ?? 0;
86
+ };
87
+ const out = new Set();
88
+ for (const f of configs) {
89
+ const pkg = pkgOf(f);
90
+ if (pkg !== '' && sizeOf(pkg) < codeFiles.length * CONFIG_SHARE) continue;
91
+ for (const [n, v] of configColours(read(join(root, f)))) {
92
+ if (PALETTE_SHADE_NAME_RE.test(n) && v3Retune(n, v)) out.add(n);
93
+ }
94
+ }
95
+ return [...out].sort();
96
+ }
97
+
43
98
  // what counts as a theme worth judging
44
99
  // three, not six: a small brand palette used everywhere is still a system
45
100
  // (hey.xyz: four brand shades; ConvertX: four colours, 2026-09-16)
@@ -237,7 +237,14 @@ if (neverImported.length >= 3) {
237
237
  if (op?.uses) lines.push(` (${op.uses} palette colour${op.uses === 1 ? '' : 's'} the theme does not own already sit in own code, ${op.samples.slice(0, 3).map((s) => `\`${s.value}\` ×${s.count}`).join(', ')}; do not add to them.)`);
238
238
  } else {
239
239
  rule(`This is a shadcn install${styleNote}. The theme is a set of CSS variables${sheetFile ? ` in \`${sheetFile}\`` : ''}: background, foreground, primary, muted, border and the rest, each with a light and a dark value. Change a colour there, never in a component.`);
240
- if ((sc.sheet?.shadcnPresent ?? 0) >= 5) rule('Use the semantic classes the theme gives you (`bg-background`, `text-muted-foreground`, `border-border`), never a palette colour like `bg-blue-500` or `text-gray-600`, and never a hand-written `dark:` colour. The variables already carry both modes.');
240
+ // The same decision as the live checks (lib/palette.mjs): the contract
241
+ // holds when any stylesheet keeps five of shadcn's rows. Until 9.7.1
242
+ // this line read the configured sheet's :root rows alone, so formbricks
243
+ // (its names in @theme, the rows in a sibling package) was told to stay
244
+ // with the palette classes the live checks flag.
245
+ const contractHolds = P.palette?.source === 'shadcn' || (sc.sheet?.shadcnPresent ?? 0) >= 5;
246
+ const contractNote = contractHolds && (sc.sheet?.shadcnPresent ?? 0) < 5 && sc.contract?.file && sc.contract.file !== sheetFile ? ` The variables are defined in \`${sc.contract.file}\`.` : '';
247
+ if (contractHolds) rule(`Use the semantic classes the theme gives you (\`bg-background\`, \`text-muted-foreground\`, \`border-border\`), never a palette colour like \`bg-blue-500\` or \`text-gray-600\`, and never a hand-written \`dark:\` colour. The variables already carry both modes.${contractNote}`);
241
248
  else rule(`${sheetFile ? `\`${sheetFile}\` defines` : 'The theme file defines'} none of shadcn's colour variables, so \`bg-background\` and \`text-muted-foreground\` have nothing behind them here. Until the theme variables are adopted, stay with the palette classes the surrounding file already uses; do not introduce semantic classes with no variable behind them, and do not add a second palette.`);
242
249
  if (paint?.tin?.uses) lines.push(` (${paint.tin.uses} palette colour${paint.tin.uses === 1 ? '' : 's'} already sit in own code, ${paint.tin.samples.slice(0, 3).map((s) => `\`${s.value}\` ×${s.count}`).join(', ')}; do not add to them.)`);
243
250
  }