roast-my-design-system 5.10.2 → 5.11.0

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
@@ -8,6 +8,8 @@
8
8
 
9
9
  A free CLI tool (and Claude Code skill) that roasts your repo's design system with real data, then generates the rules that keep your AI agent on-system.
10
10
 
11
+ > **New in 5.11: the checker and the CI guard stop contradicting each other.** `roast --check` and [guard-my-design-system](https://github.com/gregkozakiewicz/guard-my-design-system) both answer "does this change fit the system", and they disagreed in seven places. A second hand-rolled `<Button>` passed the guard. A new radius, font size, shadow or typeface passed `--check` and was then stopped in CI. Both now measure the same things. And both leave alone the files that cannot be on-system: email, print, OG cards, PDF invoices, pixel renderers and SVG artwork. A skipped file is never called clean; the result says what was left unjudged and why.
12
+
11
13
  > **New in 5.10: it reads Tailwind v3 shadcn, and every colour space.** shadcn on Tailwind v3 stores colour tokens as bare HSL channels, for the opacity trick. So a textbook shadcn repo used to scan as "2 colours, none defined as CSS variables". It now reads those tokens. It finds the greys (an oklch palette used to report 0). And it spots twins across notations: a token written as hsl and a hardcoded copy written as rgb are the same colour, and now count as a near-identical pair. Checked before and after on 6 real shadcn repos.
12
14
 
13
15
  > **New in 5.9: the agent card knows which doors your rules actually reach.** The report now states, as plain fact, which tools can read the rules files you have. Claude Code reads CLAUDE.md. Codex reads AGENTS.md. Cursor reads AGENTS.md and `.cursor/rules`. Where a door is missing, the card shows the one-line fix: Claude Code skips AGENTS.md, and a CLAUDE.md containing the single line `@AGENTS.md` closes the gap. The scan also finds rules files nested in subfolders, which is how monorepos really do it (twenty carries 35 AGENTS.md files; a root-only look reported 1). It recognises GEMINI.md, `.windsurf/rules` and `.github/instructions` too.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "5.10.2",
3
+ "version": "5.11.0",
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 34 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": [
@@ -7,6 +7,9 @@
7
7
  * New in 2.0 — 1.0 only ever read a clean globals.css; this reads the mess.
8
8
  */
9
9
  import { readFileSync } from 'node:fs';
10
+ import {
11
+ EMAIL_PRINT_RE, ARTWORK_NAME_RE, RENDER_TO_IMAGE_RE, OG_ROUTE_RE, RENDERER_PATH_RE, svgHeavy,
12
+ } from '../lib/exempt.mjs';
10
13
  import { join } from 'node:path';
11
14
 
12
15
  // ---------- counters ----------
@@ -48,6 +51,14 @@ export const tripletToHsl = (v) => {
48
51
  const m = HSL_TRIPLET_RE.exec(v);
49
52
  return m ? `hsl(${m[1]} ${m[2]}% ${m[3]}%${m[4] ? ` / ${m[4]}` : ''})` : null;
50
53
  };
54
+ // Loose hex in CODE (not in a style block, a class string or a stylesheet) is
55
+ // ambiguous: #RGBA shorthand is legal CSS but vanishingly rare in code, while
56
+ // #1042 as an order id, ticket or issue number is everyday. Read as a colour
57
+ // it expands to #11004422 and the review accuses an order id of being a
58
+ // hardcoded colour (found on the demo repo, 2026-09-07). In a colour context
59
+ // all four lengths still count; only the loose pass skips 4-digit.
60
+ const isIdentifierHex = (raw) => /^#[0-9a-f]{4}$/i.test(raw);
61
+
51
62
  // hsl(var(--primary)) is a reference to a colour, never a colour, and
52
63
  // oklch(0.35 0.08 ${hue}) is a template with a hole in it: neither may sit in
53
64
  // the palette as its own value.
@@ -122,8 +133,9 @@ function isStaticInline(block) {
122
133
  }
123
134
 
124
135
  // Render-to-image surfaces (satori OG cards, react-pdf invoices) have inline
125
- // styles as their ONLY styling mechanism — same honesty rule as email.
126
- const RENDER_TO_IMAGE_RE = /ImageResponse|from ['"]satori['"]|from ['"]@react-pdf|next\/og/;
136
+ // styles as their ONLY styling mechanism, the same honesty rule as email. The
137
+ // patterns live in lib/exempt.mjs so the report, roast --check and the guard
138
+ // all skip the same files.
127
139
 
128
140
  /** Extract inline style objects: style={{ ... }} */
129
141
  function inlineStyleBlocks(src) {
@@ -199,6 +211,7 @@ export function extractStyling(src, { css = false } = {}) {
199
211
  // colour consts) — everything not already collected above
200
212
  const seen = new Set(out.colors.map((c) => c.index));
201
213
  for (const m of src.matchAll(HEX_RE)) {
214
+ if (isIdentifierHex(m[0])) continue;
202
215
  if (![...seen].some((i) => Math.abs(i - m.index) < 4)) out.colors.push({ value: normalizeHex(m[0]), index: m.index });
203
216
  }
204
217
  for (const m of src.matchAll(/!\s*important/gi)) out.important.push({ index: m.index });
@@ -287,15 +300,9 @@ export function harvestTokens(root, styleFiles, codeFiles) {
287
300
  // Email templates (and print styles) MUST inline their styling — that's
288
301
  // correct practice, not mess. Counting them would hand a sharp dev an easy
289
302
  // reason to discredit the whole report.
290
- const EXEMPT_RE = /email|(^|[/.])print([/.]|$)/i;
291
- // SVG artwork components (logos, badges, illustrated icons) carry hex that
292
- // is drawing, not styling — same honesty rule as email templates.
293
- const ARTWORK_RE = /(^|\/)[\w.-]*(icon|logo|badge|illustration|shield|artwork|graphic|background)[\w.-]*\.(tsx|jsx)$/i;
294
- // A component that is mostly SVG markup (theme-preview mocks, decorative
295
- // scenes) is drawing, not styling — regardless of its filename.
296
- const svgHeavy = (src) => (src.match(/<(?:svg|path|rect|circle|ellipse|polygon|mask|defs)\b/g) ?? []).length >= 15;
297
- // Canvas/scene renderers draw pixels; their colour literals are not UI styling.
298
- const RENDERER_PATH_RE = /(^|\/)(renderers?|scene|canvas)\/|renderElement|DebugCanvas/i;
303
+ // Email, artwork, mostly-SVG files and pixel renderers: all imported from
304
+ // lib/exempt.mjs, the one list every checker reads.
305
+ const EXEMPT_RE = EMAIL_PRINT_RE, ARTWORK_RE = ARTWORK_NAME_RE;
299
306
  // Colour-picker palettes and design-field option lists are user-facing data.
300
307
  const PALETTE_FILE_RE = /(color-picker|colour-picker|palette|design-fields|swatch)/i;
301
308
 
@@ -357,7 +364,7 @@ export function harvestTokens(root, styleFiles, codeFiles) {
357
364
  // code: its colours are the picture's, not the product's palette. Until
358
365
  // 5.10.0 they were the only "strays" a clean shadcn repo had, and they
359
366
  // triggered the "every single one is hardcoded" banner on it.
360
- const renderToImage = RENDER_TO_IMAGE_RE.test(src) || /(^|\/)api\/og\//.test(f);
367
+ const renderToImage = RENDER_TO_IMAGE_RE.test(src) || OG_ROUTE_RE.test(f);
361
368
  if (renderToImage) continue;
362
369
 
363
370
  // Tailwind classes
@@ -400,7 +407,10 @@ export function harvestTokens(root, styleFiles, codeFiles) {
400
407
  // — cheap approximation: any hex in a .ts/.js file counts as a colour in code.
401
408
  if (/\.(ts|js)$/.test(f)) {
402
409
  for (const m of src.matchAll(HEX_RE)) {
403
- // avoid counting hashes that aren't colors (e.g. IDs): require 3/4/6/8 hex digits already enforced by regex
410
+ // 4-digit shorthand in code is an identifier far more often than a
411
+ // colour (#1042 = an order id, not #11004422): same rule as the MCP
412
+ // validator, so both measure with one ruler.
413
+ if (isIdentifierHex(m[0])) continue;
404
414
  colors.add(normalizeHex(m[0]), f);
405
415
  }
406
416
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The styling declarations beyond colour and spacing that both checkers judge:
3
+ * radius, font size, shadow and typeface.
4
+ *
5
+ * They live here rather than in either checker because keeping two copies is
6
+ * exactly how the engine and guard-my-design-system drifted apart in the first
7
+ * place. One set of patterns, one idea of what counts as a value, two scopes:
8
+ * the engine reads whole files, the guard reads added lines.
9
+ *
10
+ * The whole trimmed value is the unit of comparison, exactly as the harvest
11
+ * counts it, so "already in the system" means the same thing in both places.
12
+ */
13
+
14
+ /** kind: the finding's name · re: how to spot it · learned: the harvest key */
15
+ export const EXTRA_KINDS = [
16
+ { kind: 'radius', noun: 'border radius', plural: 'radii', re: /border-radius\s*:\s*([^;{}]+)/i, learned: 'radii' },
17
+ { kind: 'fontsize', noun: 'font size', plural: 'font sizes', re: /(?:^|[^-\w])font-size\s*:\s*([^;{}]+)/i, learned: 'fontSizes' },
18
+ { kind: 'shadow', noun: 'shadow', plural: 'shadows', re: /box-shadow\s*:\s*([^;{}]+)/i, learned: 'shadows' },
19
+ ];
20
+
21
+ export const FONT_LINE_RE = /font-family\s*:\s*([^;{}]+)/i;
22
+
23
+ // Disciplined values that are never sins: token use, resets, inheritance.
24
+ export const BENIGN_VALUE_RE = /^(var\(--[\w-]+\)|inherit|initial|unset|none|normal|0)$/i;
25
+ const FONT_BENIGN_RE = /^(var\(--[\w-]+\)|inherit)$/i;
26
+
27
+ /** The first declaration of one kind on one line, or null. The guard's door. */
28
+ export function extraValue(re, text) {
29
+ const m = re.exec(text);
30
+ if (!m) return null;
31
+ const v = m[1].trim().replace(/\s+/g, ' ');
32
+ return BENIGN_VALUE_RE.test(v) ? null : v;
33
+ }
34
+
35
+ /** Every such declaration in a whole stylesheet, with positions. The engine's door. */
36
+ export function* extraDeclarations(text) {
37
+ for (const { kind, noun, plural, re, learned } of EXTRA_KINDS) {
38
+ for (const m of text.matchAll(new RegExp(re.source, 'gi'))) {
39
+ const v = m[1].trim().replace(/\s+/g, ' ');
40
+ if (BENIGN_VALUE_RE.test(v)) continue;
41
+ yield { kind, noun, plural, learned, value: v, index: m.index + m[0].indexOf(m[1]) };
42
+ }
43
+ }
44
+ }
45
+
46
+ /** Every font-family declaration worth judging, raw value and position. */
47
+ export function* fontDeclarations(text) {
48
+ for (const m of text.matchAll(new RegExp(FONT_LINE_RE.source, 'gi'))) {
49
+ const raw = m[1].trim();
50
+ if (FONT_BENIGN_RE.test(raw)) continue;
51
+ yield { raw, index: m.index + m[0].indexOf(m[1]) };
52
+ }
53
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Honesty exemptions — the two places where styling genuinely cannot be
3
+ * on-system, so judging it would be crying wolf.
4
+ *
5
+ * Email and print: mail clients strip stylesheets and print has no cascade to
6
+ * inherit, so the styling has to be inline and hardcoded. That is the medium,
7
+ * not a lapse.
8
+ *
9
+ * Artwork: a file named Icon, Logo, Badge or Illustration carries the colours
10
+ * of the drawing itself, and a drawing is not interface. The name alone is not
11
+ * enough though, or every Badge component would walk free: the contents must
12
+ * actually draw SVG. A Badge that is plain styled UI gets judged like anything
13
+ * else. A file that is mostly SVG earns it on contents alone, whatever it is
14
+ * called.
15
+ *
16
+ * Render-to-image: an OG card or a PDF invoice is a picture drawn with code.
17
+ * satori and react-pdf accept nothing but inline styles, so there is no
18
+ * on-system way to write one.
19
+ *
20
+ * Renderers: a canvas or scene renderer draws pixels. Its colour literals are
21
+ * the picture, not the product's palette.
22
+ *
23
+ * Shared by the engine (whole files) and by guard-my-design-system (added
24
+ * lines), so both give the same answer about the same file.
25
+ */
26
+ export const EMAIL_PRINT_RE = /email|(^|[/.])print([/.]|$)/i;
27
+ export const ARTWORK_NAME_RE = /(^|\/)[\w.-]*(icon|logo|badge|illustration|shield|artwork|graphic|background)[\w.-]*\.(tsx|jsx)$/i;
28
+ export const SVG_MARKUP_RE = /<(svg|path|rect|circle|ellipse|polygon|defs|mask)\b/i;
29
+ export const RENDER_TO_IMAGE_RE = /ImageResponse|from ['"]satori['"]|from ['"]@react-pdf|next\/og/;
30
+ export const OG_ROUTE_RE = /(^|\/)api\/og\//;
31
+ export const RENDERER_PATH_RE = /(^|\/)(renderers?|scene|canvas)\/|renderElement|DebugCanvas/i;
32
+
33
+ /** Mostly drawing rather than styling, whatever the file is called. */
34
+ export const svgHeavy = (text) =>
35
+ (text.match(/<(?:svg|path|rect|circle|ellipse|polygon|mask|defs)\b/g) ?? []).length >= 15;
36
+
37
+ /**
38
+ * Why this file is exempt from judgement, as a sentence, or null when it is
39
+ * fair game. Needs the text as well as the name: the artwork exemption is
40
+ * earned by drawing, not by being called Icon.
41
+ */
42
+ export function exemptReason(file, text = '') {
43
+ if (!file) return null;
44
+ if (EMAIL_PRINT_RE.test(file)) return 'email and print styling has to be inline, because there is no cascade to inherit';
45
+ if (RENDERER_PATH_RE.test(file)) return 'a renderer draws pixels, so its colours are the picture rather than the interface';
46
+ 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';
47
+ if (svgHeavy(text)) return 'the file is mostly drawing rather than styling';
48
+ if (ARTWORK_NAME_RE.test(file) && SVG_MARKUP_RE.test(text)) return 'the colours belong to the artwork, not to the interface';
49
+ return null;
50
+ }
@@ -13,12 +13,27 @@ import { extname, join } from 'node:path';
13
13
  import { readFileSync } from 'node:fs';
14
14
  import { walkRepo } from '../harvest/walk.mjs';
15
15
  import { harvestTokens, extractStyling, normalizeHex, isGrey } from '../harvest/tokens.mjs';
16
+ import { harvestComponents, definedComponents } from '../harvest/components.mjs';
16
17
  import { loadExclusions } from './exclusions.mjs';
17
18
  import { hexRgb } from './nearpairs.mjs';
18
19
  import { typefaceOf, GENERIC_FONTS } from './typefaces.mjs';
19
20
 
20
21
  export { extractStyling, normalizeHex, isGrey, hexRgb, typefaceOf, GENERIC_FONTS };
21
22
 
23
+ // The components a piece of text defines. With the ledger below, a guard can
24
+ // tell a second <Button> from an edit to the first one.
25
+ export { definedComponents };
26
+
27
+ // The files no checker should judge. Exported so a guard reads the same list
28
+ // as the engine rather than keeping a copy that drifts.
29
+ export { EMAIL_PRINT_RE, ARTWORK_NAME_RE, SVG_MARKUP_RE, exemptReason } from './exempt.mjs';
30
+
31
+ // Radius, font size, shadow and typeface: the patterns, so both checkers agree
32
+ // on what a declaration is and what counts as a disciplined value.
33
+ export {
34
+ EXTRA_KINDS, FONT_LINE_RE, BENIGN_VALUE_RE, extraValue, extraDeclarations, fontDeclarations,
35
+ } from './declarations.mjs';
36
+
22
37
  // Same classification walkRepo uses, exposed so a caller looking at one file
23
38
  // (a diff hunk) treats it the way the full scan would.
24
39
  const CODE_EXTS = new Set(['.tsx', '.jsx', '.ts', '.js', '.mjs', '.cjs']);
@@ -41,6 +56,7 @@ export const isStyleFile = (p) => STYLE_EXTS.has(extname(p));
41
56
  * shadows, // [{ value, count, files }] every box-shadow declared
42
57
  * fontFamilies, // [{ value, count, files }] every family declared
43
58
  * tailwind, // { colors, spacing, radii, textSizes, arbitrary }
59
+ * components, // [{ name, file, usageCount, isPage }] the ledger
44
60
  * files, // { styles: n, code: n } — how much was read
45
61
  * }
46
62
  */
@@ -74,6 +90,11 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
74
90
  shadows: t.shadows,
75
91
  fontFamilies: t.fontFamilies,
76
92
  tailwind: t.tailwind,
93
+ // Trimmed to the four fields a guard can act on: what it is called, where
94
+ // it lives, how much the repo leans on it, and whether it is a page (pages
95
+ // are routes, not reusable parts, so two of a name is not a duplicate).
96
+ components: harvestComponents(repoRoot, files.code).components
97
+ .map(({ name, file, usageCount, isPage }) => ({ name, file, usageCount, isPage })),
77
98
  files: { styles: files.styles.length, code: files.code.length },
78
99
  };
79
100
  }
@@ -1,4 +1,4 @@
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 = '5.10.2';
4
+ export const VERSION = '5.11.0';
@@ -13,12 +13,17 @@
13
13
  import { extractStyling } from '../harvest/tokens.mjs';
14
14
  import { definedComponents } from '../harvest/components.mjs';
15
15
  import { hexRgb } from '../lib/nearpairs.mjs';
16
+ import { exemptReason } from '../lib/exempt.mjs';
17
+ import { extraDeclarations, fontDeclarations } from '../lib/declarations.mjs';
18
+ import { typefaceOf, GENERIC_FONTS } from '../lib/typefaces.mjs';
16
19
 
17
20
  // What this engine measures — shipped with every result, clean or not.
18
21
  export const CHECKS = [
19
22
  'hardcoded colours vs the token set',
20
23
  'near-identical colour twins',
21
24
  'off-scale spacing',
25
+ 'off-scale radii, font sizes and shadows',
26
+ 'typefaces outside the system',
22
27
  'arbitrary bracket values',
23
28
  'static inline style blocks',
24
29
  '!important',
@@ -60,10 +65,16 @@ function nearestToken(value, k) {
60
65
  * Validate one piece of content against the repo's knowledge.
61
66
  * @param content { text, file? } — file name decides css-vs-code mode and
62
67
  * lets the duplicate check excuse a component's own existing file
63
- * @returns { findings: [{ rule, severity, line, message, fix? }], checked }
68
+ * @returns { findings: [{ rule, severity, line, message, fix? }], checked,
69
+ * exempt? } — exempt is a sentence saying why the file was not judged
64
70
  */
65
71
  export function validateContent(content, k) {
66
72
  const { text, file = null } = content;
73
+ // Some files cannot be on-system by their nature. Judging them is how a
74
+ // checker earns its reputation for crying wolf, and a checker people
75
+ // distrust gets switched off. Say nothing, and say why nothing was said.
76
+ const exempt = exemptReason(file, text);
77
+ if (exempt) return { findings: [], checked: CHECKS, exempt };
67
78
  const css = file ? CSS_FILE_RE.test(file) : looksLikeCss(text);
68
79
  const got = extractStyling(text, { css });
69
80
  const findings = [];
@@ -135,6 +146,57 @@ export function validateContent(content, k) {
135
146
  'If the value repeats it is a decision: name it as a token. If it does not, use the nearest scale step.');
136
147
  }
137
148
 
149
+ // ---------- the other declared scales, and the typeface ----------
150
+ // Stylesheets only. In code these live inside class strings, where the scale
151
+ // is Tailwind's and the arbitrary-bracket check already covers straying off
152
+ // it. The rule is the guard's rule: a value the repo already declares is
153
+ // consistency, not a sin, and only a genuinely new one is worth saying.
154
+ if (css) {
155
+ const localExtras = new Map(), localFaces = new Map();
156
+ for (const d of extraDeclarations(text)) {
157
+ const key = `${d.kind}|${d.value}`;
158
+ localExtras.set(key, (localExtras.get(key) ?? 0) + 1);
159
+ }
160
+ for (const f of fontDeclarations(text)) {
161
+ const face = typefaceOf(f.raw);
162
+ if (face) localFaces.set(face, (localFaces.get(face) ?? 0) + 1);
163
+ }
164
+ const SEEN = { radii: k.radiiSeen, fontSizes: k.fontSizesSeen, shadows: k.shadowsSeen };
165
+
166
+ for (const d of extraDeclarations(text)) {
167
+ const seen = SEEN[d.learned] ?? new Map();
168
+ if (discount(seen.get(d.value), localExtras, `${d.kind}|${d.value}`) > 0) continue;
169
+ const near = nearestDeclared(d.value, seen);
170
+ const others = seen.size - (seen.has(d.value) ? 1 : 0);
171
+ if (others) {
172
+ add(`off-scale-${d.kind}`, 'violation', d.index,
173
+ `New one-off ${d.noun} ${d.value}. The repo already declares ${others} other ${others === 1 ? d.noun : d.plural}.`,
174
+ near ? `Closest value this repo already uses: ${near.value} (${near.count}x).`
175
+ : `It matches none of them. If it is a real decision it is a token, not a one-off.`);
176
+ } else {
177
+ add(`off-scale-${d.kind}`, 'warning', d.index,
178
+ `${d.value} is the first ${d.noun} this repo declares.`,
179
+ `Nothing to compare it against yet. Put it in the token layer so the next ${d.noun} has a scale to join.`);
180
+ }
181
+ }
182
+
183
+ for (const f of fontDeclarations(text)) {
184
+ const face = typefaceOf(f.raw);
185
+ if (!face || GENERIC_FONTS.has(face.toLowerCase())) continue;
186
+ if (discount(k.faceCounts?.get(face), localFaces, face) > 0) continue;
187
+ const declared = [...(k.faceCounts?.keys() ?? [])].filter((x) => x !== face);
188
+ if (declared.length) {
189
+ add('off-system-typeface', 'violation', f.index,
190
+ `New typeface ${face}. The system already declares ${declared.join(', ')}.`,
191
+ `Use a family the system declares, or add ${face} to the token layer before anything uses it.`);
192
+ } else {
193
+ add('off-system-typeface', 'warning', f.index,
194
+ `${face} is the first typeface this repo declares.`,
195
+ 'Declare it once, in the token layer, so everything else can inherit it.');
196
+ }
197
+ }
198
+ }
199
+
138
200
  // ---------- discipline ----------
139
201
  for (const b of got.inlineBlocks) {
140
202
  add('inline-style', 'violation', b.index,
@@ -174,6 +236,23 @@ function looksLikeCss(text) {
174
236
  return !/<[A-Za-z][\w.]*[\s/>]/.test(text) && /[.#:\w[\]-]+\s*\{[^}]*:/.test(text);
175
237
  }
176
238
 
239
+ /** Nearest value the repo already declares, for a scale that has lengths in it. */
240
+ function nearestDeclared(value, seen) {
241
+ const px = toPx(value);
242
+ if (px === null || !seen.size) return null;
243
+ let best = null;
244
+ for (const [v, count] of seen) {
245
+ // the file under review is inside the scan, so the value being judged is
246
+ // in this map too. Pointing at itself is not advice.
247
+ if (v === value) continue;
248
+ const p = toPx(v);
249
+ if (p === null) continue;
250
+ const d = Math.abs(p - px);
251
+ if (!best || d < best.d) best = { value: v, count, d };
252
+ }
253
+ return best;
254
+ }
255
+
177
256
  function nearestRepoSpacing(value, k) {
178
257
  const px = toPx(value);
179
258
  if (px === null || !k.spacingSeen.size) return undefined;
@@ -20,6 +20,7 @@ import { harvestContext } from '../harvest/context.mjs';
20
20
  import { loadExclusions } from '../lib/exclusions.mjs';
21
21
  import { resolveWorkspaces } from '../lib/workspaces.mjs';
22
22
  import { neverImportedComponents } from '../lib/neverimported.mjs';
23
+ import { typefaceOf } from '../lib/typefaces.mjs';
23
24
  import { hexRgb } from '../lib/nearpairs.mjs';
24
25
 
25
26
  const MAX_DEPTH = 14; // same ruler as the harvest CLI
@@ -57,6 +58,18 @@ export function loadKnowledge(root) {
57
58
  // spacing the repo already uses: raw CSS values with counts, plus whether
58
59
  // the repo styles spacing through Tailwind at all (decides what "on-scale" means)
59
60
  const spacingSeen = new Map(tokens.spacing.map((s) => [s.value, s.count]));
61
+
62
+ // the other declared scales, same shape: value → how often the repo says it.
63
+ // Typefaces collapse to the face itself, so "Inter" and "Inter, sans-serif"
64
+ // are one voice rather than two.
65
+ const radiiSeen = new Map((tokens.radii ?? []).map((r) => [r.value, r.count]));
66
+ const fontSizesSeen = new Map((tokens.fontSizes ?? []).map((f) => [f.value, f.count]));
67
+ const shadowsSeen = new Map((tokens.shadows ?? []).map((s) => [s.value, s.count]));
68
+ const faceCounts = new Map();
69
+ for (const f of tokens.fontFamilies ?? []) {
70
+ const face = typefaceOf(f.value);
71
+ if (face) faceCounts.set(face, (faceCounts.get(face) ?? 0) + f.count);
72
+ }
60
73
  const twSpacingUse = (tokens.tailwind?.spacing ?? []).reduce((sum, s) => sum + s.count, 0);
61
74
  const usesTailwind = twSpacingUse >= 20;
62
75
 
@@ -98,6 +111,10 @@ export function loadKnowledge(root) {
98
111
  tokenColors,
99
112
  tokenColorRgb: tokenColors.map((v) => ({ value: v, rgb: hexRgb(v) })).filter((t) => t.rgb),
100
113
  spacingSeen,
114
+ radiiSeen,
115
+ fontSizesSeen,
116
+ shadowsSeen,
117
+ faceCounts,
101
118
  usesTailwind,
102
119
  byName,
103
120
  canonical,
@@ -201,7 +201,8 @@ function toPxLocal(len) {
201
201
  export function validate(k, { code, file = null } = {}) {
202
202
  if (typeof code !== 'string' || !code.trim()) return invalidInput('Send the code you are about to save (and ideally its file path).');
203
203
  if (file != null && typeof file !== 'string') return invalidInput('file is optional, but when sent it must be a repo-relative path as a string.');
204
- const { findings } = validateContent({ text: code, file }, k);
204
+ const { findings, exempt } = validateContent({ text: code, file }, k);
205
+ if (exempt) return `Not judged: ${file} is exempt because ${exempt}. Nothing here was checked.`;
205
206
  if (!findings.length) return cleanResultText();
206
207
  const L = [`${findings.length} finding${findings.length === 1 ? '' : 's'}:`];
207
208
  for (const f of findings.slice(0, 12)) {
@@ -238,16 +239,21 @@ export function reviewData(k) {
238
239
  if (!changed.length) return { text: 'No changed UI or style files in the working tree. Nothing to review.', total: 0 };
239
240
 
240
241
  const perFile = [];
241
- let total = 0;
242
+ let total = 0, exemptCount = 0;
242
243
  for (const f of changed.slice(0, 40)) {
243
244
  let text;
244
245
  try { text = readFileSync(join(gitRoot, f), 'utf8'); } catch { continue; }
245
246
  // knowledge paths are scanned-root-relative; git paths are toplevel-relative
246
- const { findings } = validateContent({ text, file: relative(realRoot, join(gitRoot, f)) }, k);
247
+ const { findings, exempt } = validateContent({ text, file: relative(realRoot, join(gitRoot, f)) }, k);
248
+ if (exempt) { exemptCount++; continue; }
247
249
  if (findings.length) { perFile.push({ f, findings }); total += findings.length; }
248
250
  }
251
+ // Skipped is not the same as clean, and the reader is owed the difference.
252
+ const exemptNote = exemptCount
253
+ ? ` ${exemptCount} file${exemptCount === 1 ? ' was' : 's were'} left unjudged: email, print, artwork or pictures drawn with code.`
254
+ : '';
249
255
  if (!total) {
250
- return { text: `Reviewed ${changed.length} changed file${changed.length === 1 ? '' : 's'}. ${cleanResultText()}`, total: 0 };
256
+ return { text: `Reviewed ${changed.length} changed file${changed.length === 1 ? '' : 's'}. ${cleanResultText()}${exemptNote}`, total: 0 };
251
257
  }
252
258
  const L = [`DESIGN SYSTEM REVIEW · ${changed.length} changed file${changed.length === 1 ? '' : 's'}, ${total} finding${total === 1 ? '' : 's'}:`];
253
259
  for (const { f, findings } of perFile.slice(0, 10)) {
@@ -256,7 +262,7 @@ export function reviewData(k) {
256
262
  if (findings.length > 6) L.push(` (+${findings.length - 6} more in this file)`);
257
263
  }
258
264
  if (perFile.length > 10) L.push(`(+${perFile.length - 10} more files with findings)`);
259
- L.push(`Checked: ${CHECKS.join(', ')}. Fix the findings before finishing; rerun roast_review to confirm.`);
265
+ L.push(`Checked: ${CHECKS.join(', ')}.${exemptNote} Fix the findings before finishing; rerun roast_review to confirm.`);
260
266
  return { text: L.join('\n'), total };
261
267
  }
262
268