roast-my-design-system 8.7.0 → 8.9.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 +7 -3
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/diagnose/index.mjs +24 -2
- package/skills/roast-my-design-system/scripts/harvest/index.mjs +9 -1
- package/skills/roast-my-design-system/scripts/lib/charts.mjs +146 -0
- package/skills/roast-my-design-system/scripts/lib/gaps.mjs +50 -0
- package/skills/roast-my-design-system/scripts/lib/guard-api.mjs +9 -0
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/mcp/engine.mjs +15 -2
- package/skills/roast-my-design-system/scripts/mcp/knowledge.mjs +8 -0
- package/skills/roast-my-design-system/scripts/mcp/tools.mjs +2 -0
package/README.md
CHANGED
|
@@ -44,6 +44,7 @@ Run it on your codebase and get, in about a second:
|
|
|
44
44
|
- **Reads React and web components alike.** Stencil, Lit and custom elements are detected by tag registration and counted by kebab tag. Libraries get composition maps, not adoption accusations. Earned token namespaces are named in the header. Anything the scan can not read is declared "not measured" instead of scored.
|
|
45
45
|
- **The receipts behind it.** Every colour and its near-identical twin. Every spacing value, typeface, duplicated or never-imported component, inline style and !important. All with real file paths, in one self-contained HTML report you can open, Slack or email.
|
|
46
46
|
- **The first fixes ranked by payoff, each with its prompt.** A "Where to start" list derived from your own numbers. Every move carries a copy button with a ready-made fix prompt for your agent: the finding, the files, the expected payoff, and rules that respect deliberate craft. Fix, re-run the scan, press the next button.
|
|
47
|
+
- **Where agents will invent.** The places where the repo has no answer yet, so the next piece of UI, human or agent, will make one up. Today that is one gap, found by measuring 164 agent sessions on real products: charts that paint their series colours by hand in a repo with no chart palette. The section names the files and the one move that closes it.
|
|
47
48
|
- **Rules that stop the mess coming back.** A generated `design-system-rules.md` with canonical components, your token file, and known duplicates to avoid, so your AI agent follows your system instead of guessing at it. `--apply` injects them into every agent file you have: Claude, Cursor, GitHub Copilot, and Windsurf. Every scan also checks the rules you already have for stale references: paths that no longer exist, components named canonical that nothing imports anymore. And the agent card states which tools can actually read the rules you have (Claude Code, Codex, Cursor), with the one-line fix where a door is missing.
|
|
48
49
|
|
|
49
50
|
## Why this exists
|
|
@@ -114,11 +115,11 @@ Not to be confused with each other: **"Why this matters"** is generic, ships wit
|
|
|
114
115
|
|
|
115
116
|
The full report for vercel/ai-chatbot, top to bottom, including "What the numbers mean", Claude's read of the scan, embedded right under the verdict:
|
|
116
117
|
|
|
117
|
-

|
|
118
119
|
|
|
119
120
|
The same report in light mode (one file, built-in toggle):
|
|
120
121
|
|
|
121
|
-

|
|
122
123
|
|
|
123
124
|
## What it works on
|
|
124
125
|
|
|
@@ -180,6 +181,8 @@ The report and the rules file describe the repo as it was at scan time. `--mcp`
|
|
|
180
181
|
|
|
181
182
|
The loop: context before building, find while building, validate before saving, review before finishing.
|
|
182
183
|
|
|
184
|
+
Charts get their own rule, because a chart needs several colours that differ from each other and most design systems never name them. In a repo that keeps a chart palette (its own `--chart-*` or `--series-*` tokens, a `chartColors` entry in the theme, or shadcn's `--chart-1` to `--chart-5` when a chart actually reads them), a colour written by hand in a chart file is a finding that names the palette. In a repo that has charts but no palette, a new chart that paints by hand gets one warning that names the existing chart doing the same and asks for the palette once. The first chart in a repo gets one warning asking for a name. The report never counted chart colours; the live checks now agree with it.
|
|
185
|
+
|
|
183
186
|
The server reads the repo the way the report does. On a product built on MUI, Mantine, Chakra UI or Ant Design, the context names the theme file and the kit's own way of reading it, `roast_find_token` answers in spacing steps (`12px` is `p: 3` on a 4px MUI theme), and `roast_validate` and `roast_review` flag a colour or a pixel size written onto a kit component where the theme has a value. On a Tailwind theme or a shadcn repo they flag a palette class such as `text-gray-500` or `ring-green-500` where the theme names a colour of that kind.
|
|
184
187
|
|
|
185
188
|
### What a session looks like
|
|
@@ -222,7 +225,7 @@ It writes a first draft anyway and runs `roast_validate` before saving:
|
|
|
222
225
|
Second draft: `color: 'text.secondary'`, `p: 1.5`, the radius read from the theme. `roast_validate` again:
|
|
223
226
|
|
|
224
227
|
```
|
|
225
|
-
No measured violations found. Checked: hardcoded colours vs the token set, near-identical colour twins, off-scale spacing, off-scale radii, font sizes and shadows, typefaces outside the system, arbitrary bracket values, static inline style blocks, !important, duplicate component definitions, new colour tokens that twin an existing token, imports of a duplicate the canonical copy replaces, colours and pixel sizes written onto kit components where the theme has a value.
|
|
228
|
+
No measured violations found. Checked: hardcoded colours vs the token set, near-identical colour twins, off-scale spacing, off-scale radii, font sizes and shadows, typefaces outside the system, arbitrary bracket values, static inline style blocks, !important, duplicate component definitions, new colour tokens that twin an existing token, imports of a duplicate the canonical copy replaces, chart colours against the chart palette, colours and pixel sizes written onto kit components where the theme has a value.
|
|
226
229
|
```
|
|
227
230
|
|
|
228
231
|
Four calls, under 800 tokens in total, and the new component reads the theme instead of adding colour number 44. `roast_review` then checks the whole diff the same way before the agent says it is done.
|
|
@@ -336,6 +339,7 @@ You get the roast in chat plus `design-system-roast.html` at your repo root: a s
|
|
|
336
339
|
- a **light/dark theme toggle** in one file
|
|
337
340
|
- 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
|
|
338
341
|
- a **Where to start** close: up to 3 moves derived from your repo's own numbers, each with a file-path receipt
|
|
342
|
+
- a **Where agents will invent** list: the gaps where the repo has no answer yet, with the files that prove each one and the move that closes it
|
|
339
343
|
- a **present** 🎁 below it: you sat through the roast, so `design-system-rules.md` is wrapped inside the report itself. Unwrap, then copy or download the agent rules generated from your scan.
|
|
340
344
|
|
|
341
345
|
After the roast, the skill also offers to write `design-system-rules.md` to disk and merge it into your CLAUDE.md, `.cursor/rules` or AGENTS.md.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.9.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 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": [
|
|
@@ -1236,6 +1236,25 @@ function whereToStartSection() {
|
|
|
1236
1236
|
</div>`).join('')}</div></section>`;
|
|
1237
1237
|
}
|
|
1238
1238
|
|
|
1239
|
+
// ---------- where agents will invent ----------
|
|
1240
|
+
// A gap is a place where the next piece of UI has nothing to reuse (lib/gaps).
|
|
1241
|
+
// The September 2026 runs showed agents inventing exactly there; the section
|
|
1242
|
+
// names the gap, the files that prove it and the one move that closes it.
|
|
1243
|
+
function gapsSection() {
|
|
1244
|
+
const gaps = h.gaps ?? [];
|
|
1245
|
+
if (!gaps.length) return '';
|
|
1246
|
+
const head = `${gaps.length === 1 ? 'One place' : `${gaps.length} places`} where this repo has no answer yet. An agent asked to build there will invent, because that is what the repo taught it. Close the gap once and the checks take over.`;
|
|
1247
|
+
return `<section class="glass" style="margin-top:16px">
|
|
1248
|
+
${sectionHead('Where agents will invent', head)}
|
|
1249
|
+
<div class="ledger">${gaps.map((g, i) => `
|
|
1250
|
+
<div class="ledger-row gap-row">
|
|
1251
|
+
<span class="ledger-idx">${String(i + 1).padStart(2, '0')}</span>
|
|
1252
|
+
<div class="start-body"><div class="start-head"><div class="start-title">${esc(g.title)}</div></div><div class="sub">${esc(g.detail)}</div>
|
|
1253
|
+
<div class="sub" style="margin-top:6px"><b>Close it:</b> ${esc(g.fix)}</div>
|
|
1254
|
+
${g.files?.length ? `<div class="chips-row" style="margin-top:8px">${g.files.map((f) => `<span class="chip mono">${esc(f)}</span>`).join('')}</div>` : ''}</div>
|
|
1255
|
+
</div>`).join('')}</div></section>`;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1239
1258
|
// ---------- package by package ----------
|
|
1240
1259
|
// One number for a monorepo hides which package is the problem. Each package
|
|
1241
1260
|
// is judged on the same nine tiles, with usage counted repo-wide, so a shared
|
|
@@ -1860,10 +1879,10 @@ ${ogTags()}
|
|
|
1860
1879
|
.ledger-row a.path { font-size:13px; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
1861
1880
|
.ledger-pills { margin-left:auto; display:flex; gap:8px; flex-shrink:0; }
|
|
1862
1881
|
|
|
1863
|
-
.start-row { align-items:flex-start; }
|
|
1882
|
+
.start-row, .gap-row { align-items:flex-start; }
|
|
1864
1883
|
.start-body { min-width:0; }
|
|
1865
1884
|
.start-title { font:600 15px/1.4 var(--disp); }
|
|
1866
|
-
.start-row .sub { margin-top:2px; overflow-wrap:anywhere; }
|
|
1885
|
+
.start-row .sub, .gap-row .sub { margin-top:2px; overflow-wrap:anywhere; }
|
|
1867
1886
|
.start-actions { display:flex; align-items:center; gap:10px; margin-top:10px; }
|
|
1868
1887
|
.fixbtn { font:700 11.5px/1 var(--sans); padding:8px 13px; border-radius:8px; border:1px solid var(--accent);
|
|
1869
1888
|
background:transparent; color:var(--accent); cursor:pointer; }
|
|
@@ -2076,6 +2095,8 @@ ${extraSectionsHtml()}
|
|
|
2076
2095
|
|
|
2077
2096
|
${whereToStartHtml}
|
|
2078
2097
|
|
|
2098
|
+
${gapsSection()}
|
|
2099
|
+
|
|
2079
2100
|
${agentSection()}
|
|
2080
2101
|
|
|
2081
2102
|
${giftSection()}
|
|
@@ -2237,6 +2258,7 @@ if (summaryPath) {
|
|
|
2237
2258
|
// what the page prints. Until 7.0.0 value was the formatted string.
|
|
2238
2259
|
tiles: judgedTiles.map((t, i) => ({ metric: t.metric, label: t.label, value: t.value, display: bigStats[i].num, health: t.health })),
|
|
2239
2260
|
...(startMoves.length ? { moves: startMoves } : {}),
|
|
2261
|
+
...((h.gaps ?? []).length ? { gaps: h.gaps.map(({ id, title, files }) => ({ id, title, files })) } : {}),
|
|
2240
2262
|
packages: (h.packages ?? []).filter((p) => p.scored && p.metrics)
|
|
2241
2263
|
.map((p) => ({ dir: p.dir, name: p.name, ...(scorePackage(p.metrics) ?? {}) }))
|
|
2242
2264
|
.filter((p) => p.score !== undefined)
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { writeFileSync } from 'node:fs';
|
|
16
16
|
import { resolve } from 'node:path';
|
|
17
|
-
import { walkRepo, profileRepo } from './walk.mjs';
|
|
17
|
+
import { walkRepo, profileRepo, readSource } from './walk.mjs';
|
|
18
|
+
import { chartSystemOf } from '../lib/charts.mjs';
|
|
19
|
+
import { designGaps } from '../lib/gaps.mjs';
|
|
18
20
|
import { headerLines, detailLines } from './summary.mjs';
|
|
19
21
|
import { harvestComponents } from './components.mjs';
|
|
20
22
|
import { harvestTokens, isGrey } from './tokens.mjs';
|
|
@@ -155,6 +157,10 @@ const duplicates = findDuplicates(components, profile.uiDir, target, profile.uiD
|
|
|
155
157
|
}
|
|
156
158
|
}
|
|
157
159
|
const context = harvestContext(target);
|
|
160
|
+
// The chart palette the repo keeps, or the charts painting by hand without
|
|
161
|
+
// one, and the gaps an agent will fill by inventing (lib/charts, lib/gaps).
|
|
162
|
+
const charts = chartSystemOf(files, (f) => readSource(resolve(target, f)));
|
|
163
|
+
const gaps = designGaps({ charts, tokenFile: tokens.tokenFile ?? null });
|
|
158
164
|
const staleRules = ruleStaleness(target, components,
|
|
159
165
|
new Set(neverImportedComponents(components, profile.uiDir).map((c) => c.name)),
|
|
160
166
|
[...files.code, ...files.styles, ...files.other]);
|
|
@@ -263,6 +269,8 @@ const harvest = {
|
|
|
263
269
|
duplicates,
|
|
264
270
|
context,
|
|
265
271
|
staleRules,
|
|
272
|
+
charts,
|
|
273
|
+
gaps,
|
|
266
274
|
packages,
|
|
267
275
|
// Active user exclusions with per-pattern removal counts. Present only when
|
|
268
276
|
// something was excluded, so downstream renderers can trust its presence.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chart colours. A chart needs several colours that differ from each other,
|
|
3
|
+
* and most design systems never name them. Probe over the 126 benchmark
|
|
4
|
+
* repos (2026-09-25): charts in three quarters of them, a named chart palette
|
|
5
|
+
* in a quarter, and the biggest group (43) is charts painting their series
|
|
6
|
+
* by hand with no palette to point at. The report leaves chart colours out
|
|
7
|
+
* (a picture, not paint); the live checks counted every one, so the same
|
|
8
|
+
* file got two verdicts depending on the door. One rule now, three tiers:
|
|
9
|
+
*
|
|
10
|
+
* 1. The repo keeps a chart palette: its own names, or shadcn's default
|
|
11
|
+
* --chart-1..5 when a chart actually reads them. A colour written by
|
|
12
|
+
* hand in a chart is drift, and the finding names the palette.
|
|
13
|
+
* 2. No palette, but a chart already paints by hand. The new chart follows
|
|
14
|
+
* a precedent rather than breaking a rule: one warning per file that
|
|
15
|
+
* names the precedent and asks for the palette, once.
|
|
16
|
+
* 3. No palette, no chart: the first chart. One warning, so the colours
|
|
17
|
+
* get a name before the second chart copies them.
|
|
18
|
+
*
|
|
19
|
+
* shadcn's --chart-1..5 arrive with the install whether or not any chart
|
|
20
|
+
* uses them (6 of the 126 repos), so they count as a palette only when a
|
|
21
|
+
* chart reads them; otherwise the repo is tier 2 with the fix ready-made.
|
|
22
|
+
*/
|
|
23
|
+
import { basename } from 'node:path';
|
|
24
|
+
import { parseColor } from './color.mjs';
|
|
25
|
+
|
|
26
|
+
// chart libraries only: a map or an editor is a renderer too, but its
|
|
27
|
+
// colours are never a series (lib/kitpaint.mjs keeps the wider list)
|
|
28
|
+
export const CHART_IMPORT_RE = /from\s+['"](?:recharts|chart\.js|react-chartjs-2|echarts|echarts-for-react|d3|d3-[\w-]+|@nivo\/[\w-]+|victory|apexcharts|react-apexcharts|highcharts|highcharts-react-official|plotly\.js[\w-]*|react-plotly\.js|@visx\/[\w-]+|@antv\/[\w-]+|@mui\/x-charts|@mantine\/charts|@tremor\/react|lightweight-charts|uplot|uplot-react|frappe-charts|@ant-design\/(?:plots|charts)|chartist|billboard\.js|vega|vega-lite|@observablehq\/plot|@carbon\/charts[\w-]*|react-vis)['"\/]/;
|
|
29
|
+
// a file named for a chart; "graphql" is not a graph and neither is a "graphic"
|
|
30
|
+
const CHART_NAME_RE = /(^|[^a-z])(chart|graph(?!ql|ic)|sparkline|donut|histogram|heatmap)/i;
|
|
31
|
+
// an icon, an illustration, a story or a build is not a chart, whatever it
|
|
32
|
+
// imports (likec4's icon set, twenty's storybook, a committed Next chunk)
|
|
33
|
+
const NOT_CHART_RE = /(^|\/)(icons?|illustrations?|graphics?|__stories__|stories|storybook|\.storybook|static\/chunks|dist|build|out)\/|\.stories\.[jt]sx?$/i;
|
|
34
|
+
const CHART_TOKEN_RE = /(--(?:chart|charts|series|graph|graphs|viz|dataviz|data-viz|visuali[sz]ation)[\w-]*)\s*:\s*([^;{}]+)[;}]/gi;
|
|
35
|
+
// a key in a theme, tokens or palette file: chartColors, charts: {…}, series: […]
|
|
36
|
+
const THEME_FILE_RE = /(^|\/)[\w.-]*(theme|palette|colou?rs?|tokens?|foundations?)[\w.-]*(\/|\.[jt]sx?$)/i;
|
|
37
|
+
const CHART_KEY_RE = /\b((?:chart|charts|series|graph|dataviz|dataViz|visuali[sz]ation)(?:Colou?rs|Palette|colou?rs|palette)?)\s*:\s*[{[]/g;
|
|
38
|
+
const COLOUR_LITERAL_RE = /(['"`])(#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgba?|hsla?|oklch|oklab)\([^)'"`]*\))\1/g;
|
|
39
|
+
const SHADCN_DEFAULT_RE = /^--chart-[1-5]$/;
|
|
40
|
+
|
|
41
|
+
// a token value that is a colour: hex, rgb(), hsl(), oklch(), or shadcn's
|
|
42
|
+
// bare "220 70% 50%" triplet. "--chart-help: 'Pick a range'" is not one.
|
|
43
|
+
const isColourValue = (v) => {
|
|
44
|
+
const s = v.trim();
|
|
45
|
+
if (parseColor(s)) return true;
|
|
46
|
+
return /^(oklch|oklab|color|lab|lch)\(/.test(s) || /^\d+(?:\.\d+)?\s+\d+(?:\.\d+)?%\s+\d+(?:\.\d+)?%$/.test(s);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** A chart file: imports a chart library, or is named for one. */
|
|
50
|
+
export function isChartFile(file, text) {
|
|
51
|
+
if (file && NOT_CHART_RE.test(file)) return false;
|
|
52
|
+
if (text && CHART_IMPORT_RE.test(text)) return true;
|
|
53
|
+
return !!file && CHART_NAME_RE.test(basename(file));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
57
|
+
// how a chart would read a palette entry: var(--chart-1) or the class chart-1
|
|
58
|
+
// for a CSS name, the key itself for a theme key
|
|
59
|
+
const refRe = (name) => name.startsWith('--')
|
|
60
|
+
? new RegExp(`(?:var\\(\\s*${escapeRe(name)}\\b|(?:^|[^\\w-])${escapeRe(name.slice(2))}(?![\\w-]))`)
|
|
61
|
+
: new RegExp(`\\b${escapeRe(name)}\\b`);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What the repo already knows about charts, from the same file set the rest
|
|
65
|
+
* of the knowledge reads.
|
|
66
|
+
* @returns { palette: { names, file, shadcnDefault, referenced } | null,
|
|
67
|
+
* precedents: [{ file, count, sample }], chartFiles }
|
|
68
|
+
*/
|
|
69
|
+
export function chartSystemOf(files, read) {
|
|
70
|
+
const names = [], where = new Map();
|
|
71
|
+
const noteToken = (name, file) => { if (!where.has(name)) { names.push(name); where.set(name, file); } };
|
|
72
|
+
for (const f of files.styles ?? []) {
|
|
73
|
+
const text = read(f); if (text === null) continue;
|
|
74
|
+
for (const m of text.matchAll(CHART_TOKEN_RE)) if (isColourValue(m[2])) noteToken(m[1], f);
|
|
75
|
+
}
|
|
76
|
+
const chartTexts = [];
|
|
77
|
+
for (const f of files.code ?? []) {
|
|
78
|
+
const text = read(f); if (text === null) continue;
|
|
79
|
+
// a CSS-in-JS theme or a Tailwind @theme inside a .ts file defines
|
|
80
|
+
// custom properties too
|
|
81
|
+
for (const m of text.matchAll(CHART_TOKEN_RE)) if (isColourValue(m[2])) noteToken(m[1], f);
|
|
82
|
+
if (THEME_FILE_RE.test(f)) {
|
|
83
|
+
for (const m of text.matchAll(CHART_KEY_RE)) {
|
|
84
|
+
if (COLOUR_LITERAL_RE.test(text.slice(m.index, m.index + 600))) noteToken(m[1], f);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (isChartFile(f, text)) chartTexts.push({ f, text });
|
|
88
|
+
}
|
|
89
|
+
const palette = names.length ? { names, file: where.get(names[0]), shadcnDefault: names.every((n) => SHADCN_DEFAULT_RE.test(n)), referenced: false } : null;
|
|
90
|
+
const precedents = [];
|
|
91
|
+
for (const { f, text } of chartTexts) {
|
|
92
|
+
if (palette && !palette.referenced && palette.names.some((n) => refRe(n).test(text))) palette.referenced = true;
|
|
93
|
+
const literals = [...new Set([...text.matchAll(COLOUR_LITERAL_RE)].map((m) => m[2].toLowerCase()))];
|
|
94
|
+
if (literals.length >= 2) precedents.push({ file: f, count: literals.length, sample: literals.slice(0, 3) });
|
|
95
|
+
}
|
|
96
|
+
precedents.sort((a, b) => b.count - a.count);
|
|
97
|
+
return { palette, precedents, chartFiles: chartTexts.length };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 1: a palette to use; 2: a precedent and a gap; 3: the first chart. */
|
|
101
|
+
export function chartTier(charts) {
|
|
102
|
+
if (!charts) return 3;
|
|
103
|
+
if (charts.palette && (!charts.palette.shadcnDefault || charts.palette.referenced)) return 1;
|
|
104
|
+
if (charts.precedents.length || charts.palette) return 2;
|
|
105
|
+
return 3;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const list = (values, max = 6) => `${values.slice(0, max).join(', ')}${values.length > max ? ` and ${values.length - max} more` : ''}`;
|
|
109
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The findings for the colours a chart file writes by hand.
|
|
113
|
+
* @param colours [{ value, index }] the non-token colour literals in the file
|
|
114
|
+
*/
|
|
115
|
+
export function chartFindings({ file, colours, charts, tokenFile = null }) {
|
|
116
|
+
if (!colours?.length) return [];
|
|
117
|
+
const tier = chartTier(charts);
|
|
118
|
+
const distinct = [...new Set(colours.map((c) => c.value))];
|
|
119
|
+
const first = colours[0].index;
|
|
120
|
+
if (tier === 1) {
|
|
121
|
+
const { names, file: pf } = charts.palette;
|
|
122
|
+
const example = names[0].startsWith('--') ? `var(${names[0]})` : `${names[0]}[0]`;
|
|
123
|
+
return colours.map((c) => ({
|
|
124
|
+
rule: 'chart-colour', severity: 'violation', index: c.index,
|
|
125
|
+
message: `Chart colour ${c.value} written by hand; this repo keeps a chart palette (${list(names, 5)} in ${pf}).`,
|
|
126
|
+
fix: `Read the series colour from the palette (${example}). A series that needs a colour the palette lacks gets a new entry there, once.`,
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
const where = tokenFile ?? 'the theme';
|
|
130
|
+
if (tier === 2) {
|
|
131
|
+
const prec = charts.precedents.find((p) => p.file !== file) ?? null;
|
|
132
|
+
const shadcn = charts.palette?.shadcnDefault ? charts.palette : null;
|
|
133
|
+
return [{
|
|
134
|
+
rule: 'chart-palette', severity: 'warning', index: first,
|
|
135
|
+
message: `This chart paints its ${plural(distinct.length, 'series colour')} by hand (${list(distinct)}); the repo has no chart palette${prec ? `, and ${prec.file} already does the same with ${prec.count}` : ''}.`,
|
|
136
|
+
fix: shadcn
|
|
137
|
+
? `The theme already defines --chart-1 to --chart-5 in ${shadcn.file} and no chart reads them. Read them here (var(--chart-1))${prec ? ` and point ${prec.file} at them too` : ''}.`
|
|
138
|
+
: `Name these once as chart tokens (--chart-1, --chart-2 … in ${where}) and read them here${prec ? ` and in ${prec.file}` : ''}, so every chart draws from one set.`,
|
|
139
|
+
}];
|
|
140
|
+
}
|
|
141
|
+
return [{
|
|
142
|
+
rule: 'chart-palette', severity: 'warning', index: first,
|
|
143
|
+
message: `First chart in this repo: ${plural(distinct.length, 'series colour')} written by hand (${list(distinct)}) and no chart palette exists yet.`,
|
|
144
|
+
fix: `Name them once as chart tokens (--chart-1, --chart-2 … in ${where}) so the next chart reads the same set instead of copying these.`,
|
|
145
|
+
}];
|
|
146
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where agents will invent. The September 2026 runs (164 sessions, 10 real
|
|
3
|
+
* products) showed agents copying what a repo already has and inventing only
|
|
4
|
+
* where the design system has no answer: a theme, a chart. A gap is a place
|
|
5
|
+
* where the next piece of UI, human or agent, has nothing to reuse, so the
|
|
6
|
+
* report names it before the invention happens. Every gap carries the files
|
|
7
|
+
* that prove it and the one move that closes it.
|
|
8
|
+
*
|
|
9
|
+
* A gap is admitted only after a fleet probe says it discriminates: it must
|
|
10
|
+
* be common enough to matter and absent often enough to be a finding.
|
|
11
|
+
* Chart palettes (probe of 126 repos, 2026-09-25): charts in three quarters,
|
|
12
|
+
* a palette in a quarter, 44 repos painting series by hand with no palette.
|
|
13
|
+
*
|
|
14
|
+
* Probed the same day and left out, so the next session does not re-run it:
|
|
15
|
+
* - tokens without a dark value: 32 of the 49 dark-mode repos, median 29%
|
|
16
|
+
* of tokens, but a token not restated in .dark is usually a deliberate
|
|
17
|
+
* mode-invariant colour, so the innocent explanation wins;
|
|
18
|
+
* - no radius, shadow or font-size scale: only 7 repos style without
|
|
19
|
+
* Tailwind or a kit, and none of them lacks a scale, so it never fires;
|
|
20
|
+
* - no status colours (success, warning, danger): missing in two thirds
|
|
21
|
+
* of repos, and the agent runs showed no drift on a new status.
|
|
22
|
+
*/
|
|
23
|
+
import { chartTier } from './charts.mjs';
|
|
24
|
+
|
|
25
|
+
const list = (xs, max = 3) => `${xs.slice(0, max).join(', ')}${xs.length > max ? ` and ${xs.length - max} more` : ''}`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param charts from chartSystemOf (lib/charts.mjs)
|
|
29
|
+
* @param tokenFile where the repo keeps its colour tokens, or null
|
|
30
|
+
* @returns [{ id, title, detail, fix, files, weight }] most pressing first
|
|
31
|
+
*/
|
|
32
|
+
export function designGaps({ charts = null, tokenFile = null } = {}) {
|
|
33
|
+
const gaps = [];
|
|
34
|
+
if (charts && chartTier(charts) === 2 && charts.precedents.length) {
|
|
35
|
+
const p = charts.precedents;
|
|
36
|
+
const colours = p.reduce((s, x) => s + x.count, 0);
|
|
37
|
+
const shadcn = charts.palette?.shadcnDefault ? charts.palette : null;
|
|
38
|
+
gaps.push({
|
|
39
|
+
id: 'chart-palette',
|
|
40
|
+
title: shadcn ? 'The chart palette exists and no chart reads it' : 'Charts have no palette',
|
|
41
|
+
detail: `${p.length} chart file${p.length === 1 ? '' : 's'} paint${p.length === 1 ? 's' : ''} ${colours} series colours by hand (${list(p.map((x) => `${x.file} (${x.count})`))})${shadcn ? `, while ${shadcn.file} already defines --chart-1 to --chart-5` : ', and no token names a chart colour'}. The next chart will invent colours ${colours + 1} to ${colours + 6}, because that is what every chart here did.`,
|
|
42
|
+
fix: shadcn
|
|
43
|
+
? `Point ${p.length === 1 ? 'that chart' : 'the existing charts'} at var(--chart-1) to var(--chart-5), then the next chart has a palette to read.`
|
|
44
|
+
: `Name the series once (--chart-1, --chart-2 … in ${tokenFile ?? 'the theme'}) and point ${p.length === 1 ? 'that chart' : 'the existing charts'} at them. From then on a chart that writes a colour by hand is a finding the checks catch.`,
|
|
45
|
+
files: p.slice(0, 5).map((x) => x.file),
|
|
46
|
+
weight: Math.min(60, 20 + colours),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return gaps.sort((a, b) => b.weight - a.weight);
|
|
50
|
+
}
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { extname, join } from 'node:path';
|
|
13
13
|
import { walkRepo, readSource, profileRepo } from '../harvest/walk.mjs';
|
|
14
|
+
import { chartSystemOf } from './charts.mjs';
|
|
15
|
+
import { designGaps } from './gaps.mjs';
|
|
16
|
+
export { designGaps } from './gaps.mjs';
|
|
14
17
|
import { decideProfile, profileOf, installedDirs } from '../profiles/index.mjs';
|
|
15
18
|
import { KITS } from '../profiles/kit-common.mjs';
|
|
16
19
|
import { kitPaintFindings } from './kitpaint.mjs';
|
|
@@ -59,6 +62,7 @@ export { kitPaintFindings };
|
|
|
59
62
|
// Same words as roast_validate, roast_review and --check.
|
|
60
63
|
export { tokenTwinFindings, tokenDefsOf } from './tokentwins.mjs';
|
|
61
64
|
export { avoidedImportFindings, canonicalCopy } from './avoidedimports.mjs';
|
|
65
|
+
export { isChartFile, chartSystemOf, chartTier, chartFindings } from './charts.mjs';
|
|
62
66
|
|
|
63
67
|
// Radius, font size, shadow and typeface: the patterns, so both checkers agree
|
|
64
68
|
// on what a declaration is and what counts as a disciplined value.
|
|
@@ -90,6 +94,7 @@ export const isStyleFile = (p) => STYLE_EXTS.has(extname(p));
|
|
|
90
94
|
* tailwind, // { colors, spacing, radii, textSizes, arbitrary }
|
|
91
95
|
* components, // [{ name, file, usageCount, isPage }] the ledger
|
|
92
96
|
* tokenDefs, // [{ name, file, value, canon, darkValue?, darkCanon? }] colour tokens
|
|
97
|
+
* charts, // { palette, precedents, chartFiles } the chart palette or its absence
|
|
93
98
|
* duplicates, // Map<name, { copies: [{ file, usageCount }], strays }>
|
|
94
99
|
* files, // { styles: n, code: n } — how much was read
|
|
95
100
|
* }
|
|
@@ -105,6 +110,7 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
|
|
|
105
110
|
// the same two lists the MCP knowledge builds (mcp/knowledge.mjs), from the
|
|
106
111
|
// same helpers, for tokenTwinFindings and avoidedImportFindings (8.6.1)
|
|
107
112
|
const read = (f) => readSource(join(repoRoot, f));
|
|
113
|
+
const charts = chartSystemOf(files, read);
|
|
108
114
|
const hardDupes = (findDuplicates(ledger, profile.uiDir, repoRoot).exactDuplicates ?? []).filter((d) => !d.wrapped);
|
|
109
115
|
const P = profileOf(profile);
|
|
110
116
|
// the kit the product is built on, with its definition attached, the way
|
|
@@ -152,6 +158,9 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
|
|
|
152
158
|
// every colour token each stylesheet defines, light and dark: the
|
|
153
159
|
// `others` tokenTwinFindings compares a new token with (8.6.1)
|
|
154
160
|
tokenDefs: repoTokenDefs(files.styles, read),
|
|
161
|
+
// the chart palette, or the charts painting by hand without one (lib/charts.mjs)
|
|
162
|
+
charts,
|
|
163
|
+
gaps: designGaps({ charts, tokenFile: t.tokenFile ?? null }),
|
|
155
164
|
// each duplicated name's copies, their usage and the colours each
|
|
156
165
|
// non-canonical copy hard-codes: the `dupes` avoidedImportFindings reads
|
|
157
166
|
duplicates: dupeCopiesOf(hardDupes, ledger, read),
|
|
@@ -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 = '8.
|
|
4
|
+
export const VERSION = '8.9.0';
|
|
5
5
|
// The shape of harvest.json and summary.json. Bumped only when a field is
|
|
6
6
|
// renamed, removed or changes meaning; a new field is not a new schema. A
|
|
7
7
|
// tool comparing two scans compares like with like by this number, not by VERSION.
|
|
@@ -22,6 +22,7 @@ import { TAILWIND_DEFAULTS } from '../profiles/tailwind-defaults.mjs';
|
|
|
22
22
|
import { oklab } from '../lib/color.mjs';
|
|
23
23
|
import { tokenTwinFindings } from '../lib/tokentwins.mjs';
|
|
24
24
|
import { avoidedImportFindings } from '../lib/avoidedimports.mjs';
|
|
25
|
+
import { isChartFile, chartFindings } from '../lib/charts.mjs';
|
|
25
26
|
|
|
26
27
|
// What this engine measures — shipped with every result, clean or not.
|
|
27
28
|
export const CHECKS = [
|
|
@@ -36,6 +37,7 @@ export const CHECKS = [
|
|
|
36
37
|
'duplicate component definitions',
|
|
37
38
|
'new colour tokens that twin an existing token',
|
|
38
39
|
'imports of a duplicate the canonical copy replaces',
|
|
40
|
+
'chart colours against the chart palette',
|
|
39
41
|
];
|
|
40
42
|
// What a kit or a Tailwind theme adds to the list, so a clean result on an
|
|
41
43
|
// MUI repo says the kit check ran.
|
|
@@ -121,7 +123,18 @@ export function validateContent(content, k) {
|
|
|
121
123
|
// knows which are not paint (a comment, a fallback after a theme read, a
|
|
122
124
|
// compare, SVG artwork), so the generic check stays out of its way.
|
|
123
125
|
let kitOwnsColours = false;
|
|
124
|
-
|
|
126
|
+
// ---------- chart colours ----------
|
|
127
|
+
// A chart's series colours are judged against the chart palette, or its
|
|
128
|
+
// absence, by lib/charts.mjs: the report never counted them, the live
|
|
129
|
+
// checks counted every one, and the same file got two verdicts. On a
|
|
130
|
+
// chart file the chart rule owns colours; the kit judge and the generic
|
|
131
|
+
// colour check stay out of its way.
|
|
132
|
+
const chartFile = !css && !!k.charts && isChartFile(file, text);
|
|
133
|
+
if (chartFile) {
|
|
134
|
+
const paint = got.colors.filter((c) => !k.colorInfo.get(c.value)?.isToken);
|
|
135
|
+
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);
|
|
136
|
+
}
|
|
137
|
+
if (k.kit?.def && !css && !chartFile) {
|
|
125
138
|
const judged = kitPaintFindings(text, k.kit, { file });
|
|
126
139
|
if (judged && !judged.exempt) {
|
|
127
140
|
kitOwnsColours = true;
|
|
@@ -187,7 +200,7 @@ export function validateContent(content, k) {
|
|
|
187
200
|
for (const c of got.colors) {
|
|
188
201
|
if (seenHere.has(`${c.value}:${c.index}`)) continue;
|
|
189
202
|
seenHere.add(`${c.value}:${c.index}`);
|
|
190
|
-
if (kitOwnsColours) continue; // the kit judge said it, or judged it not paint
|
|
203
|
+
if (kitOwnsColours || chartFile) continue; // the kit judge or the chart rule said it, or judged it not paint
|
|
191
204
|
const info = k.colorInfo.get(c.value);
|
|
192
205
|
if (info?.isToken) continue; // it IS the token's value — the system can see it
|
|
193
206
|
const near = nearestToken(c.value, k);
|
|
@@ -26,6 +26,8 @@ import { decideProfile, profileOf } from '../profiles/index.mjs';
|
|
|
26
26
|
import { KITS } from '../profiles/kit-common.mjs';
|
|
27
27
|
import { repoTokenDefs } from '../lib/tokentwins.mjs';
|
|
28
28
|
import { dupeCopiesOf } from '../lib/avoidedimports.mjs';
|
|
29
|
+
import { chartSystemOf } from '../lib/charts.mjs';
|
|
30
|
+
import { designGaps } from '../lib/gaps.mjs';
|
|
29
31
|
|
|
30
32
|
const MAX_DEPTH = 14; // same ruler as the harvest CLI
|
|
31
33
|
|
|
@@ -128,6 +130,10 @@ export function loadKnowledge(root) {
|
|
|
128
130
|
// Every colour token each stylesheet defines, light and dark, so a new one
|
|
129
131
|
// can be compared with the ones already there (lib/tokentwins).
|
|
130
132
|
const tokenDefs = repoTokenDefs(files.styles, read);
|
|
133
|
+
// the chart palette the repo keeps, or the charts that paint by hand
|
|
134
|
+
// without one (lib/charts.mjs)
|
|
135
|
+
const charts = chartSystemOf(files, read);
|
|
136
|
+
const gaps = designGaps({ charts, tokenFile: tokens.tokenFile ?? null });
|
|
131
137
|
|
|
132
138
|
return {
|
|
133
139
|
root,
|
|
@@ -156,6 +162,8 @@ export function loadKnowledge(root) {
|
|
|
156
162
|
dupeByName,
|
|
157
163
|
dupeCopies,
|
|
158
164
|
tokenDefs,
|
|
165
|
+
charts,
|
|
166
|
+
gaps,
|
|
159
167
|
neverImported,
|
|
160
168
|
// stock, not debt: see profiles/index.mjs
|
|
161
169
|
vendoredUi: P.vendoredUi,
|
|
@@ -91,6 +91,8 @@ export function getContext(k, { path = null } = {}) {
|
|
|
91
91
|
// stylesheet repeats more often
|
|
92
92
|
const faces = distinctTypefaces([...(t.themeFonts ?? []), ...(t.fontFamilies ?? [])]);
|
|
93
93
|
if (faces.length) L.push(`TYPE: ${faces.slice(0, 3).join(', ')}. No new typefaces, no hand-declared font stacks.`);
|
|
94
|
+
// where the repo has no answer yet, so the agent knows it is inventing
|
|
95
|
+
for (const g of (k.gaps ?? []).slice(0, 2)) L.push(`GAP: ${g.title.toLowerCase()}. ${g.fix}`);
|
|
94
96
|
L.push('DISCIPLINE: no static style={{ }}, no !important, no new arbitrary values. Match the neighbouring component.');
|
|
95
97
|
if (k.agentFiles.length === 0) L.push('NOTE: this repo has no agent rules file; these lines are the only guardrail. Suggest running npx roast-my-design-system@latest --apply.');
|
|
96
98
|
L.push('Before finishing: call roast_validate on what you wrote, then roast_review.');
|