roast-my-design-system 9.2.2 → 9.2.4

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)**: 622 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, 22 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.2",
3
+ "version": "9.2.4",
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>`
@@ -59,9 +59,11 @@ const outPath = resolve(arg('out', 'harvest.json'));
59
59
  const t0 = Date.now();
60
60
  const exclusions = loadExclusions(target, argAll('exclude'));
61
61
  let files = walkRepo(target, 14, exclusions);
62
+ // where the repo keeps its emails (lib/exempt.mjs), kept past any rescoping
63
+ const email = files.email;
62
64
  const profile = profileRepo(target, files);
63
65
  let { components } = harvestComponents(target, files.code);
64
- let tokens = harvestTokens(target, files.styles, files.code);
66
+ let tokens = harvestTokens(target, files.styles, files.code, { email });
65
67
  // Repo kind and measurability, decided once in profiles/ and read everywhere
66
68
  // (a published components package with no app pages is a LIBRARY; a stack the
67
69
  // detector cannot read is NOT MEASURED, never scored as zeros). The decision
@@ -98,7 +100,7 @@ if (profileOf(profile).isRegistry) {
98
100
  const sheetFile = profile.shadcn?.sheet?.found ? profile.shadcn.sheet.file : null;
99
101
  if (sheetFile && !files.styles.includes(sheetFile)) files.styles.push(sheetFile);
100
102
  ({ components } = harvestComponents(target, files.code));
101
- tokens = harvestTokens(target, files.styles, files.code);
103
+ tokens = harvestTokens(target, files.styles, files.code, { email });
102
104
  // published components are the project's own work, not installed code
103
105
  profile.uiDirs = []; profile.uiDir = null; profile.vendoredUi = false;
104
106
  profile.registry.counted = { code: files.code.length, styles: files.styles.length };
@@ -109,7 +111,7 @@ if (profileOf(profile).isRegistry) {
109
111
  {
110
112
  const P = profileOf(profile);
111
113
  if (P.isTailwind && profile.tailwind?.adopted) {
112
- profile.tailwind.paint = countPaint(target, files.code, { uiDirs: [], kitNames: new Set(), retuned: profile.tailwind.retuned, families: profile.tailwind.families });
114
+ profile.tailwind.paint = countPaint(target, files.code, { uiDirs: [], kitNames: new Set(), retuned: profile.tailwind.retuned, families: profile.tailwind.families, email });
113
115
  }
114
116
  }
115
117
  // A shadcn kitchen gets the 2 paint checks from shadcn's own agent rules,
@@ -126,12 +128,12 @@ if (profileOf(profile).isRegistry) {
126
128
  // registries, because a palette colour in ai-elements teaches the same
127
129
  // wrong lesson as one in own code. The catalogue and kit blocks stay out:
128
130
  // editing them is the intended use.
129
- profile.shadcn.paint = countPaint(target, files.code, { uiDirs: [...P.uiDirs, ...blocks], kitNames });
131
+ profile.shadcn.paint = countPaint(target, files.code, { uiDirs: [...P.uiDirs, ...blocks], kitNames, email });
130
132
  // Own code alone, for the breakdown line under the score.
131
- profile.shadcn.paintOwn = regDirs.length ? countPaint(target, files.code, { uiDirs: allInstalled, kitNames }) : profile.shadcn.paint;
133
+ profile.shadcn.paintOwn = regDirs.length ? countPaint(target, files.code, { uiDirs: allInstalled, kitNames, email }) : profile.shadcn.paint;
132
134
  if (regDirs.length) {
133
135
  const regFiles = files.code.filter((f) => regDirs.some((d) => f.startsWith(`${d}/`)));
134
- const rp = countPaint(target, regFiles, { uiDirs: [], kitNames });
136
+ const rp = countPaint(target, regFiles, { uiDirs: [], kitNames, email });
135
137
  profile.shadcn.registryPaint = { files: rp.ownFiles, tinUses: rp.tin.uses, doorUses: rp.doors.uses, dirs: regDirs };
136
138
  }
137
139
  // Bracket values inside installed code are shadcn's (or a registry's)
@@ -214,7 +216,7 @@ if (workspaces.length > 1) {
214
216
  const codeCount = own.code.length, styleCount = own.styles.length;
215
217
  const entry = { name: w.name, dir: w.dir, codeFiles: codeCount, styleFiles: styleCount, scored: false };
216
218
  if (!scanning.has(w.dir)) { packages.push(entry); continue; }
217
- const t = harvestTokens(target, own.styles, own.code);
219
+ const t = harvestTokens(target, own.styles, own.code, { email });
218
220
  const comps = components.filter((c) => inDir(c.file, w.dir));
219
221
  const dupes = duplicates.exactDuplicates.filter((d) => !d.wrapped
220
222
  && d.files.every((f) => inDir(typeof f === 'string' ? f : f.file, w.dir)));
@@ -58,7 +58,7 @@ export const DEMO_PATH_RE = /(^|\/)(stories|storybook|__stories__|examples?|demo
58
58
  * @param opts { uiDirs: string[], kitNames: Set<string> } — catalogue folders
59
59
  * and the component names defined in them
60
60
  */
61
- export function countPaint(root, codeFiles, { uiDirs = [], kitNames = new Set(), retuned = [], families = null } = {}) {
61
+ export function countPaint(root, codeFiles, { uiDirs = [], kitNames = new Set(), retuned = [], families = null, email = null } = {}) {
62
62
  const inCatalogue = (f) => uiDirs.some((d) => f === d || f.startsWith(`${d}/`));
63
63
  const names = [...kitNames].filter((n) => /^[A-Z][A-Za-z0-9]*$/.test(n));
64
64
  const doorOpen = names.length
@@ -79,7 +79,7 @@ export function countPaint(root, codeFiles, { uiDirs = [], kitNames = new Set(),
79
79
  for (const f of codeFiles) {
80
80
  if (!/\.(tsx|jsx)$/.test(f) || inCatalogue(f) || DEMO_PATH_RE.test(f)) continue;
81
81
  const raw = readSource(join(root, f));
82
- if (raw === null || exemptReason(f, raw)) continue;
82
+ if (raw === null || exemptReason(f, raw, { email })) continue;
83
83
  ownFiles += 1;
84
84
  const src = blankComments(raw);
85
85
 
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { existsSync } from 'node:fs';
10
10
  import {
11
- EMAIL_PRINT_RE, EMAIL_KIT_RE, ARTWORK_NAME_RE, foreignStylesheet, RENDER_TO_IMAGE_RE, OG_ROUTE_RE, RENDERER_PATH_RE, CRASH_PAGE_RE, svgHeavy, exemptReason, isLibraryClass, WIDGET_CSS_RE, WIDGET_CONFIG_RE,
11
+ isEmail, EMAIL_KIT_RE, ARTWORK_NAME_RE, foreignStylesheet, RENDER_TO_IMAGE_RE, OG_ROUTE_RE, RENDERER_PATH_RE, CRASH_PAGE_RE, svgHeavy, exemptReason, isLibraryClass, WIDGET_CSS_RE, WIDGET_CONFIG_RE,
12
12
  } from '../lib/exempt.mjs';
13
13
  import { join } from 'node:path';
14
14
  import { canonical, parseColor } from '../lib/color.mjs';
@@ -311,7 +311,7 @@ export function colourListShare(src) {
311
311
  return inLists / all;
312
312
  }
313
313
 
314
- export function harvestTokens(root, styleFiles, codeFiles) {
314
+ export function harvestTokens(root, styleFiles, codeFiles, { email = null } = {}) {
315
315
  const colors = new Tally(), spacing = new Tally(), radii = new Tally(),
316
316
  fontSizes = new Tally(), fontFamilies = new Tally(), shadows = new Tally();
317
317
  const twColors = new Tally(), twSpacing = new Tally(), twRadii = new Tally(), twTextSizes = new Tally();
@@ -487,9 +487,9 @@ export function harvestTokens(root, styleFiles, codeFiles) {
487
487
  // stylesheets the team did not write, named in the report and not counted
488
488
  const foreignStyles = [];
489
489
  for (const f of styleFiles) {
490
- if (/email|(^|[/.])print([/.]|$)/i.test(f)) continue;
491
490
  const text = readSource(join(root, f));
492
491
  if (text === null) continue;
492
+ if (isEmail(f, text, email)) continue;
493
493
  const foreign = foreignStylesheet(f, text);
494
494
  if (foreign) { foreignStyles.push({ file: f, reason: foreign }); continue; }
495
495
  scanCssText(text, f);
@@ -500,18 +500,20 @@ export function harvestTokens(root, styleFiles, codeFiles) {
500
500
  // reason to discredit the whole report.
501
501
  // Email, artwork, mostly-SVG files and pixel renderers: all imported from
502
502
  // lib/exempt.mjs, the one list every checker reads.
503
- const EXEMPT_RE = EMAIL_PRINT_RE, ARTWORK_RE = ARTWORK_NAME_RE;
503
+ const ARTWORK_RE = ARTWORK_NAME_RE;
504
504
  // every file a check skipped, with the reason, so the report can name them
505
505
  const exemptFiles = [];
506
- const skip = (f, src) => { const r = exemptReason(f, src); if (r) exemptFiles.push({ file: f, reason: r }); return true; };
506
+ const skip = (f, src) => { const r = exemptReason(f, src, { email }); if (r) exemptFiles.push({ file: f, reason: r }); return true; };
507
507
  // Colour-picker palettes and design-field option lists are user-facing data.
508
508
  const PALETTE_FILE_RE = /(color-picker|colour-picker|palette|design-fields|swatch)/i;
509
509
 
510
510
  for (const f of codeFiles) {
511
511
  if (!/\.(tsx|jsx|ts|js)$/.test(f)) continue;
512
- if (EXEMPT_RE.test(f) || CRASH_PAGE_RE.test(f)) { skip(f, ''); continue; }
512
+ if (CRASH_PAGE_RE.test(f)) { skip(f, ''); continue; }
513
513
  let src = readSource(join(root, f));
514
514
  if (src === null) continue;
515
+ // an email is an email by what it shows, not by a path that mentions one
516
+ if (isEmail(f, src, email)) { skip(f, src); continue; }
515
517
  for (const m of src.matchAll(/[A-Za-z_][\w-]+/g)) ownClasses.add(m[0]);
516
518
  listShare.set(f, colourListShare(src));
517
519
 
@@ -4,6 +4,7 @@
4
4
  * stylesheets of any flavor, and config — not just a happy-path shadcn layout.
5
5
  */
6
6
  import { CATALOGUE } from '../profiles/shadcn-data.mjs';
7
+ import { emailContextOf } from '../lib/exempt.mjs';
7
8
  import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
8
9
  import { join, relative, extname } from 'node:path';
9
10
 
@@ -168,6 +169,9 @@ export function walkRepo(root, maxDepth = 14, exclusions = null, readAnyway = ne
168
169
  again.readAnyway = { dir: biggest.dir, files: biggest.files };
169
170
  return again;
170
171
  }
172
+ // where the repo keeps its emails, read once here so every checker that
173
+ // is handed the walk judges an email the same way (lib/exempt.mjs)
174
+ files.email = emailContextOf(files, (f) => readSource(join(root, f)));
171
175
  return files;
172
176
  }
173
177
 
@@ -4,7 +4,11 @@
4
4
  *
5
5
  * Email and print: mail clients strip stylesheets and print has no cascade to
6
6
  * inherit, so the styling has to be inline and hardcoded. That is the medium,
7
- * not a lapse.
7
+ * not a lapse. A screen ABOUT email is not an email, though: a sign-in form,
8
+ * the email settings, an inbox. Until 9.2.3 any path with "email" in it was
9
+ * skipped, and an email client (inbox-zero keeps its whole app under
10
+ * [emailAccountId]/) went unjudged. Now a file is an email when it shows it:
11
+ * see isEmail below.
8
12
  *
9
13
  * Artwork: a file named Icon, Logo, Badge or Illustration carries the colours
10
14
  * of the drawing itself, and a drawing is not interface. The name alone is not
@@ -23,10 +27,78 @@
23
27
  * Shared by the engine (whole files) and by guard-my-design-system (added
24
28
  * lines), so both give the same answer about the same file.
25
29
  */
30
+ // Where an email or a print stylesheet may live: a path that mentions either.
31
+ // A candidate only; isEmail decides.
26
32
  export const EMAIL_PRINT_RE = /email|(^|[/.])print([/.]|$)/i;
33
+ const PRINT_RE = /(^|[/.])print([/.]|$)/i;
27
34
  // An email built with an email kit is an email wherever it lives: react-email
28
35
  // keeps 75 templates in folders like two-buttons/ (2026-09-16).
29
36
  export const EMAIL_KIT_RE = /from\s+['"](?:react-email|@react-email\/[\w-]+|jsx-email|@jsx-email\/[\w-]+|mjml-react|@faire\/mjml-react)['"]/;
37
+ // Markup only an email carries, or a page sent outside the app: MJML, Outlook
38
+ // conditionals, the old table attributes, react-email's roots, and an HTML
39
+ // style="" attribute written as text, which JSX cannot hold (a server that
40
+ // builds its emails as strings: lobe-chat, Ghost, novu).
41
+ export const EMAIL_MARKUP_RE = /<mj-|mso-|<!--\[if|\bcellpadding=|\bcellspacing=|\bbgcolor=|<Html[\s>]|<Body[\s>]|\bstyle\s*=\s*\\?["'][^"'{}]*:[^"']*["']/i;
42
+ // A folder named for email: email, emails, email-templates, twenty-emails,
43
+ // email_service. Not [emailAccountId], which is a route holding an app.
44
+ const EMAIL_FOLDER_RE = /^(?:[a-z0-9]+[-_.])*emails?(?:[-_.][a-z0-9]+)*$/i;
45
+ // Email templates written in a template language rather than code
46
+ const EMAIL_TEMPLATE_FILE_RE = /\.(hbs|handlebars|mjml|njk|ejs|pug|liquid|mustache|html)$/i;
47
+ // A preview of an email draws the email's colours, not the product's
48
+ const EMAIL_PREVIEW_RE = /(e?mail|newsletter).*(preview|frame)|(preview|frame).*(e?mail|newsletter)/i;
49
+ // A stylesheet written for emails: email.css, emails.scss, email-pdf-styles.css
50
+ const EMAIL_SHEET_RE = /^e?mails?(?:[-_.][a-z0-9-]+)*\.(css|scss|sass|less)$/i;
51
+ const base = (f) => f.slice(f.lastIndexOf('/') + 1);
52
+ const dir = (f) => (f.includes('/') ? f.slice(0, f.lastIndexOf('/')) : '');
53
+
54
+ /**
55
+ * Where the repo keeps its emails, read once per scan: `homes` are folders
56
+ * named for email that hold an email template (a code file with an email kit
57
+ * or email markup, or a template-language file), and `templateDirs` are the
58
+ * folders the templates sit in. A template that builds on the team's own
59
+ * layout (twenty's billing emails import their BaseEmail) carries no kit
60
+ * itself; its home says what it is.
61
+ * @param files the walk's { code, other } (repo-relative paths)
62
+ * @param read (file) => text or null
63
+ */
64
+ export function emailContextOf(files, read) {
65
+ const templates = [];
66
+ for (const f of [...(files.code ?? []), ...(files.other ?? [])]) {
67
+ if (!/email/i.test(f)) continue;
68
+ if (EMAIL_TEMPLATE_FILE_RE.test(f)) { templates.push(f); continue; }
69
+ if (!/\.(tsx|jsx|js|ts|mjs|cjs)$/.test(f)) continue;
70
+ const t = read(f) ?? '';
71
+ if (EMAIL_KIT_RE.test(t) || EMAIL_MARKUP_RE.test(t)) templates.push(f);
72
+ }
73
+ const homes = new Set();
74
+ for (const f of templates) {
75
+ const parts = f.split('/');
76
+ for (let i = 1; i < parts.length; i++) if (EMAIL_FOLDER_RE.test(parts[i - 1])) homes.add(parts.slice(0, i).join('/'));
77
+ }
78
+ return { homes: [...homes].sort(), templateDirs: [...new Set(templates.map(dir))].sort() };
79
+ }
80
+
81
+ /**
82
+ * Whether a file is an email or a print stylesheet. A path that mentions
83
+ * email is only a candidate: the file has to show it, by its kit, its markup,
84
+ * the email folder it sits in, being a preview of an email or a stylesheet
85
+ * written for one, or an email-named file beside the templates it sends
86
+ * (Ghost's gift-email-service.ts next to its .hbs files).
87
+ * @param email emailContextOf's answer for this repo; without it, only what
88
+ * the file shows by itself counts
89
+ */
90
+ export function isEmail(file, text = '', email = null) {
91
+ if (!file) return false;
92
+ if (PRINT_RE.test(file) || EMAIL_KIT_RE.test(text)) return true;
93
+ if (!/email/i.test(file)) return false;
94
+ if (EMAIL_MARKUP_RE.test(text)) return true;
95
+ const name = base(file);
96
+ if (EMAIL_PREVIEW_RE.test(name) || EMAIL_SHEET_RE.test(name)) return true;
97
+ if (!email) return false;
98
+ if (email.homes.some((h) => file.startsWith(`${h}/`))) return true;
99
+ const here = dir(file);
100
+ return /e?mail/i.test(name) && email.templateDirs.some((d) => d === here || dir(d) === here);
101
+ }
30
102
  // Next.js's crash page replaces the root layout, so the app's stylesheet
31
103
  // never loads there: Next.js tells developers to build it self-contained,
32
104
  // styling written on the elements. The medium, not a lapse (2026-09-13).
@@ -46,11 +118,13 @@ export const svgHeavy = (text) =>
46
118
  /**
47
119
  * Why this file is exempt from judgement, as a sentence, or null when it is
48
120
  * fair game. Needs the text as well as the name: the artwork exemption is
49
- * earned by drawing, not by being called Icon.
121
+ * earned by drawing, not by being called Icon, and the email one by being an
122
+ * email, not by mentioning one.
123
+ * @param email emailContextOf's answer for this repo (see isEmail)
50
124
  */
51
- export function exemptReason(file, text = '') {
125
+ export function exemptReason(file, text = '', { email = null } = {}) {
52
126
  if (!file) return null;
53
- if (EMAIL_PRINT_RE.test(file) || EMAIL_KIT_RE.test(text)) return 'email and print styling has to be inline, because there is no cascade to inherit';
127
+ if (isEmail(file, text, email)) return 'email and print styling has to be inline, because there is no cascade to inherit';
54
128
  if (CRASH_PAGE_RE.test(file)) return 'the crash page replaces the root layout, so the stylesheet never loads there and its styling has to be inline';
55
129
  if (RENDERER_PATH_RE.test(file)) return 'a renderer draws pixels, so its colours are the picture rather than the interface';
56
130
  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';
@@ -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 } from './exempt.mjs';
56
+ export { EMAIL_PRINT_RE, ARTWORK_NAME_RE, SVG_MARKUP_RE, exemptReason, isEmail } 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.
@@ -122,7 +122,7 @@ export const isStyleFile = (p) => STYLE_EXTS.has(extname(p));
122
122
  export function learnSystem(repoRoot, { exclude = [] } = {}) {
123
123
  const exclusions = loadExclusions(repoRoot, exclude);
124
124
  const files = walkRepo(repoRoot, 14, exclusions);
125
- const t = harvestTokens(repoRoot, files.styles, files.code);
125
+ const t = harvestTokens(repoRoot, files.styles, files.code, { email: files.email });
126
126
  const ledger = harvestComponents(repoRoot, files.code).components;
127
127
  const components = ledger.map(({ name, file, usageCount, isPage }) => ({ name, file, usageCount, isPage }));
128
128
  const profile = profileRepo(repoRoot, files);
@@ -168,6 +168,9 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
168
168
  }
169
169
 
170
170
  return {
171
+ // where the repo keeps its emails: pass it to exemptReason(file, text,
172
+ // { email }) so a guard judges an email the way the report does (9.2.3)
173
+ email: files.email ?? null,
171
174
  tokenFile: t.tokenFile,
172
175
  buttons,
173
176
  workspaces,
@@ -118,7 +118,7 @@ export function kitImportRe(def, layers = []) {
118
118
  * disagree about a line. Returns null when the file is not a kit file or is
119
119
  * the theme itself; { exempt } when the file is not judged, and why.
120
120
  */
121
- export function kitPaintInSource(src, def, { file = null, layers = [] } = {}) {
121
+ export function kitPaintInSource(src, def, { file = null, layers = [], email = null } = {}) {
122
122
  const importRe = kitImportRe(def, layers);
123
123
  const code = src.replace(/\/\*[\s\S]*?\*\//g, (m) => ' '.repeat(m.length)).replace(/(^|[^:'"`\\])\/\/[^\n]*/g, (m, pre) => pre + ' '.repeat(m.length - pre.length));
124
124
  if (!importRe.test(code)) return null;
@@ -129,7 +129,7 @@ export function kitPaintInSource(src, def, { file = null, layers = [] } = {}) {
129
129
  const colours = [...cleaned.matchAll(COLOUR_RE)]
130
130
  .map((m) => ({ value: m[2].toLowerCase().replace(/\s+/g, ''), index: m.index }))
131
131
  .filter((c) => !/,\s*0(?:\.0+)?\)$/.test(c.value) && c.value !== 'transparent');
132
- const why = exemptReason(file, src)
132
+ const why = exemptReason(file, src, { email })
133
133
  ?? (colourTable(code, colours.length) ? 'the file is a colour table, data rather than styling' : null)
134
134
  ?? (colours.length && RENDERER_IMPORT_RE.test(code) ? 'the file drives a chart or a map, so its colours are the picture' : null);
135
135
  if (why) return { exempt: why, colours: [], px: [] };
@@ -141,7 +141,7 @@ export function kitPaintInSource(src, def, { file = null, layers = [] } = {}) {
141
141
  return { exempt: null, colours, px };
142
142
  }
143
143
 
144
- export function countKitPaint(root, codeFiles, { importRe: kitImportRe, themeRe, refRe, themeImportRe = kitImportRe, pxPropRes = [], pxMin = 0, reexportRe = null, spacingCustomRe = null }) {
144
+ export function countKitPaint(root, codeFiles, { importRe: kitImportRe, themeRe, refRe, themeImportRe = kitImportRe, pxPropRes = [], pxMin = 0, reexportRe = null, spacingCustomRe = null }, { email = null } = {}) {
145
145
  const layers = reexportRe ? kitLayers(root, codeFiles, reexportRe) : [];
146
146
  const importRe = layers.length
147
147
  ? new RegExp(`${kitImportRe.source}|from\\s+['"](?:[^'"]*\\/)?(?:${layers.map(escapeRe).join('|')})(?:['"]|\\/)`)
@@ -184,7 +184,7 @@ export function countKitPaint(root, codeFiles, { importRe: kitImportRe, themeRe,
184
184
  kitFiles += 1;
185
185
  refs += (code.match(refRe) ?? []).length;
186
186
  if (isTheme || THEME_NAME_RE.test(f)) { if (!isTheme) for (const c of colours) themeValues.add(c); continue; }
187
- const why = exemptReason(f, src)
187
+ const why = exemptReason(f, src, { email })
188
188
  ?? (colourTable(code, colours.length) ? 'the file is a colour table, data rather than styling' : null)
189
189
  ?? (colours.length && RENDERER_IMPORT_RE.test(code) ? 'the file drives a chart or a map, so its colours are the picture' : null);
190
190
  if (why) { if (colours.length) exempt.push({ file: f, reason: why }); continue; }
@@ -250,9 +250,9 @@ const plain = (html) => String(html ?? '').replace(/<\/?code>/g, '').replace(/&q
250
250
  * component"), note what the theme says about it or null, message the two
251
251
  * as one sentence for the live checks.
252
252
  */
253
- export function kitPaintFindings(src, kit, { file = null } = {}) {
253
+ export function kitPaintFindings(src, kit, { file = null, email = null } = {}) {
254
254
  if (!kit?.def) return null;
255
- const paint = kitPaintInSource(src, kit.def, { file, layers: kit.layers ?? [] });
255
+ const paint = kitPaintInSource(src, kit.def, { file, layers: kit.layers ?? [], email });
256
256
  if (!paint) return null;
257
257
  if (paint.exempt) return { exempt: paint.exempt, findings: [] };
258
258
  const adv = kit.def.advice, themeFile = kit.themeFiles?.[0] ?? null;
@@ -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.2';
4
+ export const VERSION = '9.2.4';
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.
@@ -123,7 +123,7 @@ export function validateContent(content, k) {
123
123
  // Some files cannot be on-system by their nature. Judging them is how a
124
124
  // checker earns its reputation for crying wolf, and a checker people
125
125
  // distrust gets switched off. Say nothing, and say why nothing was said.
126
- const exempt = exemptReason(file, text);
126
+ const exempt = exemptReason(file, text, { email: k.email });
127
127
  const css = file ? CSS_FILE_RE.test(file) : looksLikeCss(text);
128
128
  const findings = [];
129
129
  const add = (rule, severity, index, message, fix) => findings.push({
@@ -169,7 +169,7 @@ export function validateContent(content, k) {
169
169
  for (const f of chartFindings({ file, colours: paint, charts: k.charts, tokenFile: k.tokens.tokenFile })) add(f.rule, f.severity, f.index, f.message, f.fix);
170
170
  }
171
171
  if (k.kit?.def && !css && !chartFile) {
172
- const judged = kitPaintFindings(text, k.kit, { file });
172
+ const judged = kitPaintFindings(text, k.kit, { file, email: k.email });
173
173
  if (judged && !judged.exempt) {
174
174
  kitOwnsColours = true;
175
175
  for (const f of judged.findings) {
@@ -57,7 +57,7 @@ export function loadKnowledge(root) {
57
57
  // library read as a product here. Same call, same answer, both doors.
58
58
  decideProfile(profile, components, files, root);
59
59
  const P = profileOf(profile);
60
- let tokens = harvestTokens(root, files.styles, files.code);
60
+ let tokens = harvestTokens(root, files.styles, files.code, { email: files.email });
61
61
  // A product built on a kit (MUI, Mantine, Chakra, Ant Design): the theme
62
62
  // file is where colours are decided, and its values are the token set the
63
63
  // tools snap to. The report reads the same profile; see profiles/kit-common.
@@ -199,6 +199,9 @@ export function loadKnowledge(root) {
199
199
  neverImported,
200
200
  // stock, not debt: see profiles/index.mjs
201
201
  vendoredUi: P.vendoredUi,
202
+ // where the repo keeps its emails, so a file is judged an email by what it
203
+ // shows, the way the report judges it (lib/exempt.mjs)
204
+ email: files.email ?? null,
202
205
  // folders the team did not write, by the same line the guard doorway
203
206
  // draws (lib/guard-api.mjs): only a shadcn kit has installed code, and a
204
207
  // registry's published folders are its own work
@@ -51,7 +51,7 @@ export function kitProfile(def) {
51
51
  if (!listed) return null;
52
52
  const kitUse = importCounts(ctx.root, ctx.files.code);
53
53
  if (Object.entries(kitUse).some(([k, n]) => k !== def.name && n > kitUse[def.name])) return null;
54
- const paint = countKitPaint(ctx.root, ctx.files.code, def);
54
+ const paint = countKitPaint(ctx.root, ctx.files.code, def, { email: ctx.files.email ?? null });
55
55
  if (paint.kitFiles < MIN_FILES) return null;
56
56
  profile.designSystem = { kind: 'kit', name: def.name, pkg: def.packages[0], confidence: 'high' };
57
57
  profile.kit = { name: def.name, ...paint };