roast-my-design-system 9.3.2 → 9.4.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
@@ -133,20 +133,20 @@ Eleven reports, hosted exactly as the tool writes them. Every number determinist
133
133
  - **[Metabase](https://gregkozakiewicz.github.io/roast-my-design-system/examples/metabase-mantine.html)** (Mantine): its own wrapper over Mantine counts as the kit, so 2,679 files are read instead of 392; nothing written onto components, 2 spacings per 100 kit files. Score 47.
134
134
  - **[SigNoz](https://gregkozakiewicz.github.io/roast-my-design-system/examples/signoz-antd.html)** (Ant Design): 2 colours and 7 spacings per 100 kit files written in style objects where a token exists. Score 51.
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
- - **[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.
136
+ - **[vercel/ai-chatbot](https://gregkozakiewicz.github.io/roast-my-design-system/examples/vercel-ai-chatbot.html)** (shadcn install): 71 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)**: 624 arbitrary bracket values, 21 duplicated components, and the chart-palette gap named. Score 20.
138
+ - **[dubinc/dub](https://gregkozakiewicz.github.io/roast-my-design-system/examples/dubinc-dub.html)**: 642 arbitrary bracket values, 21 duplicated components, and the chart-palette gap named. Score 20.
139
139
  - **[telekom/scale](https://gregkozakiewicz.github.io/roast-my-design-system/examples/telekom-scale.html)** (Stencil): 95 Stencil components read by tag; 66 spacing values outside the scale where about 12 would do; Claude's notes embedded. Score 55.
140
140
  - **[magicuidesign/magicui](https://gregkozakiewicz.github.io/roast-my-design-system/examples/magicui.html)** (registry): counted on the components it publishes, 52 off-theme colours per 100 files in the code it ships, its docs site kept out and named. Score 78.
141
141
  - **[adobe/spectrum-web-components](https://gregkozakiewicz.github.io/roast-my-design-system/examples/adobe-spectrum.html)** (Lit): hardcoded colours sitting beside 744 colour tokens, and 37 !important declarations. Score 66.
142
142
 
143
143
  The full report for vercel/ai-chatbot. The verdict answers what the agent will learn here, the gap section comes first, then "What the repo teaches the agent", Claude's read of the scan:
144
144
 
145
- ![The full diagnosis report for vercel/ai-chatbot in dark mode: a fixed side panel with the health score and what it measures, the stack, how the repo was read as a shadcn install, an index of every section and what is not the team's and not counted; then where the agent will have to guess, the fixes you can make right now, each with what it is worth and its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, 3-yardstick tiles including the 2 shadcn tiles, the adoption map treemap, palette forensics, the shadcn theme variable by variable, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](assets/report-full-dark.png?v=9.2.6)
145
+ ![The full diagnosis report for vercel/ai-chatbot in dark mode: a fixed side panel with the health score and what it measures, the stack, how the repo was read as a shadcn install, an index of every section and what is not the team's and not counted; then where the agent will have to guess, the fixes you can make right now, each with what it is worth and its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, 3-yardstick tiles including the 2 shadcn tiles, the adoption map treemap, palette forensics, the shadcn theme variable by variable, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](assets/report-full-dark.png?v=9.3.3)
146
146
 
147
147
  The same report in light mode (one file, built-in toggle):
148
148
 
149
- ![The diagnosis report in light mode](assets/report-light-hero.png?v=9.2.6)
149
+ ![The diagnosis report in light mode](assets/report-light-hero.png?v=9.3.3)
150
150
 
151
151
  ## Why the numbers hold
152
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "9.3.2",
3
+ "version": "9.4.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": [
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { existsSync } from 'node:fs';
10
10
  import {
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,
11
+ isEmail, foreignStylesheet, CRASH_PAGE_RE, 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';
@@ -25,7 +25,10 @@ class Tally {
25
25
  e.count++;
26
26
  e.files.set(file, (e.files.get(file) ?? 0) + 1);
27
27
  }
28
- toJSON(limitFiles = 5) {
28
+ // `every` keeps the whole per-file tally beside the capped list shown in
29
+ // the report, for a caller that has to split the count by folder exactly
30
+ // (profiles/splitArbitrary: installed code against the team's own).
31
+ toJSON(limitFiles = 5, { every = false } = {}) {
29
32
  return [...this.map.values()]
30
33
  .sort((a, b) => b.count - a.count)
31
34
  .map((e) => ({
@@ -33,6 +36,7 @@ class Tally {
33
36
  count: e.count,
34
37
  files: [...e.files.entries()].sort((a, b) => b[1] - a[1]).slice(0, limitFiles)
35
38
  .map(([file, count]) => ({ file, count })),
39
+ ...(every ? { every: [...e.files.entries()].map(([file, count]) => ({ file, count })) } : {}),
36
40
  }));
37
41
  }
38
42
  }
@@ -500,7 +504,6 @@ export function harvestTokens(root, styleFiles, codeFiles, { email = null } = {}
500
504
  // reason to discredit the whole report.
501
505
  // Email, artwork, mostly-SVG files and pixel renderers: all imported from
502
506
  // lib/exempt.mjs, the one list every checker reads.
503
- const ARTWORK_RE = ARTWORK_NAME_RE;
504
507
  // every file a check skipped, with the reason, so the report can name them
505
508
  const exemptFiles = [];
506
509
  const skip = (f, src) => { const r = exemptReason(f, src, { email }); if (r) exemptFiles.push({ file: f, reason: r }); return true; };
@@ -566,18 +569,14 @@ export function harvestTokens(root, styleFiles, codeFiles, { email = null } = {}
566
569
  paletteFiles.add(f);
567
570
  for (const m of src.matchAll(HEX_RE)) tokenDefined.add(normalizeHex(m[0]));
568
571
  }
569
- if (ARTWORK_RE.test(f) || RENDERER_PATH_RE.test(f) || svgHeavy(src)) { skip(f, src); continue; }
570
- // A render-to-image surface (OG card, PDF invoice) is artwork drawn with
571
- // code: its colours are the picture's, not the product's palette. Until
572
- // 5.10.0 they were the only "strays" a clean shadcn repo had, and they
573
- // triggered the "every single one is hardcoded" banner on it.
574
- const renderToImage = RENDER_TO_IMAGE_RE.test(src) || OG_ROUTE_RE.test(f);
575
- if (renderToImage) { skip(f, src); continue; }
576
- // a page a headless browser prints to a PDF (rybbit's reports), found by
577
- // the walk from the file that prints it (lib/exempt.mjs printedFilesOf)
578
- if (email?.printed?.includes(f)) { skip(f, src); continue; }
579
- // an email written with an email kit, wherever it lives (react-email)
580
- if (EMAIL_KIT_RE.test(src)) { skip(f, src); continue; }
572
+ // Artwork, renderers, render-to-image surfaces (OG cards, PDF invoices),
573
+ // pages a headless browser prints, emails written with a kit: one rule,
574
+ // lib/exempt.mjs, the same answer the live checks and the guard give.
575
+ // Until 9.3.3 this counter kept its own list, and the two had drifted: a
576
+ // file named Badge or IconButton walked free here on its name alone
577
+ // (2,321 files in 173 repos) while an icon in an icons folder was
578
+ // counted here and exempt everywhere else (likec4's 12,203 icon colours).
579
+ if (exemptReason(f, src, { email })) { skip(f, src); continue; }
581
580
 
582
581
  // Tailwind classes
583
582
  for (const cls of classStrings(src)) {
@@ -603,7 +602,7 @@ export function harvestTokens(root, styleFiles, codeFiles, { email = null } = {}
603
602
  // length inside them is still a hardcoded value — harvest those from every
604
603
  // non-trivial block; count only the all-literal blocks.
605
604
  const allBlocks = inlineStyleBlocks(src).filter((b) => !TRIVIAL_INLINE_RE.test(b.trim()));
606
- const staticBlocks = renderToImage ? [] : allBlocks.filter(isStaticInline);
605
+ const staticBlocks = allBlocks.filter(isStaticInline);
607
606
  if (staticBlocks.length) {
608
607
  inlineStyleCount += staticBlocks.length;
609
608
  inlineStyleFiles.set(f, (inlineStyleFiles.get(f) ?? 0) + staticBlocks.length);
@@ -810,7 +809,7 @@ export function harvestTokens(root, styleFiles, codeFiles, { email = null } = {}
810
809
  spacing: twSpacing.toJSON(),
811
810
  radii: twRadii.toJSON(),
812
811
  textSizes: twTextSizes.toJSON(),
813
- arbitrary: twArbitrary.toJSON(),
812
+ arbitrary: twArbitrary.toJSON(5, { every: true }),
814
813
  },
815
814
  important: {
816
815
  // aimed at a library's own class names: kept out, named at the top
@@ -17,6 +17,7 @@ export { designGaps } from './gaps.mjs';
17
17
  import { decideProfile, profileOf, installedDirs } from '../profiles/index.mjs';
18
18
  import { KITS } from '../profiles/kit-common.mjs';
19
19
  import { kitPaintFindings } from './kitpaint.mjs';
20
+ import { paletteFindings } from './palette.mjs';
20
21
  import { PALETTE_CLASS_RE, DEMO_PATH_RE, blankComments } from '../harvest/paint.mjs';
21
22
  import { harvestTokens, extractStyling, normalizeHex, isGrey } from '../harvest/tokens.mjs';
22
23
  import { harvestComponents, definedComponents, tsconfigAliases } from '../harvest/components.mjs';
@@ -67,6 +68,14 @@ export { WIDGET_CSS_RE, WIDGET_CONFIG_RE, LIBRARY_CLASS_RE, isLibraryClass } fro
67
68
  // examples and demos: a guard that skips them flags palette classes in the
68
69
  // same files the report counts.
69
70
  export { PALETTE_CLASS_RE, DEMO_PATH_RE, blankComments };
71
+ // The palette rule itself (9.4.0): paletteFindings(fileText, system.profile
72
+ // .palette, { file }) returns the worded findings the live checks give for
73
+ // the same file, with the index of each in the text; empty when the rule is
74
+ // off, on a stylesheet, in a demo folder or inside a kit door. Which
75
+ // vocabulary applies was decided once by the profile (lib/palette.mjs), so
76
+ // a guard that calls this agrees with the report's live checks by
77
+ // construction, on a Tailwind theme and on a shadcn kit alike.
78
+ export { paletteFindings };
70
79
  // A product built on a kit (MUI, Mantine, Chakra UI, Ant Design), 8.4.6: a
71
80
  // colour or a pixel size written onto a kit component where the theme has a
72
81
  // value. kitPaintFindings(fileText, system.profile.kit, { file }) returns the
@@ -225,8 +234,14 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
225
234
  // a product built on a kit: name, theme files and values, spacing step,
226
235
  // the team's own layers over it, and the definition kitPaintFindings reads
227
236
  kit,
228
- // a shadcn kit whose theme file holds the variables, in CSS-variable
229
- // mode: a palette class in own code is paint from a tin
237
+ // the vocabulary the palette rule judges against, or null when it is
238
+ // off: a Tailwind theme's own names, shadcn's names when the sheet's
239
+ // contract holds in CSS-variable mode, or the repo's own theme under a
240
+ // shadcn kit with no rows (lib/palette.mjs, 9.4.0). Hand it to
241
+ // paletteFindings.
242
+ palette: P.palette,
243
+ // superseded by `palette` in 9.4.0; kept one release for readers of the
244
+ // old shape. The old gate read the configured sheet's :root rows alone.
230
245
  paletteReady: P.isShadcn && (P.shadcn?.sheet?.shadcnPresent ?? 0) >= 5 && P.designSystem?.cssVariables !== false,
231
246
  sheetFile: P.shadcn?.sheet?.found ? P.shadcn.sheet.file : null,
232
247
  // packages whose Tailwind config scopes utilities under an id: widgets
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Palette classes where the theme names its colours: one rule, read by the
3
+ * report's live checks (validate, review, --check, the edit hook, the
4
+ * end-of-turn review) and by the guard through the doorway.
5
+ *
6
+ * Until 9.4.0 the rule lived twice. The live checks switched it on for a
7
+ * Tailwind theme and for any shadcn repo; the guard switched it on only for
8
+ * a shadcn repo whose configured sheet held five of shadcn's rows under
9
+ * :root. Probed on the fleet (2026-10-01): the guard was silent on 11 of 48
10
+ * shadcn repos where the live checks spoke (formbricks writes its theme
11
+ * straight into @theme, documenso names no sheet path, Nango and Ghost keep
12
+ * a v4 theme of their own and no shadcn rows), and the live checks flagged
13
+ * 1,394 palette classes in rybbit's last 300 changes where the report says
14
+ * the palette is the theme (utility-class mode). A rule kept in two places
15
+ * drifts; this is the one place.
16
+ *
17
+ * decidePalette answers once, from the profile, which vocabulary a palette
18
+ * class is judged against (profiles/index.mjs stores it as profile.palette):
19
+ * 1. a Tailwind theme in use: its own names, with the theme's families
20
+ * (a palette grey is only drift where the theme has a grey) and its
21
+ * retuned names (a palette name given the repo's own colour is the
22
+ * theme, not drift)
23
+ * 2. a shadcn install in CSS-variable mode whose theme contract holds,
24
+ * five or more of shadcn's ten rows in any of its stylesheets: shadcn's
25
+ * names
26
+ * 3. a shadcn install with no contract but a v4 theme of its own: that
27
+ * theme's names, as in 1
28
+ * 4. utility-class mode (the palette is the theme, as the report says), or
29
+ * nothing to point at: off
30
+ * paletteFindings words the hits, the same words on every door: the class,
31
+ * the theme file, the theme colour nearest by value that suits the utility,
32
+ * and what to add when none fits.
33
+ */
34
+ import { PALETTE_CLASS_RE, GREY_HUE_RE, DARK_WB_RE, DEMO_PATH_RE, blankComments } from '../harvest/paint.mjs';
35
+ import { TAILWIND_DEFAULTS } from '../profiles/tailwind-defaults.mjs';
36
+ import { oklab } from './color.mjs';
37
+
38
+ // shadcn's names, one per role the utilities want; the sheet always has
39
+ // greys and colours, so no family check
40
+ const SHADCN_NAMES = ['primary', 'foreground', 'muted-foreground', 'background', 'border'];
41
+ // rows of shadcn's contract that make the sheet the vocabulary
42
+ export const CONTRACT_MIN = 5;
43
+ const CSS_FILE_RE = /\.(css|scss|sass|less|styl)$/i;
44
+
45
+ const fromTheme = (t, doors) => ({
46
+ source: 'tailwind', file: t.file, names: t.names, values: t.values ?? {}, retuned: t.retuned ?? [],
47
+ families: t.families ?? null, adopted: t.adopted !== false, doors,
48
+ });
49
+
50
+ /**
51
+ * The vocabulary a palette class is judged against, or null when the rule
52
+ * is off. `readTheme()` reads the repo's own v4 theme on demand (case 3),
53
+ * the same reader the Tailwind profile uses.
54
+ * @returns {{ source, file, names, values, retuned, families, adopted?, doors } | null}
55
+ */
56
+ export function decidePalette(profile, P, readTheme = null) {
57
+ if (P.isTailwind && profile.tailwind) return fromTheme(profile.tailwind, []);
58
+ if (!P.isShadcn || !profile.shadcn) return null;
59
+ const sc = profile.shadcn;
60
+ // utility-class mode: the palette is the theme, and the report scores it so
61
+ if (P.designSystem?.cssVariables === false) return null;
62
+ // the catalogue and kit blocks are the kit's own doors: editing them is the
63
+ // intended use, and the report's tile leaves them out
64
+ const doors = [...(P.uiDirs ?? []), ...(sc.blockFiles ?? [])];
65
+ const rows = Math.max(sc.sheet?.shadcnPresent ?? 0, sc.contract?.rows ?? 0);
66
+ if (rows >= CONTRACT_MIN) {
67
+ return {
68
+ source: 'shadcn', file: sc.sheet?.found ? sc.sheet.file : sc.contract?.file ?? null,
69
+ names: SHADCN_NAMES, values: {}, retuned: [], families: null, doors,
70
+ };
71
+ }
72
+ const own = typeof readTheme === 'function' ? readTheme() : null;
73
+ return own ? fromTheme(own, doors) : null;
74
+ }
75
+
76
+ /**
77
+ * The palette classes in a piece of text that the vocabulary answers, each
78
+ * with the fix in the engine's words. Empty when the rule is off, on a
79
+ * stylesheet, in a demo folder or inside a kit door. `index` is the class's
80
+ * position in `text`; comments are blanked, not cut, so it holds.
81
+ * @returns [{ rule: 'palette-class', severity: 'violation', index, value, themeFile, example, message, fix }]
82
+ */
83
+ export function paletteFindings(text, palette, { file = null, css = null } = {}) {
84
+ if (!palette || !text) return [];
85
+ if (css ?? (file ? CSS_FILE_RE.test(file) : false)) return [];
86
+ if (file && (DEMO_PATH_RE.test(file) || (palette.doors ?? []).some((d) => file === d || file.startsWith(`${d}/`)))) return [];
87
+ const retuned = new Set(palette.retuned ?? []);
88
+ const fam = palette.families ?? null;
89
+ const hasFamily = (cls) => !fam || (GREY_HUE_RE.test(cls) ? fam.grey : fam.colour);
90
+ const themeFile = palette.file ?? 'the theme sheet';
91
+ // the example name follows the utility: a text- class wants an ink or
92
+ // foreground name, a bg- class a surface or background name
93
+ const names = palette.names ?? [];
94
+ const pick = (re) => names.find((n) => re.test(n)) ?? names[0] ?? 'brand';
95
+ const values = palette.values ?? {};
96
+ // a class named in a comment paints nothing; blanked, not cut, so the
97
+ // indexes still point at their line
98
+ const code = blankComments(text);
99
+ const hits = [...code.matchAll(PALETTE_CLASS_RE)];
100
+ // the evening override painted by hand (dark:bg-black) is the same sin on a
101
+ // shadcn sheet, which always has a dark row of its own
102
+ if (palette.source === 'shadcn') hits.push(...code.matchAll(DARK_WB_RE));
103
+ const out = [];
104
+ for (const m of hits.sort((a, b) => a.index - b.index)) {
105
+ const cls = m[0];
106
+ const util = cls.replace(/^((?:[\w-]+:)*[a-z]+)-.*$/, '$1');
107
+ const shade = cls.replace(/^(?:[\w-]+:)*[a-z]+-/, '').replace(/\/\d+$/, '');
108
+ if (retuned.has(shade)) continue;
109
+ if (!hasFamily(cls)) continue;
110
+ const role = utilRole(util);
111
+ // the theme colour nearest by value among the names that suit the
112
+ // utility; the pick by name alone only when none is close
113
+ const example = nearestThemeName(shade, role, values)
114
+ ?? (role === 'text' ? pick(/ink|text|fg|foreground/) : role === 'surface' ? pick(/surface|bg|background|canvas/) : pick(/border|edge|line|ring/));
115
+ const word = hueWord(shade, names);
116
+ out.push({
117
+ rule: 'palette-class', severity: 'violation', index: m.index, value: cls, themeFile, example: `${util}-${example}`,
118
+ message: `Palette class ${cls} where the theme names its colours (${themeFile}).`,
119
+ fix: `Use a theme token as the class (${util}-${example}). ${names.includes(word)
120
+ ? `If no token fits, add ${/^[aeiou]/.test(word) ? 'an' : 'a'} ${word} shade to the theme once and use it by name.`
121
+ : `If no token fits, add one to the theme once, for example ${word}, and use ${util}-${word}.`}`,
122
+ });
123
+ }
124
+ return out;
125
+ }
126
+
127
+ // ---------- the theme colour a palette class stands in for ----------
128
+ // Which theme names suit a utility, read from the name: a text class wants an
129
+ // ink, a bg class a surface, a border class an edge. A name that says none of
130
+ // these (brand, warning, positive) is an accent and suits every utility.
131
+ const TEXT_NAME_RE = /ink|text|fg|foreground/;
132
+ const SURFACE_NAME_RE = /surface|(^|-)bg(-|$)|background|canvas|soft|tint|wash/;
133
+ const EDGE_NAME_RE = /border|edge|line|ring|divider|outline/;
134
+ function utilRole(util) {
135
+ if (/(^|:)(?:text|placeholder|caret|decoration|fill|stroke)$/.test(util)) return 'text';
136
+ if (/(^|:)bg$/.test(util)) return 'surface';
137
+ if (/(^|:)(?:border|ring|outline|divide)$/.test(util)) return 'edge';
138
+ return 'any';
139
+ }
140
+ function suits(name, role) {
141
+ const text = TEXT_NAME_RE.test(name), surface = SURFACE_NAME_RE.test(name), edge = EDGE_NAME_RE.test(name);
142
+ if (role === 'any' || (!text && !surface && !edge)) return true;
143
+ return role === 'text' ? text && !surface && !edge : role === 'surface' ? surface : edge;
144
+ }
145
+ // Distance in OKLab with the two colour axes doubled, so a pale amber lands on
146
+ // the pale warning surface rather than on the near-white canvas beside it.
147
+ const hueDistance = (x, y) => Math.hypot(x.L - y.L, 2 * (x.a - y.a), 2 * (x.b - y.b));
148
+ // Past this, the nearest theme colour is a different colour, not a stand-in
149
+ // (bg-blue-600 against a theme of greys), and the pick by name answers.
150
+ const CLOSE = 0.2;
151
+ /** The suitable theme name nearest to a palette shade's Tailwind value, or null. */
152
+ function nearestThemeName(shade, role, values) {
153
+ const from = oklab(TAILWIND_DEFAULTS[shade] ?? '');
154
+ if (!from) return null;
155
+ let best = null;
156
+ for (const [name, value] of Object.entries(values)) {
157
+ if (!suits(name, role)) continue;
158
+ const to = oklab(value);
159
+ if (!to) continue;
160
+ const d = hueDistance(from, to);
161
+ if (!best || d < best.d) best = { name, d };
162
+ }
163
+ return best && best.d <= CLOSE ? best.name : null;
164
+ }
165
+ // The word for a new token follows the hue, in the theme's own vocabulary when
166
+ // it has one (Ledgerly says positive and negative, not success and danger).
167
+ const HUE_WORDS = [
168
+ [/^(?:red|rose)-/, ['negative', 'danger', 'error', 'destructive']],
169
+ [/^(?:orange|amber|yellow)-/, ['warning', 'caution']],
170
+ [/^(?:green|emerald|lime)-/, ['positive', 'success']],
171
+ [/^(?:blue|sky|cyan)-/, ['info']],
172
+ ];
173
+ function hueWord(shade, names) {
174
+ const words = HUE_WORDS.find(([re]) => re.test(shade))?.[1];
175
+ if (!words) return GREY_HUE_RE.test(`-${shade}`) ? 'muted' : 'accent';
176
+ return words.find((w) => names.some((n) => n === w || n.startsWith(`${w}-`)))
177
+ ?? { negative: 'danger', warning: 'warning', positive: 'success', info: 'info' }[words[0]];
178
+ }
@@ -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.3.2';
4
+ export const VERSION = '9.4.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.
@@ -18,9 +18,7 @@ import { exemptReason, isDrawing, SVG_MARKUP_RE } from '../lib/exempt.mjs';
18
18
  import { extraDeclarations, fontDeclarations } from '../lib/declarations.mjs';
19
19
  import { typefaceOf, GENERIC_FONTS } from '../lib/typefaces.mjs';
20
20
  import { kitPaintFindings } from '../lib/kitpaint.mjs';
21
- import { PALETTE_CLASS_RE, GREY_HUE_RE, DARK_WB_RE, DEMO_PATH_RE, blankComments } from '../harvest/paint.mjs';
22
- import { TAILWIND_DEFAULTS } from '../profiles/tailwind-defaults.mjs';
23
- import { oklab } from '../lib/color.mjs';
21
+ import { paletteFindings } from '../lib/palette.mjs';
24
22
  import { tokenTwinFindings } from '../lib/tokentwins.mjs';
25
23
  import { avoidedImportFindings } from '../lib/avoidedimports.mjs';
26
24
  import { isChartFile, chartFindings } from '../lib/charts.mjs';
@@ -47,7 +45,7 @@ export const KIT_CHECK = 'colours and pixel sizes written onto kit components wh
47
45
  export const PALETTE_CHECK = 'palette classes where the theme names a colour';
48
46
  export const BUTTON_CHECK = 'hand-made buttons where the repo has a Button';
49
47
  export function checksFor(k) {
50
- const list = k?.kit ? [...CHECKS, KIT_CHECK] : (k?.tailwind || k?.shadcn) ? [...CHECKS, PALETTE_CHECK] : [...CHECKS];
48
+ const list = k?.kit ? [...CHECKS, KIT_CHECK] : k?.palette ? [...CHECKS, PALETTE_CHECK] : [...CHECKS];
51
49
  if (k?.buttons?.length) list.push(BUTTON_CHECK);
52
50
  return list;
53
51
  }
@@ -180,57 +178,12 @@ export function validateContent(content, k) {
180
178
  }
181
179
 
182
180
  // ---------- palette classes where the theme names its colours ----------
183
- // A Tailwind theme names its colours (bg-surface, text-ink); a shadcn sheet
184
- // names them too (bg-primary, text-muted-foreground). A palette class
185
- // (text-gray-500, ring-green-500) where a name of that kind exists is paint
186
- // from the tin. Same rule as the report's tile, from harvest/paint.mjs.
187
- // On a shadcn repo the catalogue and kit blocks are the kit's own doors
188
- // (editing them is the intended use) and a demo folder is not own code,
189
- // exactly the files the tile leaves out.
190
- const inShadcnDoors = (f) => !!f && k.shadcn.doors.some((d) => f === d || f.startsWith(`${d}/`));
191
- // A demo folder is left out on a Tailwind theme too: the report's tile never
192
- // counted one, and the live check flagged it (2026-09-30).
193
- const demo = !!file && DEMO_PATH_RE.test(file);
194
- const paletteRule = k.tailwind ? (demo ? null : 'tailwind')
195
- : k.shadcn && !(file && (inShadcnDoors(file) || demo)) ? 'shadcn'
196
- : null;
197
- if (paletteRule && !css) {
198
- const tw = k.tailwind ?? {};
199
- const retuned = new Set(tw.retuned ?? []);
200
- const fam = tw.families ?? null;
201
- const hasFamily = (cls) => !fam || (GREY_HUE_RE.test(cls) ? fam.grey : fam.colour);
202
- const themeFile = paletteRule === 'tailwind' ? tw.file : (k.shadcn.sheet ?? 'the theme sheet');
203
- // the example name follows the utility: a text- class wants an ink or
204
- // foreground name, a bg- class a surface or background name
205
- const names = paletteRule === 'tailwind' ? (tw.names ?? []) : ['primary', 'foreground', 'muted-foreground', 'background', 'border'];
206
- const pick = (re) => names.find((n) => re.test(n)) ?? names[0] ?? 'brand';
207
- const values = paletteRule === 'tailwind' ? tw.values ?? {} : {};
208
- // a class named in a comment paints nothing; blanked, not cut, so the
209
- // line numbers below still hold
210
- const code = blankComments(text);
211
- const hits = [...code.matchAll(PALETTE_CLASS_RE)];
212
- // the evening override painted by hand (dark:bg-black) is the same sin
213
- // on a shadcn sheet, which always has a dark row of its own
214
- if (paletteRule === 'shadcn') hits.push(...code.matchAll(DARK_WB_RE));
215
- for (const m of hits.sort((a, b) => a.index - b.index)) {
216
- const cls = m[0];
217
- const util = cls.replace(/^((?:[\w-]+:)*[a-z]+)-.*$/, '$1');
218
- const shade = cls.replace(/^(?:[\w-]+:)*[a-z]+-/, '').replace(/\/\d+$/, '');
219
- const role = utilRole(util);
220
- // the theme colour nearest by value among the names that suit the
221
- // utility; the old pick by name alone only when none is close
222
- const example = nearestThemeName(shade, role, values)
223
- ?? (role === 'text' ? pick(/ink|text|fg|foreground/) : role === 'surface' ? pick(/surface|bg|background|canvas/) : pick(/border|edge|line|ring/));
224
- if (retuned.has(shade)) continue;
225
- if (!hasFamily(cls)) continue;
226
- const word = hueWord(shade, names);
227
- add('palette-class', 'violation', m.index,
228
- `Palette class ${cls} where the theme names its colours (${themeFile}).`,
229
- `Use a theme token as the class (${util}-${example}). ${names.includes(word)
230
- ? `If no token fits, add ${/^[aeiou]/.test(word) ? 'an' : 'a'} ${word} shade to the theme once and use it by name.`
231
- : `If no token fits, add one to the theme once, for example ${word}, and use ${util}-${word}.`}`);
232
- }
233
- }
181
+ // One rule for every door (lib/palette.mjs): which vocabulary applies, a
182
+ // Tailwind theme, shadcn's sheet or the repo's own theme under a shadcn
183
+ // kit, was decided once by the profile. The catalogue, kit blocks and demo
184
+ // folders are left out there, the same files the report's tile leaves out,
185
+ // and utility-class mode switches the rule off, as the report scores it.
186
+ for (const f of paletteFindings(text, k.palette, { file, css })) add(f.rule, f.severity, f.index, f.message, f.fix);
234
187
 
235
188
  // ---------- colours ----------
236
189
  const seenHere = new Set();
@@ -396,59 +349,6 @@ export function validateContent(content, k) {
396
349
  return { findings, checked: checksFor(k) };
397
350
  }
398
351
 
399
- // ---------- the theme colour a palette class stands in for ----------
400
- // Which theme names suit a utility, read from the name: a text class wants an
401
- // ink, a bg class a surface, a border class an edge. A name that says none of
402
- // these (brand, warning, positive) is an accent and suits every utility.
403
- const TEXT_NAME_RE = /ink|text|fg|foreground/;
404
- const SURFACE_NAME_RE = /surface|(^|-)bg(-|$)|background|canvas|soft|tint|wash/;
405
- const EDGE_NAME_RE = /border|edge|line|ring|divider|outline/;
406
- function utilRole(util) {
407
- if (/(^|:)(?:text|placeholder|caret|decoration|fill|stroke)$/.test(util)) return 'text';
408
- if (/(^|:)bg$/.test(util)) return 'surface';
409
- if (/(^|:)(?:border|ring|outline|divide)$/.test(util)) return 'edge';
410
- return 'any';
411
- }
412
- function suits(name, role) {
413
- const text = TEXT_NAME_RE.test(name), surface = SURFACE_NAME_RE.test(name), edge = EDGE_NAME_RE.test(name);
414
- if (role === 'any' || (!text && !surface && !edge)) return true;
415
- return role === 'text' ? text && !surface && !edge : role === 'surface' ? surface : edge;
416
- }
417
- // Distance in OKLab with the two colour axes doubled, so a pale amber lands on
418
- // the pale warning surface rather than on the near-white canvas beside it.
419
- const hueDistance = (x, y) => Math.hypot(x.L - y.L, 2 * (x.a - y.a), 2 * (x.b - y.b));
420
- // Past this, the nearest theme colour is a different colour, not a stand-in
421
- // (bg-blue-600 against a theme of greys), and the old pick by name answers.
422
- const CLOSE = 0.2;
423
- /** The suitable theme name nearest to a palette shade's Tailwind value, or null. */
424
- function nearestThemeName(shade, role, values) {
425
- const from = oklab(TAILWIND_DEFAULTS[shade] ?? '');
426
- if (!from) return null;
427
- let best = null;
428
- for (const [name, value] of Object.entries(values)) {
429
- if (!suits(name, role)) continue;
430
- const to = oklab(value);
431
- if (!to) continue;
432
- const d = hueDistance(from, to);
433
- if (!best || d < best.d) best = { name, d };
434
- }
435
- return best && best.d <= CLOSE ? best.name : null;
436
- }
437
- // The word for a new token follows the hue, in the theme's own vocabulary when
438
- // it has one (Ledgerly says positive and negative, not success and danger).
439
- const HUE_WORDS = [
440
- [/^(?:red|rose)-/, ['negative', 'danger', 'error', 'destructive']],
441
- [/^(?:orange|amber|yellow)-/, ['warning', 'caution']],
442
- [/^(?:green|emerald|lime)-/, ['positive', 'success']],
443
- [/^(?:blue|sky|cyan)-/, ['info']],
444
- ];
445
- function hueWord(shade, names) {
446
- const words = HUE_WORDS.find(([re]) => re.test(shade))?.[1];
447
- if (!words) return GREY_HUE_RE.test(`-${shade}`) ? 'muted' : 'accent';
448
- return words.find((w) => names.some((n) => n === w || n.startsWith(`${w}-`)))
449
- ?? { negative: 'danger', warning: 'warning', positive: 'success', info: 'info' }[words[0]];
450
- }
451
-
452
352
  function looksLikeCss(text) {
453
353
  // no JSX tags, has a `{ ...: ` declaration shape → treat as stylesheet.
454
354
  // Sniffed on the head of the text with bounded patterns: the old version
@@ -210,6 +210,9 @@ export function loadKnowledge(root) {
210
210
  // colours in: null when the repo is neither (see profiles/)
211
211
  kit,
212
212
  tailwind: P.isTailwind && profile.tailwind ? profile.tailwind : null,
213
+ // the vocabulary the palette rule judges against, decided once by the
214
+ // profile (lib/palette.mjs); null switches the rule off
215
+ palette: P.palette,
213
216
  // A shadcn kitchen: the sheet names the colours, so a palette class in
214
217
  // own code is paint from a tin (shadcn's own rule: semantic colours,
215
218
  // never bg-blue-500). The report's tile counts it over own code plus
@@ -52,7 +52,14 @@ export function getContext(k, { path = null } = {}) {
52
52
  if (kit.colour?.uses) L.push(` ${kit.colour.uses} colours are already written onto components (${kit.colour.samples.slice(0, 3).map((x) => x.value).join(', ')}); do not add one.`);
53
53
  } else if (t.tokenFile) {
54
54
  const strays = t.colors.length - k.tokenColors.length;
55
- L.push(`TOKENS: ${k.tokenColors.length} colour tokens in ${t.tokenFile}. Use them${k.shadcn ? ' as classes (bg-primary, text-muted-foreground); never a palette class (text-gray-500, ring-green-500) and' : ';'} never hardcode a colour. Do not add a token that duplicates an existing one; reuse it.${strays ? ` (${strays} hardcoded strays already exist; do not add more.)` : ''}`);
55
+ // on a shadcn kit the example classes follow the vocabulary the palette
56
+ // rule judges against (lib/palette.mjs): shadcn's names, or the repo's own
57
+ // theme when it keeps one and no shadcn rows; in utility-class mode the
58
+ // palette is the theme and the clause is dropped
59
+ const pal = k.shadcn ? k.palette : null;
60
+ const pick = (re, i) => (pal?.names ?? []).find((n) => re.test(n)) ?? pal?.names?.[i] ?? 'brand';
61
+ const examples = pal?.source === 'tailwind' ? `bg-${pick(/surface|(^|-)bg(-|$)|background|canvas/, 0)}, text-${pick(/ink|text|fg|foreground/, 1)}` : 'bg-primary, text-muted-foreground';
62
+ L.push(`TOKENS: ${k.tokenColors.length} colour tokens in ${t.tokenFile}. Use them${pal ? ` as classes (${examples}); never a palette class (text-gray-500, ring-green-500) and` : ';'} never hardcode a colour. Do not add a token that duplicates an existing one; reuse it.${strays ? ` (${strays} hardcoded strays already exist; do not add more.)` : ''}`);
56
63
  } else if (t.colors.length) {
57
64
  L.push(`TOKENS: none defined. ${t.colors.length} distinct colours already in play; reuse one, never invent another.`);
58
65
  }
@@ -22,7 +22,8 @@ import { join, basename, dirname } from 'node:path';
22
22
  * fixture's expected output is byte-identical before and after.
23
23
  */
24
24
  import shadcn from './shadcn.mjs';
25
- import tailwind from './tailwind.mjs';
25
+ import tailwind, { readTailwindTheme } from './tailwind.mjs';
26
+ import { decidePalette } from '../lib/palette.mjs';
26
27
  import mui from './mui.mjs';
27
28
  import mantine from './mantine.mjs';
28
29
  import chakra from './chakra.mjs';
@@ -82,6 +83,12 @@ export function decideProfile(profile, components, files, root = null) {
82
83
  profile.kind = picked.kind;
83
84
  profile.kindConfidence = decision.confidence;
84
85
  profile.kindEvidence = decision.evidence;
86
+ // Which vocabulary a palette class is judged against, decided once here
87
+ // and read by the live checks and the guard (lib/palette.mjs): a Tailwind
88
+ // theme, shadcn's sheet when its contract holds, the repo's own theme
89
+ // under a shadcn kit with no rows, or nothing. The report's tile keeps
90
+ // its own gates (harvest/index.mjs) and no score reads this.
91
+ profile.palette = decidePalette(profile, profileOf(profile), () => readTailwindTheme({ ...profile }, ctx)?.facts ?? null);
85
92
  // A shadcn repo that PUBLISHES a registry is a registry: the fourth kind.
86
93
  // Every count still reads the shadcn facts (release (a): zero score change);
87
94
  // the kind, the receipt and the header line say what it is.
@@ -154,6 +161,8 @@ export function profileOf(h) {
154
161
  // a Tailwind repo with its own theme and no kit
155
162
  isTailwind: kind === 'tailwind',
156
163
  tailwind: p.tailwind ?? null,
164
+ // the vocabulary the palette rule judges against, or null when it is off
165
+ palette: p.palette ?? null,
157
166
  // a product built on a component kit installed from npm (mui)
158
167
  isKit: !!p.kit,
159
168
  kit: p.kit ?? null,
@@ -197,14 +206,18 @@ export function splitArbitrary(entries, dirs) {
197
206
  const installedValues = new Map();
198
207
  let installedUses = 0;
199
208
  for (const e of entries) {
200
- const ownFiles = (e.files ?? []).filter((f) => !underAny(f.file, dirs));
201
- const instFiles = (e.files ?? []).filter((f) => underAny(f.file, dirs));
202
- const instCount = instFiles.reduce((s, f) => s + f.count, 0);
209
+ // the whole per-file tally when the harvest kept one (every), else the
210
+ // capped list: a use in a file that fell off the list of five was counted
211
+ // as the team's own, so ai-chatbot's installed badge put one of shadcn's
212
+ // [3px] values in the headline (2026-09-30)
213
+ const all = e.every ?? e.files ?? [];
214
+ const ownFiles = all.filter((f) => !underAny(f.file, dirs)).sort((a, b) => b.count - a.count).slice(0, 5);
215
+ const instCount = all.filter((f) => underAny(f.file, dirs)).reduce((s, f) => s + f.count, 0);
203
216
  if (instCount) { installedUses += instCount; installedValues.set(e.value, (installedValues.get(e.value) ?? 0) + instCount); }
204
- // the file list under an entry is capped, so own is the remainder of the
205
- // entry's total, never a sum of the files that happened to be listed
217
+ // own is the remainder of the entry's total, never a sum of the files
218
+ // that happened to be listed
206
219
  const ownCount = e.count - instCount;
207
- if (ownCount > 0) own.push({ ...e, count: ownCount, files: ownFiles });
220
+ if (ownCount > 0) { const { every, ...rest } = e; own.push({ ...rest, count: ownCount, files: ownFiles }); }
208
221
  }
209
222
  own.sort((a, b) => b.count - a.count);
210
223
  const values = [...installedValues.entries()].sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
@@ -328,6 +328,12 @@ export default {
328
328
  if (registryDirs.length) evidence.push(`${registryDirs.length} installed registr${registryDirs.length === 1 ? 'y' : 'ies'} beside it: ${registryDirs.map((d) => basename(d)).join(', ')}`);
329
329
  profile.shadcn = {
330
330
  installs: installs.map((i) => ({ config: i.config, uiDir: i.uiDir, catalogueNames: i.catalogueNames })),
331
+ // the theme contract: how many of shadcn's rows any stylesheet defines,
332
+ // and the file carrying most of them. The configured sheet can hold
333
+ // none under :root (formbricks writes its theme straight into @theme,
334
+ // a sibling package keeps the rows) while the contract still holds;
335
+ // the palette rule reads this, not the sheet alone (lib/palette.mjs)
336
+ contract: { rows: contract, file: themeContract.file ?? null },
331
337
  kit,
332
338
  sheet,
333
339
  blockFiles,
@@ -118,15 +118,16 @@ function countUses(root, codeFiles, styleFiles, own) {
118
118
  return { uses, files };
119
119
  }
120
120
 
121
- export default {
122
- kind: 'tailwind',
123
-
124
- /**
125
- * @param profile the profiler's facts (mutated: designSystem, tailwind)
126
- * @param counts reusable/pages/codeFiles
127
- * @param ctx { root, files }
128
- */
129
- recognise(profile, counts, ctx) {
121
+ /**
122
+ * The repo's own v4 theme, read without deciding anything: the facts the
123
+ * profile stores as `tailwind` and the evidence for the decision, or null
124
+ * when there is no theme worth judging. Exported so the palette rule can
125
+ * read the theme of a repo another kind claimed (a shadcn install with no
126
+ * shadcn rows but a theme of its own, lib/palette.mjs). Reads `profile`,
127
+ * never writes it.
128
+ * @returns {{ facts, decision } | null}
129
+ */
130
+ export function readTailwindTheme(profile, ctx) {
130
131
  if (!ctx?.root || !ctx?.files) return null;
131
132
  const { root, files } = ctx;
132
133
  const twRaw = profile.stylingDeps?.includes('Tailwind CSS') || /tailwind/i.test((profile.stylingDeps ?? []).join(' '))
@@ -158,8 +159,7 @@ export default {
158
159
  twRaw ? `Tailwind ${twRaw}` : 'Tailwind v4 theme block',
159
160
  ];
160
161
  const values = resolveValues(root, files.styles, theme.own.map((x) => [x, theme.names.get(x)]));
161
- profile.designSystem = { kind: 'tailwind', name: 'a Tailwind theme', confidence: 'high', cssVariables: true };
162
- profile.tailwind = {
162
+ const facts = {
163
163
  file: theme.file,
164
164
  names: theme.own,
165
165
  restated: theme.names.size - theme.own.length,
@@ -175,6 +175,22 @@ export default {
175
175
  // a theme nobody uses is not the system yet: shown with receipts, not scored
176
176
  adopted: usedEnough(uses, theme.own.length),
177
177
  };
178
- return { confidence: usedEnough(uses, theme.own.length) ? 'high' : 'medium', evidence };
178
+ return { facts, decision: { confidence: usedEnough(uses, theme.own.length) ? 'high' : 'medium', evidence } };
179
+ }
180
+
181
+ export default {
182
+ kind: 'tailwind',
183
+
184
+ /**
185
+ * @param profile the profiler's facts (mutated: designSystem, tailwind)
186
+ * @param counts reusable/pages/codeFiles
187
+ * @param ctx { root, files }
188
+ */
189
+ recognise(profile, counts, ctx) {
190
+ const read = readTailwindTheme(profile, ctx);
191
+ if (!read) return null;
192
+ profile.designSystem = { kind: 'tailwind', name: 'a Tailwind theme', confidence: 'high', cssVariables: true };
193
+ profile.tailwind = read.facts;
194
+ return read.decision;
179
195
  },
180
196
  };