roast-my-design-system 8.7.0 → 8.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -180,6 +180,8 @@ The report and the rules file describe the repo as it was at scan time. `--mcp`
180
180
 
181
181
  The loop: context before building, find while building, validate before saving, review before finishing.
182
182
 
183
+ 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.
184
+
183
185
  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
186
 
185
187
  ### What a session looks like
@@ -222,7 +224,7 @@ It writes a first draft anyway and runs `roast_validate` before saving:
222
224
  Second draft: `color: 'text.secondary'`, `p: 1.5`, the radius read from the theme. `roast_validate` again:
223
225
 
224
226
  ```
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.
227
+ 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
228
  ```
227
229
 
228
230
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "8.7.0",
3
+ "version": "8.8.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": [
@@ -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, "flowchart" is a diagram
30
+ const CHART_NAME_RE = /(^|[^a-z])(chart|graph(?!ql)|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?|__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
+ }
@@ -11,6 +11,7 @@
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';
14
15
  import { decideProfile, profileOf, installedDirs } from '../profiles/index.mjs';
15
16
  import { KITS } from '../profiles/kit-common.mjs';
16
17
  import { kitPaintFindings } from './kitpaint.mjs';
@@ -59,6 +60,7 @@ export { kitPaintFindings };
59
60
  // Same words as roast_validate, roast_review and --check.
60
61
  export { tokenTwinFindings, tokenDefsOf } from './tokentwins.mjs';
61
62
  export { avoidedImportFindings, canonicalCopy } from './avoidedimports.mjs';
63
+ export { isChartFile, chartSystemOf, chartTier, chartFindings } from './charts.mjs';
62
64
 
63
65
  // Radius, font size, shadow and typeface: the patterns, so both checkers agree
64
66
  // on what a declaration is and what counts as a disciplined value.
@@ -90,6 +92,7 @@ export const isStyleFile = (p) => STYLE_EXTS.has(extname(p));
90
92
  * tailwind, // { colors, spacing, radii, textSizes, arbitrary }
91
93
  * components, // [{ name, file, usageCount, isPage }] the ledger
92
94
  * tokenDefs, // [{ name, file, value, canon, darkValue?, darkCanon? }] colour tokens
95
+ * charts, // { palette, precedents, chartFiles } the chart palette or its absence
93
96
  * duplicates, // Map<name, { copies: [{ file, usageCount }], strays }>
94
97
  * files, // { styles: n, code: n } — how much was read
95
98
  * }
@@ -152,6 +155,8 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
152
155
  // every colour token each stylesheet defines, light and dark: the
153
156
  // `others` tokenTwinFindings compares a new token with (8.6.1)
154
157
  tokenDefs: repoTokenDefs(files.styles, read),
158
+ // the chart palette, or the charts painting by hand without one (lib/charts.mjs)
159
+ charts: chartSystemOf(files, read),
155
160
  // each duplicated name's copies, their usage and the colours each
156
161
  // non-canonical copy hard-codes: the `dupes` avoidedImportFindings reads
157
162
  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.7.0';
4
+ export const VERSION = '8.8.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
- if (k.kit?.def && !css) {
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,7 @@ 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';
29
30
 
30
31
  const MAX_DEPTH = 14; // same ruler as the harvest CLI
31
32
 
@@ -128,6 +129,9 @@ export function loadKnowledge(root) {
128
129
  // Every colour token each stylesheet defines, light and dark, so a new one
129
130
  // can be compared with the ones already there (lib/tokentwins).
130
131
  const tokenDefs = repoTokenDefs(files.styles, read);
132
+ // the chart palette the repo keeps, or the charts that paint by hand
133
+ // without one (lib/charts.mjs)
134
+ const charts = chartSystemOf(files, read);
131
135
 
132
136
  return {
133
137
  root,
@@ -156,6 +160,7 @@ export function loadKnowledge(root) {
156
160
  dupeByName,
157
161
  dupeCopies,
158
162
  tokenDefs,
163
+ charts,
159
164
  neverImported,
160
165
  // stock, not debt: see profiles/index.mjs
161
166
  vendoredUi: P.vendoredUi,