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 +3 -3
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/diagnose/index.mjs +2 -2
- package/skills/roast-my-design-system/scripts/harvest/coloruse.mjs +12 -2
- package/skills/roast-my-design-system/scripts/lib/palette.mjs +7 -0
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/profiles/index.mjs +7 -1
- package/skills/roast-my-design-system/scripts/profiles/shadcn.mjs +9 -3
- package/skills/roast-my-design-system/scripts/profiles/tailwind.mjs +56 -1
- package/skills/roast-my-design-system/scripts/rules/build.mjs +8 -1
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
|
-
|
|
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
|
-

|
|
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.7.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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\
|
|
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
|
-
|
|
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
|
}
|