roast-my-design-system 9.2.1 → 9.2.3
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 +5 -5
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/diagnose/index.mjs +33 -11
- package/skills/roast-my-design-system/scripts/harvest/index.mjs +9 -7
- package/skills/roast-my-design-system/scripts/harvest/paint.mjs +2 -2
- package/skills/roast-my-design-system/scripts/harvest/tokens.mjs +8 -6
- package/skills/roast-my-design-system/scripts/harvest/walk.mjs +4 -0
- package/skills/roast-my-design-system/scripts/lib/exempt.mjs +78 -4
- package/skills/roast-my-design-system/scripts/lib/guard-api.mjs +5 -2
- package/skills/roast-my-design-system/scripts/lib/kitpaint.mjs +6 -6
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/mcp/engine.mjs +2 -2
- package/skills/roast-my-design-system/scripts/mcp/knowledge.mjs +4 -1
- package/skills/roast-my-design-system/scripts/profiles/kit-common.mjs +1 -1
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ The first thing the report says: the places where the repo has no answer yet, or
|
|
|
73
73
|
|
|
74
74
|
One gap is known for certain today, because the runs found it: charts hardcoding colours in a repo with no chart palette. 43 of 126 public repos look like that. A gap joins the list when it has been measured.
|
|
75
75
|
|
|
76
|
-
###
|
|
76
|
+
### Fixes you can make right now to increase the health score
|
|
77
77
|
|
|
78
78
|
The mess the agent will copy, ranked by what fixing it is worth.
|
|
79
79
|
|
|
@@ -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)**:
|
|
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
|
-

|
|
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
|
|
|
@@ -347,7 +347,7 @@ You get the roast in chat plus `design-system-roast.html` at your repo root, a s
|
|
|
347
347
|
- stat tiles comparing you to all 3 yardsticks
|
|
348
348
|
- a **light/dark theme toggle** in one file
|
|
349
349
|
- the usage-weighted palette bar, the grey ramp, the off-scale spacing receipts, the duplicate-component receipts with clickable file paths, and the worst-offenders ledger
|
|
350
|
-
- a **
|
|
350
|
+
- a **Fixes you can make right now to increase the health score** list: up to 3 moves derived from your repo's own numbers, each with a file-path receipt
|
|
351
351
|
- **Where will your agent have to guess?** opens the report: the gaps where the repo has no answer yet, with the files that prove each one and the move that closes it, or "No gaps found"
|
|
352
352
|
- **Give the agent the answers**: `design-system-rules.md` wrapped inside the report as a present. Unwrap, then copy or download the agent rules generated from your scan
|
|
353
353
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "9.2.
|
|
3
|
+
"version": "9.2.3",
|
|
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": [
|
|
@@ -1201,17 +1201,22 @@ function whereToStartSection() {
|
|
|
1201
1201
|
}
|
|
1202
1202
|
|
|
1203
1203
|
// What each move is actually worth, then rank by payoff for real.
|
|
1204
|
+
// A move with no points says why, so the list never promises what the
|
|
1205
|
+
// score cannot give: the thing it fixes is not scored, or its tile is
|
|
1206
|
+
// already green, or the move lands inside the band it started in.
|
|
1204
1207
|
for (const item of c) {
|
|
1205
|
-
item.delta = 0; item.target = null;
|
|
1208
|
+
item.delta = 0; item.target = null; item.why = 'not part of the score';
|
|
1206
1209
|
if (!item.metric) continue;
|
|
1207
|
-
const
|
|
1208
|
-
|
|
1210
|
+
const tile = bigStats.find((st) => st.metric === item.metric);
|
|
1211
|
+
const now = tile?.healthValue;
|
|
1212
|
+
if (now === undefined || !(tile.health in SCORE_OF)) continue;
|
|
1209
1213
|
if (item.after !== undefined) {
|
|
1210
1214
|
const gain = bandPoints(item.metric, now, item.after);
|
|
1211
1215
|
if (gain > 0) { item.delta = gain; continue; }
|
|
1212
1216
|
}
|
|
1213
1217
|
const nb = nextBand(item.metric, now);
|
|
1214
1218
|
if (nb && nb.gain > 0) item.target = nb;
|
|
1219
|
+
else item.why = healthOf(item.metric, now) === 'good' ? 'already green' : 'no points on its own';
|
|
1215
1220
|
}
|
|
1216
1221
|
const top3 = c.sort((a, b) => (b.delta - a.delta) || (b.score - a.score)).slice(0, 3);
|
|
1217
1222
|
if (!top3.length) return '';
|
|
@@ -1220,16 +1225,32 @@ function whereToStartSection() {
|
|
|
1220
1225
|
const applied = new Map();
|
|
1221
1226
|
for (const item of top3) if (item.metric && item.after !== undefined && item.delta > 0) applied.set(item.metric, item.after);
|
|
1222
1227
|
const after = projectedScore(applied);
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
+
// The line under the heading counts only the moves that pay, and says what
|
|
1229
|
+
// the others are. "Three fixes to increase your score" stood over a list
|
|
1230
|
+
// where 1 of the 3 could, by 4 points (shadcn-admin, 2026-09-29).
|
|
1231
|
+
const paying = healthScore !== null && after > healthScore ? top3.filter((item) => item.delta > 0) : [];
|
|
1232
|
+
const later = top3.filter((item) => !paying.includes(item) && item.target);
|
|
1233
|
+
startProjection = { count: paying.length, after };
|
|
1234
|
+
const COUNT = ['one', 'two', 'three'];
|
|
1235
|
+
const cap = (s) => s[0].toUpperCase() + s.slice(1);
|
|
1236
|
+
const total = top3.length;
|
|
1237
|
+
const none = total - paying.length - later.length;
|
|
1238
|
+
const ofThese = (k) => (k === total
|
|
1239
|
+
? `${cap(COUNT[k - 1])} ${k === 1 ? 'fix' : 'fixes'}`
|
|
1240
|
+
: `${cap(COUNT[k - 1])} of these ${COUNT[total - 1]} fixes`);
|
|
1241
|
+
const inFull = (k) => `once ${k === 1 ? 'it is' : 'they are'} done in full`;
|
|
1242
|
+
const rest = none ? ` The remaining ${none === 1 ? 'one does' : `${COUNT[none - 1]} do`} not move the score.` : '';
|
|
1243
|
+
const head = paying.length
|
|
1244
|
+
? `${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
|
+
: later.length
|
|
1246
|
+
? `${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.`;
|
|
1248
|
+
const title = paying.length || later.length ? 'Fixes you can make right now to increase the health score' : 'Fixes you can make right now';
|
|
1228
1249
|
const chip = (item) => item.delta > 0
|
|
1229
1250
|
? `<span class="delta" title="once all of them are done">+${item.delta}</span>`
|
|
1230
1251
|
: item.target
|
|
1231
1252
|
? `<span class="delta delta-target">${item.target.target === 0 ? 'clear them all' : `under ${n(item.target.target)}`} · +${item.target.gain}</span>`
|
|
1232
|
-
:
|
|
1253
|
+
: `<span class="delta delta-none">${item.why}</span>`;
|
|
1233
1254
|
// The copy button beside each move hands the finding to whatever agent the
|
|
1234
1255
|
// reader pastes it into: one move per prompt, so progress stays visible and
|
|
1235
1256
|
// finishable. Prompt text is composed from the same title/sub the row shows
|
|
@@ -1251,7 +1272,7 @@ function whereToStartSection() {
|
|
|
1251
1272
|
delta: item.delta || 0, prompt: promptFor(item),
|
|
1252
1273
|
}));
|
|
1253
1274
|
return `<section class="glass pad">
|
|
1254
|
-
${sectionHead(
|
|
1275
|
+
${sectionHead(title, head)}
|
|
1255
1276
|
<div class="ledger">${top3.map((item, i) => `
|
|
1256
1277
|
<div class="ledger-row start-row">
|
|
1257
1278
|
<span class="ledger-idx">${String(i + 1).padStart(2, '0')}</span>
|
|
@@ -1651,7 +1672,7 @@ function exceptionsBlock() {
|
|
|
1651
1672
|
function addIndex(page) {
|
|
1652
1673
|
// heading text is already HTML-escaped; strip tags and leading counts only
|
|
1653
1674
|
const short = (t) => t.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim().replace(/^[\d,]+ /, '')
|
|
1654
|
-
.replace(/^What your AI agent sees today$/, 'What your agent sees').replace(/^Give the agent the answers$/, 'Agent rules').replace(/^What the repo teaches the agent$/, 'What it teaches').replace(/^
|
|
1675
|
+
.replace(/^What your AI agent sees today$/, 'What your agent sees').replace(/^Give the agent the answers$/, 'Agent rules').replace(/^What the repo teaches the agent$/, 'What it teaches').replace(/^Fixes you can make right now( to increase the health score)?$/, 'What to fix')
|
|
1655
1676
|
.replace(/^The shadcn theme and the 2 shadcn checks$/, 'shadcn theme and checks')
|
|
1656
1677
|
.replace(/^Your Tailwind theme, and what goes around it$/, 'Your Tailwind theme').replace(/^Your (\w+) theme, and what is written around it$/, 'Your $1 theme').replace(/, declared .*$/, '')
|
|
1657
1678
|
.replace(/^off-scale spacing values$/, 'Off-scale spacing').replace(/^inline style blocks?$/, 'Inline styles').replace(/^typefaces?$/, 'Typefaces');
|
|
@@ -2047,6 +2068,7 @@ ${ogTags()}
|
|
|
2047
2068
|
.delta { font:700 11px/1 var(--sans); letter-spacing:.04em; padding:5px 9px; border-radius:99px;
|
|
2048
2069
|
background:var(--ok-soft); color:var(--ok); white-space:nowrap; }
|
|
2049
2070
|
.delta-target { background:var(--amber-soft); color:var(--amber); font-weight:600; }
|
|
2071
|
+
.delta-none { background:transparent; box-shadow:inset 0 0 0 1px var(--line-soft); color:var(--dim); font-weight:500; letter-spacing:0; }
|
|
2050
2072
|
.proj { color:var(--accent); font-weight:700; }
|
|
2051
2073
|
.ndot { display:inline-block; width:9px; height:9px; border-radius:3px; margin-right:4px; vertical-align:-1px; box-shadow:inset 0 0 0 1px var(--cell-ring); }
|
|
2052
2074
|
.nsim { color:var(--dim2); font-weight:400; padding:0 2px; }
|
|
@@ -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
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
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.
|
|
4
|
+
export const VERSION = '9.2.3';
|
|
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 };
|