roast-my-design-system 9.3.3 → 9.5.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
@@ -142,11 +142,11 @@ Eleven reports, hosted exactly as the tool writes them. Every number determinist
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.3.3)
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.5.0)
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.3.3)
149
+ ![The diagnosis report in light mode](assets/report-light-hero.png?v=9.5.0)
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.3",
3
+ "version": "9.5.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": [
@@ -32,7 +32,7 @@ import { fixPrompt } from '../lib/fixprompt.mjs';
32
32
  import { WHY } from './why.mjs';
33
33
  import { parseColor, luminance, isGrey } from '../lib/color.mjs';
34
34
  import { loadBenchmark, benchHelpers, makeHealthOf, coreMetrics, tileHealths, scoreOfTiles, scoreBreakdown, scorePackage as scorePackageOf, ZERO_IDEAL, WARN_TOLERANCE, SCORE_OF, SCHEMA_VERSION } from './score.mjs';
35
- import { ownSpacing, profileOf, installedDirs, splitArbitrary } from '../profiles/index.mjs';
35
+ import { ownSpacing, profileOf, installedOf, splitArbitrary } from '../profiles/index.mjs';
36
36
  import { KITS } from '../profiles/kit-common.mjs';
37
37
  import { rulesOn as lintRulesOn, describeRule as lintDescribe, entryMatcher as lintMatcher, RULE_TILE as LINT_TILE } from '../profiles/shadcn-lint.mjs';
38
38
  import { publishesLine } from '../profiles/registry.mjs';
@@ -257,7 +257,7 @@ const P = profileOf(h);
257
257
  const fresh = P.isShadcn && P.shadcn?.fresh?.fresh === true;
258
258
  // On a shadcn repo the bracket list is the team's own; installed brackets are
259
259
  // named in the side panel instead (see coreMetrics for the count).
260
- if (P.isShadcn) arbitrary = splitArbitrary(arbitrary, installedDirs(P)).own;
260
+ if (P.isShadcn) arbitrary = splitArbitrary(arbitrary, installedOf(P)).own;
261
261
  const breakdown = scoreBreakdown(h, bench);
262
262
  const neverImported = neverImportedComponents(h.components, P.uiDir);
263
263
 
@@ -26,7 +26,7 @@ import { isGrey } from '../lib/color.mjs';
26
26
  import { nearColorPairs } from '../lib/nearpairs.mjs';
27
27
  import { neverImportedComponents } from '../lib/neverimported.mjs';
28
28
  import { SCHEMA_VERSION } from '../lib/version.mjs';
29
- import { profileOf, installedDirs, splitArbitrary, ownSpacing } from '../profiles/index.mjs';
29
+ import { profileOf, installedOf, splitArbitrary, ownSpacing } from '../profiles/index.mjs';
30
30
 
31
31
  export { SCHEMA_VERSION };
32
32
 
@@ -146,7 +146,7 @@ export function coreMetrics(h, opts = {}) {
146
146
  const P = profileOf(h);
147
147
  // On a shadcn repo the bracket count is the team's own: brackets inside the
148
148
  // catalogue, kit blocks and registries are installed choices, not drift.
149
- const arbitraryEntries = P.isShadcn ? splitArbitrary(h.tokens?.tailwind?.arbitrary ?? [], installedDirs(P)).own : (h.tokens?.tailwind?.arbitrary ?? []);
149
+ const arbitraryEntries = P.isShadcn ? splitArbitrary(h.tokens?.tailwind?.arbitrary ?? [], installedOf(P)).own : (h.tokens?.tailwind?.arbitrary ?? []);
150
150
  // ownCode: the breakdown under the score. Same metrics, paint counted on
151
151
  // own code only, so the difference is what installed registries cost.
152
152
  const paint = P.isTailwind ? P.tailwind?.paint
@@ -29,9 +29,9 @@ import { ruleStaleness } from '../lib/staleness.mjs';
29
29
  import { neverImportedComponents } from '../lib/neverimported.mjs';
30
30
  import { lastTouchedDates } from '../lib/lasttouched.mjs';
31
31
  import { SCHEMA_VERSION } from '../lib/version.mjs';
32
- import { decideProfile, decideFresh, profileOf, installedDirs, splitArbitrary, scopeFiles } from '../profiles/index.mjs';
32
+ import { decideProfile, decideFresh, profileOf, installedOf, doorFile, installedFile, splitArbitrary, scopeFiles } from '../profiles/index.mjs';
33
33
  import { publishesLine } from '../profiles/registry.mjs';
34
- import { countPaint } from './paint.mjs';
34
+ import { countPaint, doorShades } from './paint.mjs';
35
35
 
36
36
  function arg(name, fallback) {
37
37
  const i = process.argv.indexOf(`--${name}`);
@@ -120,17 +120,18 @@ if (profileOf(profile).isRegistry) {
120
120
  const P = profileOf(profile);
121
121
  if (P.isShadcn) {
122
122
  const regDirs = profile.shadcn.registryDirs ?? [];
123
- const blocks = profile.shadcn.blockFiles ?? [];
124
- const allInstalled = installedDirs(P);
125
- const doorFiles = new Set(allInstalled.flatMap((d) => files.code.filter((f) => f === d || f.startsWith(`${d}/`))));
123
+ // installed code, file by file (profiles/installed.mjs): a component of
124
+ // the team's own kept in the catalogue folder is own code (9.5.0)
125
+ const installed = installedOf(P);
126
+ const doorFiles = new Set(files.code.filter(installed));
126
127
  const kitNames = new Set(components.filter((c) => doorFiles.has(c.file)).map((c) => c.name));
127
128
  // The score reads the repo as the agent meets it: own code plus installed
128
129
  // registries, because a palette colour in ai-elements teaches the same
129
- // wrong lesson as one in own code. The catalogue and kit blocks stay out:
130
- // editing them is the intended use.
131
- profile.shadcn.paint = countPaint(target, files.code, { uiDirs: [...P.uiDirs, ...blocks], kitNames, email });
130
+ // wrong lesson as one in own code. shadcn's own components and kit blocks
131
+ // stay out: editing them is the intended use.
132
+ profile.shadcn.paint = countPaint(target, files.code, { isDoor: (f) => doorFile(P, f), kitNames, email });
132
133
  // Own code alone, for the breakdown line under the score.
133
- profile.shadcn.paintOwn = regDirs.length ? countPaint(target, files.code, { uiDirs: allInstalled, kitNames, email }) : profile.shadcn.paint;
134
+ profile.shadcn.paintOwn = regDirs.length ? countPaint(target, files.code, { isDoor: installed, kitNames, email }) : profile.shadcn.paint;
134
135
  if (regDirs.length) {
135
136
  const regFiles = files.code.filter((f) => regDirs.some((d) => f.startsWith(`${d}/`)));
136
137
  const rp = countPaint(target, regFiles, { uiDirs: [], kitNames, email });
@@ -138,7 +139,18 @@ if (profileOf(profile).isRegistry) {
138
139
  }
139
140
  // Bracket values inside installed code are shadcn's (or a registry's)
140
141
  // choices: a true lesson, badly framed. Kept out of the count, named.
141
- const split = splitArbitrary(tokens.tailwind?.arbitrary ?? [], allInstalled);
142
+ // Utility-class mode (9.5.0), for the rules file only: the shades shadcn's
143
+ // own components paint with, and, where the repo keeps a theme of its
144
+ // own, the palette classes in own code that theme does not own.
145
+ if (P.designSystem?.cssVariables === false) {
146
+ profile.shadcn.doorShades = doorShades(target, files.code.filter((f) => /\.(tsx|jsx)$/.test(f) && doorFile(P, f)));
147
+ const own = profile.shadcn.ownTheme;
148
+ if (own) {
149
+ const p = countPaint(target, files.code, { isDoor: installed, retuned: own.retuned, families: own.families, email });
150
+ profile.shadcn.ownThemePaint = { uses: p.tin.uses, samples: p.tin.samples.slice(0, 6) };
151
+ }
152
+ }
153
+ const split = splitArbitrary(tokens.tailwind?.arbitrary ?? [], installed);
142
154
  profile.shadcn.arbitraryInstalled = split.installed;
143
155
  // A fresh kit (the install command's output, nothing built yet) is
144
156
  // decided here, once, from the facts above.
@@ -162,8 +174,8 @@ const context = harvestContext(target);
162
174
  // The chart palette the repo keeps, or the charts painting by hand without
163
175
  // one, and the gaps an agent will fill by inventing (lib/charts, lib/gaps).
164
176
  const charts = (() => {
165
- const installed = installedDirs(profileOf(profile));
166
- const own = (f) => !installed.some((d) => f === d || f.startsWith(`${d}/`));
177
+ const P = profileOf(profile);
178
+ const own = (f) => !installedFile(P, f);
167
179
  return chartSystemOf(files, (f) => readSource(resolve(target, f)), { own });
168
180
  })();
169
181
  const gaps = designGaps({ charts, tokenFile: tokens.tokenFile ?? null });
@@ -60,11 +60,13 @@ export const DEMO_PATH_RE = /(^|\/)(stories|storybook|__stories__|examples?|demo
60
60
  /**
61
61
  * @param root repo root
62
62
  * @param codeFiles relative code paths from the walk
63
- * @param opts { uiDirs: string[], kitNames: Set<string> } — catalogue folders
63
+ * @param opts { uiDirs: string[], kitNames: Set<string> } — catalogue folders;
64
+ * `isDoor(file)` in place of uiDirs decides file by file (9.5.0: a team
65
+ * component kept in the catalogue folder is own code, profiles/installed.mjs)
64
66
  * and the component names defined in them
65
67
  */
66
- export function countPaint(root, codeFiles, { uiDirs = [], kitNames = new Set(), retuned = [], families = null, email = null } = {}) {
67
- const inCatalogue = (f) => uiDirs.some((d) => f === d || f.startsWith(`${d}/`));
68
+ export function countPaint(root, codeFiles, { uiDirs = [], isDoor = null, kitNames = new Set(), retuned = [], families = null, email = null } = {}) {
69
+ const inCatalogue = isDoor ?? ((f) => uiDirs.some((d) => f === d || f.startsWith(`${d}/`)));
68
70
  const names = [...kitNames].filter((n) => /^[A-Z][A-Za-z0-9]*$/.test(n));
69
71
  const doorOpen = names.length
70
72
  ? new RegExp(`<(${names.join('|')})\\b[^>]*?className=(?:"([^"]*)"|\\{cn\\(\\s*["'\`]([^"'\`]*)["'\`])`, 'g')
@@ -141,3 +143,22 @@ export function countPaint(root, codeFiles, { uiDirs = [], kitNames = new Set(),
141
143
  });
142
144
  return { ownFiles, tin: finish(tin), doors: finish(doors), typo: finish(typo) };
143
145
  }
146
+
147
+ /**
148
+ * The palette shades shadcn's own components paint with, most used first,
149
+ * with variant prefixes and opacity dropped (hover:bg-zinc-900/90 is
150
+ * bg-zinc-900). On a utility-class install these are the system: the rules
151
+ * file tells the agent to stay with them (9.5.0).
152
+ */
153
+ export function doorShades(root, doorFiles) {
154
+ const tally = new Map();
155
+ for (const f of doorFiles) {
156
+ const raw = readSource(join(root, f));
157
+ if (raw === null) continue;
158
+ for (const m of blankComments(raw).matchAll(PALETTE_CLASS_RE)) {
159
+ const cls = m[0].replace(/^(?:[\w-]+:)*/, '').replace(/\/\d+$/, '');
160
+ tally.set(cls, (tally.get(cls) ?? 0) + 1);
161
+ }
162
+ }
163
+ return [...tally.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 8).map(([value, count]) => ({ value, count }));
164
+ }
@@ -14,9 +14,10 @@ import { walkRepo, readSource, profileRepo } from '../harvest/walk.mjs';
14
14
  import { chartSystemOf } from './charts.mjs';
15
15
  import { designGaps } from './gaps.mjs';
16
16
  export { designGaps } from './gaps.mjs';
17
- import { decideProfile, profileOf, installedDirs } from '../profiles/index.mjs';
17
+ import { decideProfile, profileOf, installedDirs, installedFile, installedFrom } 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,19 @@ 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 };
79
+ // Installed code, file by file (9.5.0, profiles/installed.mjs): a guard
80
+ // leaves installedFile(system.profile.installedFrom, file, under) alone and
81
+ // judges everything else, including the team's own components kept in the
82
+ // shadcn catalogue folder. isShadcnFile(file) is the name test on its own.
83
+ export { installedFile, doorFile, isShadcnFile } from '../profiles/installed.mjs';
70
84
  // A product built on a kit (MUI, Mantine, Chakra UI, Ant Design), 8.4.6: a
71
85
  // colour or a pixel size written onto a kit component where the theme has a
72
86
  // value. kitPaintFindings(fileText, system.profile.kit, { file }) returns the
@@ -135,8 +149,7 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
135
149
  const read = (f) => readSource(join(repoRoot, f));
136
150
  const hardDupes = (findDuplicates(ledger, profile.uiDir, repoRoot, profile.uiDirs ?? null).exactDuplicates ?? []).filter((d) => !d.wrapped);
137
151
  const P = profileOf(profile);
138
- const installedNow = installedDirs(P);
139
- const charts = chartSystemOf(files, read, { own: (f) => !installedNow.some((d) => f === d || f.startsWith(`${d}/`)) });
152
+ const charts = chartSystemOf(files, read, { own: (f) => !installedFile(P, f) });
140
153
  // the kit the product is built on, with its definition attached, the way
141
154
  // the MCP knowledge reads it (mcp/knowledge.mjs): the theme's colours are
142
155
  // the token set on a kit repo, whatever stylesheet holds the most literals
@@ -216,17 +229,27 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
216
229
  profile: {
217
230
  kind: P.kind,
218
231
  role: P.role,
219
- // folders the team did not write (a shadcn catalogue, installed
220
- // registries, kit blocks): their brackets and copies are not the PR's
221
- // sin. Empty on a registry: what it publishes is its own work.
222
- // only a shadcn kit has installed code; a hand-written components/ui on a
223
- // plain product is the team's own (the report splits nothing there either)
232
+ // folders that hold installed code (a shadcn catalogue, installed
233
+ // registries, kit blocks). Empty on a registry: what it publishes is
234
+ // its own work. Only a shadcn kit has installed code; a hand-written
235
+ // components/ui on a plain product is the team's own.
224
236
  installedDirs: P.isShadcn && !P.isRegistry ? installedDirs(P) : [],
237
+ // Which files in those folders are installed, file by file (9.5.0): a
238
+ // component of the team's own kept in the catalogue folder is the
239
+ // team's, and judged. installedFile(profile.installedFrom, file, under)
240
+ // answers; `under` is how the caller matches a file to a folder.
241
+ installedFrom: installedFrom(P),
225
242
  // a product built on a kit: name, theme files and values, spacing step,
226
243
  // the team's own layers over it, and the definition kitPaintFindings reads
227
244
  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
245
+ // the vocabulary the palette rule judges against, or null when it is
246
+ // off: a Tailwind theme's own names, shadcn's names when the sheet's
247
+ // contract holds in CSS-variable mode, or the repo's own theme under a
248
+ // shadcn kit with no rows (lib/palette.mjs, 9.4.0). Hand it to
249
+ // paletteFindings.
250
+ palette: P.palette,
251
+ // superseded by `palette` in 9.4.0; kept one release for readers of the
252
+ // old shape. The old gate read the configured sheet's :root rows alone.
230
253
  paletteReady: P.isShadcn && (P.shadcn?.sheet?.shadcnPresent ?? 0) >= 5 && P.designSystem?.cssVariables !== false,
231
254
  sheetFile: P.shadcn?.sheet?.found ? P.shadcn.sheet.file : null,
232
255
  // packages whose Tailwind config scopes utilities under an id: widgets
@@ -0,0 +1,195 @@
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
+ import { doorFile } from '../profiles/installed.mjs';
38
+
39
+ // shadcn's names, one per role the utilities want; the sheet always has
40
+ // greys and colours, so no family check
41
+ const SHADCN_NAMES = ['primary', 'foreground', 'muted-foreground', 'background', 'border'];
42
+ // rows of shadcn's contract that make the sheet the vocabulary
43
+ export const CONTRACT_MIN = 5;
44
+ const CSS_FILE_RE = /\.(css|scss|sass|less|styl)$/i;
45
+
46
+ const fromTheme = (t, doors) => ({
47
+ source: 'tailwind', file: t.file, names: t.names, values: t.values ?? {}, retuned: t.retuned ?? [],
48
+ families: t.families ?? null, adopted: t.adopted !== false, doors,
49
+ });
50
+
51
+ /**
52
+ * The vocabulary a palette class is judged against, or null when the rule
53
+ * is off. `readTheme()` reads the repo's own v4 theme on demand (case 3),
54
+ * the same reader the Tailwind profile uses.
55
+ * @returns {{ source, file, names, values, retuned, families, adopted?, doors } | null}
56
+ */
57
+ export function decidePalette(profile, P, readTheme = null) {
58
+ if (P.isTailwind && profile.tailwind) return fromTheme(profile.tailwind, []);
59
+ if (!P.isShadcn || !profile.shadcn) return null;
60
+ const sc = profile.shadcn;
61
+ // utility-class mode: the palette is the theme, and the report scores it so
62
+ if (P.designSystem?.cssVariables === false) return null;
63
+ // shadcn's own components and kit blocks are the kit's doors: editing them
64
+ // is the intended use, and the report's tile leaves them out. A component
65
+ // of the team's own kept in the catalogue folder is not a door (9.5.0).
66
+ const doors = { uiDirs: [...(P.uiDirs ?? [])], shadcn: { blockFiles: [...(sc.blockFiles ?? [])] } };
67
+ const rows = Math.max(sc.sheet?.shadcnPresent ?? 0, sc.contract?.rows ?? 0);
68
+ if (rows >= CONTRACT_MIN) {
69
+ return {
70
+ source: 'shadcn', file: sc.sheet?.found ? sc.sheet.file : sc.contract?.file ?? null,
71
+ names: SHADCN_NAMES, values: {}, retuned: [], families: null, doors,
72
+ };
73
+ }
74
+ const own = typeof readTheme === 'function' ? readTheme() : null;
75
+ return own ? fromTheme(own, doors) : null;
76
+ }
77
+
78
+ /**
79
+ * The palette classes in a piece of text that the vocabulary answers, each
80
+ * with the fix in the engine's words. Empty when the rule is off, on a
81
+ * stylesheet, in a demo folder or inside a kit door. `index` is the class's
82
+ * position in `text`; comments are blanked, not cut, so it holds.
83
+ * @returns [{ rule: 'palette-class', severity: 'violation', index, value, themeFile, example, message, fix }]
84
+ */
85
+ export function paletteFindings(text, palette, { file = null, css = null } = {}) {
86
+ if (!palette || !text) return [];
87
+ if (css ?? (file ? CSS_FILE_RE.test(file) : false)) return [];
88
+ if (file && (DEMO_PATH_RE.test(file) || doorFile(palette.doors, file))) return [];
89
+ const retuned = new Set(palette.retuned ?? []);
90
+ const fam = palette.families ?? null;
91
+ const hasFamily = (cls) => !fam || (GREY_HUE_RE.test(cls) ? fam.grey : fam.colour);
92
+ const themeFile = palette.file ?? 'the theme sheet';
93
+ // the example name follows the utility: a text- class wants an ink or
94
+ // foreground name, a bg- class a surface or background name
95
+ const names = palette.names ?? [];
96
+ const pick = (re) => names.find((n) => re.test(n)) ?? names[0] ?? 'brand';
97
+ const values = palette.values ?? {};
98
+ // a class named in a comment paints nothing; blanked, not cut, so the
99
+ // indexes still point at their line
100
+ const code = blankComments(text);
101
+ const hits = [...code.matchAll(PALETTE_CLASS_RE)];
102
+ // the evening override painted by hand (dark:bg-black) is the same sin on a
103
+ // shadcn sheet, which always has a dark row of its own
104
+ if (palette.source === 'shadcn') hits.push(...code.matchAll(DARK_WB_RE));
105
+ const out = [];
106
+ for (const m of hits.sort((a, b) => a.index - b.index)) {
107
+ const cls = m[0];
108
+ const util = cls.replace(/^((?:[\w-]+:)*[a-z]+)-.*$/, '$1');
109
+ const shade = cls.replace(/^(?:[\w-]+:)*[a-z]+-/, '').replace(/\/\d+$/, '');
110
+ if (retuned.has(shade)) continue;
111
+ if (!hasFamily(cls)) continue;
112
+ const role = utilRole(util);
113
+ // the theme colour nearest by value among the names that suit the
114
+ // utility; the pick by name alone only when none is close
115
+ const example = nearestThemeName(shade, role, values)
116
+ ?? (role === 'text' ? pick(/ink|text|fg|foreground/) : role === 'surface' ? pick(/surface|bg|background|canvas/) : pick(/border|edge|line|ring/));
117
+ const word = hueWord(shade, names);
118
+ out.push({
119
+ rule: 'palette-class', severity: 'violation', index: m.index, value: cls, themeFile, example: `${util}-${example}`,
120
+ message: `Palette class ${cls} where the theme names its colours (${themeFile}).`,
121
+ fix: `Use a theme token as the class (${util}-${example}). ${names.includes(word)
122
+ ? `If no token fits, add ${/^[aeiou]/.test(word) ? 'an' : 'a'} ${word} shade to the theme once and use it by name.`
123
+ : `If no token fits, add one to the theme once, for example ${word}, and use ${util}-${word}.`}`,
124
+ });
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /**
130
+ * The theme class that stands in for a palette class, or null: the theme's
131
+ * own word for the hue when it has one (text-red-500 on a theme naming
132
+ * destructive is text-destructive), else the suitable theme colour nearest
133
+ * by value. For the rules file's example (9.5.0).
134
+ */
135
+ export function themeClassFor(cls, names = [], values = {}) {
136
+ const util = cls.replace(/^((?:[\w-]+:)*[a-z]+)-.*$/, '$1').replace(/^(?:[\w-]+:)*/, '');
137
+ const shade = cls.replace(/^(?:[\w-]+:)*[a-z]+-/, '').replace(/\/\d+$/, '');
138
+ const word = hueWord(shade, names);
139
+ if (names.includes(word)) return `${util}-${word}`;
140
+ const near = nearestThemeName(shade, utilRole(util), values);
141
+ return near ? `${util}-${near}` : null;
142
+ }
143
+
144
+ // ---------- the theme colour a palette class stands in for ----------
145
+ // Which theme names suit a utility, read from the name: a text class wants an
146
+ // ink, a bg class a surface, a border class an edge. A name that says none of
147
+ // these (brand, warning, positive) is an accent and suits every utility.
148
+ const TEXT_NAME_RE = /ink|text|fg|foreground/;
149
+ const SURFACE_NAME_RE = /surface|(^|-)bg(-|$)|background|canvas|soft|tint|wash/;
150
+ const EDGE_NAME_RE = /border|edge|line|ring|divider|outline/;
151
+ function utilRole(util) {
152
+ if (/(^|:)(?:text|placeholder|caret|decoration|fill|stroke)$/.test(util)) return 'text';
153
+ if (/(^|:)bg$/.test(util)) return 'surface';
154
+ if (/(^|:)(?:border|ring|outline|divide)$/.test(util)) return 'edge';
155
+ return 'any';
156
+ }
157
+ function suits(name, role) {
158
+ const text = TEXT_NAME_RE.test(name), surface = SURFACE_NAME_RE.test(name), edge = EDGE_NAME_RE.test(name);
159
+ if (role === 'any' || (!text && !surface && !edge)) return true;
160
+ return role === 'text' ? text && !surface && !edge : role === 'surface' ? surface : edge;
161
+ }
162
+ // Distance in OKLab with the two colour axes doubled, so a pale amber lands on
163
+ // the pale warning surface rather than on the near-white canvas beside it.
164
+ const hueDistance = (x, y) => Math.hypot(x.L - y.L, 2 * (x.a - y.a), 2 * (x.b - y.b));
165
+ // Past this, the nearest theme colour is a different colour, not a stand-in
166
+ // (bg-blue-600 against a theme of greys), and the pick by name answers.
167
+ const CLOSE = 0.2;
168
+ /** The suitable theme name nearest to a palette shade's Tailwind value, or null. */
169
+ function nearestThemeName(shade, role, values) {
170
+ const from = oklab(TAILWIND_DEFAULTS[shade] ?? '');
171
+ if (!from) return null;
172
+ let best = null;
173
+ for (const [name, value] of Object.entries(values)) {
174
+ if (!suits(name, role)) continue;
175
+ const to = oklab(value);
176
+ if (!to) continue;
177
+ const d = hueDistance(from, to);
178
+ if (!best || d < best.d) best = { name, d };
179
+ }
180
+ return best && best.d <= CLOSE ? best.name : null;
181
+ }
182
+ // The word for a new token follows the hue, in the theme's own vocabulary when
183
+ // it has one (Ledgerly says positive and negative, not success and danger).
184
+ const HUE_WORDS = [
185
+ [/^(?:red|rose)-/, ['negative', 'danger', 'error', 'destructive']],
186
+ [/^(?:orange|amber|yellow)-/, ['warning', 'caution']],
187
+ [/^(?:green|emerald|lime)-/, ['positive', 'success']],
188
+ [/^(?:blue|sky|cyan)-/, ['info']],
189
+ ];
190
+ function hueWord(shade, names) {
191
+ const words = HUE_WORDS.find(([re]) => re.test(shade))?.[1];
192
+ if (!words) return GREY_HUE_RE.test(`-${shade}`) ? 'muted' : 'accent';
193
+ return words.find((w) => names.some((n) => n === w || n.startsWith(`${w}-`)))
194
+ ?? { negative: 'danger', warning: 'warning', positive: 'success', info: 'info' }[words[0]];
195
+ }
@@ -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.3';
4
+ export const VERSION = '9.5.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,8 @@ 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 { installedFile } from '../profiles/installed.mjs';
22
+ import { paletteFindings } from '../lib/palette.mjs';
24
23
  import { tokenTwinFindings } from '../lib/tokentwins.mjs';
25
24
  import { avoidedImportFindings } from '../lib/avoidedimports.mjs';
26
25
  import { isChartFile, chartFindings } from '../lib/charts.mjs';
@@ -47,7 +46,7 @@ export const KIT_CHECK = 'colours and pixel sizes written onto kit components wh
47
46
  export const PALETTE_CHECK = 'palette classes where the theme names a colour';
48
47
  export const BUTTON_CHECK = 'hand-made buttons where the repo has a Button';
49
48
  export function checksFor(k) {
50
- const list = k?.kit ? [...CHECKS, KIT_CHECK] : (k?.tailwind || k?.shadcn) ? [...CHECKS, PALETTE_CHECK] : [...CHECKS];
49
+ const list = k?.kit ? [...CHECKS, KIT_CHECK] : k?.palette ? [...CHECKS, PALETTE_CHECK] : [...CHECKS];
51
50
  if (k?.buttons?.length) list.push(BUTTON_CHECK);
52
51
  return list;
53
52
  }
@@ -180,57 +179,12 @@ export function validateContent(content, k) {
180
179
  }
181
180
 
182
181
  // ---------- 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
- }
182
+ // One rule for every door (lib/palette.mjs): which vocabulary applies, a
183
+ // Tailwind theme, shadcn's sheet or the repo's own theme under a shadcn
184
+ // kit, was decided once by the profile. The catalogue, kit blocks and demo
185
+ // folders are left out there, the same files the report's tile leaves out,
186
+ // and utility-class mode switches the rule off, as the report scores it.
187
+ for (const f of paletteFindings(text, k.palette, { file, css })) add(f.rule, f.severity, f.index, f.message, f.fix);
234
188
 
235
189
  // ---------- colours ----------
236
190
  const seenHere = new Set();
@@ -293,10 +247,12 @@ export function validateContent(content, k) {
293
247
  // a raw value the repo already uses, in a non-Tailwind repo, is consistency,
294
248
  // not a new offence — silence
295
249
  }
296
- // Bracket values inside installed code (the shadcn catalogue, kit blocks,
297
- // registries) are the kit's own choices. The report names them and keeps
298
- // them out of the count; a file under review is judged by the same line.
299
- const installed = !!file && (k.installedDirs ?? []).some((d) => file === d || file.startsWith(`${d}/`));
250
+ // Bracket values inside installed code (shadcn's own components, kit
251
+ // blocks, registries) are the kit's own choices. The report names them and
252
+ // keeps them out of the count; a file under review is judged by the same
253
+ // line, file by file: a component of the team's own kept in the catalogue
254
+ // folder is the team's (profiles/installed.mjs, 9.5.0).
255
+ const installed = !!file && installedFile(k.installedFrom, file);
300
256
  const localArb = new Map();
301
257
  for (const a of got.arbitrary) localArb.set(a.value, (localArb.get(a.value) ?? 0) + 1);
302
258
  for (const a of installed ? [] : got.arbitrary) {
@@ -396,59 +352,6 @@ export function validateContent(content, k) {
396
352
  return { findings, checked: checksFor(k) };
397
353
  }
398
354
 
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
355
  function looksLikeCss(text) {
453
356
  // no JSX tags, has a `{ ...: ` declaration shape → treat as stylesheet.
454
357
  // Sniffed on the head of the text with bounded patterns: the old version
@@ -24,7 +24,7 @@ import { tsconfigAliases } from '../harvest/components.mjs';
24
24
  import { neverImportedComponents } from '../lib/neverimported.mjs';
25
25
  import { typefaceOf } from '../lib/typefaces.mjs';
26
26
  import { hexRgb } from '../lib/nearpairs.mjs';
27
- import { decideProfile, profileOf, installedDirs } from '../profiles/index.mjs';
27
+ import { decideProfile, profileOf, installedDirs, installedFile, installedFrom } from '../profiles/index.mjs';
28
28
  import { KITS } from '../profiles/kit-common.mjs';
29
29
  import { repoTokenDefs } from '../lib/tokentwins.mjs';
30
30
  import { dupeCopiesOf } from '../lib/avoidedimports.mjs';
@@ -159,7 +159,7 @@ export function loadKnowledge(root) {
159
159
  // without one (lib/charts.mjs)
160
160
  // installed kit code is the kit's own door, never a precedent (profiles/index)
161
161
  const installed = installedDirs(P);
162
- const ownCode = (f) => !installed.some((d) => f === d || f.startsWith(`${d}/`));
162
+ const ownCode = (f) => !installedFile(P, f);
163
163
  const charts = chartSystemOf(files, read, { own: ownCode });
164
164
  const gaps = designGaps({ charts, tokenFile: tokens.tokenFile ?? null });
165
165
 
@@ -206,10 +206,16 @@ export function loadKnowledge(root) {
206
206
  // draws (lib/guard-api.mjs): only a shadcn kit has installed code, and a
207
207
  // registry's published folders are its own work
208
208
  installedDirs: P.isShadcn && !P.isRegistry ? installed : [],
209
+ // the facts installedFile reads (profiles/installed.mjs): a component of
210
+ // the team's own kept in the catalogue folder is judged like own code
211
+ installedFrom: installedFrom(P),
209
212
  // the kit the product is built on, and the Tailwind theme it names its
210
213
  // colours in: null when the repo is neither (see profiles/)
211
214
  kit,
212
215
  tailwind: P.isTailwind && profile.tailwind ? profile.tailwind : null,
216
+ // the vocabulary the palette rule judges against, decided once by the
217
+ // profile (lib/palette.mjs); null switches the rule off
218
+ palette: P.palette,
213
219
  // A shadcn kitchen: the sheet names the colours, so a palette class in
214
220
  // own code is paint from a tin (shadcn's own rule: semantic colours,
215
221
  // 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,9 @@ 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';
27
+ import { installedFile } from './installed.mjs';
26
28
  import mui from './mui.mjs';
27
29
  import mantine from './mantine.mjs';
28
30
  import chakra from './chakra.mjs';
@@ -82,6 +84,19 @@ export function decideProfile(profile, components, files, root = null) {
82
84
  profile.kind = picked.kind;
83
85
  profile.kindConfidence = decision.confidence;
84
86
  profile.kindEvidence = decision.evidence;
87
+ // Which vocabulary a palette class is judged against, decided once here
88
+ // and read by the live checks and the guard (lib/palette.mjs): a Tailwind
89
+ // theme, shadcn's sheet when its contract holds, the repo's own theme
90
+ // under a shadcn kit with no rows, or nothing. The report's tile keeps
91
+ // its own gates (harvest/index.mjs) and no score reads this.
92
+ profile.palette = decidePalette(profile, profileOf(profile), () => readTailwindTheme({ ...profile }, ctx)?.facts ?? null);
93
+ // shadcn in utility-class mode (components.json: cssVariables false) paints
94
+ // with Tailwind classes by design. A theme of the repo's own beside it
95
+ // (rybbit retunes the whole neutral scale) is what the rules file points
96
+ // the agent at. Kept as facts: no score and no check reads it (9.5.0).
97
+ if (profile.shadcn && profile.designSystem?.cssVariables === false) {
98
+ profile.shadcn.ownTheme = readTailwindTheme({ ...profile }, ctx)?.facts ?? null;
99
+ }
85
100
  // A shadcn repo that PUBLISHES a registry is a registry: the fourth kind.
86
101
  // Every count still reads the shadcn facts (release (a): zero score change);
87
102
  // the kind, the receipt and the header line say what it is.
@@ -154,6 +169,8 @@ export function profileOf(h) {
154
169
  // a Tailwind repo with its own theme and no kit
155
170
  isTailwind: kind === 'tailwind',
156
171
  tailwind: p.tailwind ?? null,
172
+ // the vocabulary the palette rule judges against, or null when it is off
173
+ palette: p.palette ?? null,
157
174
  // a product built on a component kit installed from npm (mui)
158
175
  isKit: !!p.kit,
159
176
  kit: p.kit ?? null,
@@ -185,14 +202,31 @@ export function installedDirs(P) {
185
202
  return [...(P?.uiDirs ?? []), ...(sc.registryDirs ?? []), ...(sc.blockFiles ?? [])];
186
203
  }
187
204
  const underAny = (file, dirs) => dirs.some((d) => file === d || file.startsWith(`${d}/`));
205
+ // Which files in those folders are installed, file by file (9.5.0): a
206
+ // component of the team's own kept in the catalogue folder is the team's.
207
+ // The rule lives in profiles/installed.mjs.
208
+ export { isShadcnFile, doorFile, installedFile, installedFrom } from './installed.mjs';
209
+
210
+ /**
211
+ * The installed-code test for one reading, as a function of a file path.
212
+ * `none` is set when the repo has no installed folders at all, so a caller
213
+ * can skip the split.
214
+ */
215
+ export function installedOf(P) {
216
+ const fn = (file) => installedFile(P, file);
217
+ fn.none = installedDirs(P).length === 0;
218
+ return fn;
219
+ }
188
220
 
189
221
  /**
190
222
  * Split bracket-value entries ({value, count, files:[{file,count}]}) into the
191
223
  * team's own uses and installed uses. Own entries keep only own files with
192
224
  * counts re-summed; installed is a flat count with its top values.
225
+ * `installed` is installedOf(P), or (older callers) a list of folders.
193
226
  */
194
- export function splitArbitrary(entries, dirs) {
195
- if (!dirs.length) return { own: entries, installed: { uses: 0, values: [] } };
227
+ export function splitArbitrary(entries, installed) {
228
+ const isInstalled = typeof installed === 'function' ? installed : (file) => underAny(file, installed);
229
+ if (typeof installed === 'function' ? installed.none : !installed.length) return { own: entries, installed: { uses: 0, values: [] } };
196
230
  const own = [];
197
231
  const installedValues = new Map();
198
232
  let installedUses = 0;
@@ -202,8 +236,8 @@ export function splitArbitrary(entries, dirs) {
202
236
  // as the team's own, so ai-chatbot's installed badge put one of shadcn's
203
237
  // [3px] values in the headline (2026-09-30)
204
238
  const all = e.every ?? e.files ?? [];
205
- const ownFiles = all.filter((f) => !underAny(f.file, dirs)).sort((a, b) => b.count - a.count).slice(0, 5);
206
- const instCount = all.filter((f) => underAny(f.file, dirs)).reduce((s, f) => s + f.count, 0);
239
+ const ownFiles = all.filter((f) => !isInstalled(f.file)).sort((a, b) => b.count - a.count).slice(0, 5);
240
+ const instCount = all.filter((f) => isInstalled(f.file)).reduce((s, f) => s + f.count, 0);
207
241
  if (instCount) { installedUses += instCount; installedValues.set(e.value, (installedValues.get(e.value) ?? 0) + instCount); }
208
242
  // own is the remainder of the entry's total, never a sum of the files
209
243
  // that happened to be listed
@@ -238,16 +272,16 @@ export function decideFresh(profile, components, files, tokens, root = null) {
238
272
  const P = profileOf(profile);
239
273
  if (!P.isShadcn || !profile.shadcn) return;
240
274
  const sc = profile.shadcn;
241
- const dirs = installedDirs(P);
242
- const own = (files.code ?? []).filter((f) => /\.[jt]sx?$/.test(f) && !underAny(f, dirs));
275
+ const installed = installedOf(P);
276
+ const own = (files.code ?? []).filter((f) => /\.[jt]sx?$/.test(f) && !installed(f));
243
277
  // build config at the root (next.config, vite.config, tailwind.config) is scaffold too
244
278
  const stem = (f) => f.split('/').pop().replace(/\.[jt]sx?$/, '').replace(/^[\w-]+\.config$/, 'app');
245
- const ownComponents = (components ?? []).filter((c) => !c.isPage && !underAny(c.file, dirs));
246
- const ownArbitrary = splitArbitrary(tokens?.tailwind?.arbitrary ?? [], dirs).own.reduce((s, a) => s + a.count, 0);
247
- const ownInline = (tokens?.inlineStyles?.files ?? []).filter((f) => !underAny(f.file ?? f, dirs)).length;
279
+ const ownComponents = (components ?? []).filter((c) => !c.isPage && !installed(c.file));
280
+ const ownArbitrary = splitArbitrary(tokens?.tailwind?.arbitrary ?? [], installed).own.reduce((s, a) => s + a.count, 0);
281
+ const ownInline = (tokens?.inlineStyles?.files ?? []).filter((f) => !installed(f.file ?? f)).length;
248
282
  // the demo page the install command writes shows one Button; a page that
249
283
  // composes several doors is a screen someone built
250
- const ownPages = (components ?? []).filter((c) => c.isPage && !underAny(c.file, dirs)).map((c) => c.file);
284
+ const ownPages = (components ?? []).filter((c) => c.isPage && !installed(c.file)).map((c) => c.file);
251
285
  const doorImports = (f) => {
252
286
  if (!root) return 0;
253
287
  let src = '';
@@ -281,7 +315,7 @@ export function ownSpacing(h) {
281
315
  const css = h.tokens?.spacing ?? [];
282
316
  const tw = (h.tokens?.tailwind?.spacing ?? []).filter((v) => v.value.startsWith('['));
283
317
  if (!P.isShadcn) return { css, tw, installed: { uses: 0, values: [] } };
284
- const dirs = installedDirs(P);
285
- const a = splitArbitrary(css, dirs), b = splitArbitrary(tw, dirs);
318
+ const installed = installedOf(P);
319
+ const a = splitArbitrary(css, installed), b = splitArbitrary(tw, installed);
286
320
  return { css: a.own, tw: b.own, installed: { uses: a.installed.uses + b.installed.uses, values: [...a.installed.values, ...b.installed.values].sort((x, y) => y.count - x.count) } };
287
321
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Installed code on a shadcn repo: which files the team did not write.
3
+ *
4
+ * Until 9.5.0 every file in the catalogue folder (components/ui, or wherever
5
+ * components.json points) counted as installed: its bracket values were
6
+ * called shadcn's, its palette classes were not counted, and the guard left
7
+ * it alone. Probed on the fleet (2026-10-01): 33 of 44 shadcn product repos
8
+ * keep components of their own in that folder, 1,014 of 2,709 files, with
9
+ * 1,285 palette classes nothing counted. formbricks keeps 191 of its own
10
+ * there, and the report called their values shadcn's.
11
+ *
12
+ * The rule, in one place for the report, the live checks and the guard:
13
+ * - a third-party registry folder (ai-elements, magicui) is installed
14
+ * - a kit block (app-sidebar, login-form) is installed
15
+ * - a file in the catalogue folder is installed only when it is one of
16
+ * shadcn's components by name, however the team spells it (Nango keeps
17
+ * Avatar.tsx and Checkbox.tsx); anything else there is the team's
18
+ *
19
+ * The name list is shadcn's own (CATALOGUE, BLOCK_COMPONENTS in
20
+ * shadcn-data.mjs), checked on 2026-10-01 against every component name
21
+ * shadcn has shipped, current and historical. A third-party component
22
+ * installed straight into the catalogue folder, rather than a folder of its
23
+ * own, reads as the team's: nothing on disk tells the two apart.
24
+ */
25
+ import { basename, dirname } from 'node:path';
26
+ import { CATALOGUE, BLOCK_COMPONENTS } from './shadcn-data.mjs';
27
+
28
+ const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/_/g, '-').toLowerCase();
29
+
30
+ /** The component a file holds, by shadcn's naming: index files take their folder's name. */
31
+ function componentName(file) {
32
+ const stem = basename(file).replace(/\.[cm]?[jt]sx?$/, '');
33
+ return kebab(stem === 'index' ? basename(dirname(file)) : stem);
34
+ }
35
+
36
+ /** Is this one of shadcn's own files: a catalogue component or a kit block, by name? */
37
+ export function isShadcnFile(file) {
38
+ const n = componentName(file);
39
+ return CATALOGUE.has(n) || BLOCK_COMPONENTS.has(n);
40
+ }
41
+
42
+ const prefixUnder = (file, dirs) => (dirs ?? []).some((d) => file === d || file.startsWith(`${d}/`));
43
+
44
+ /**
45
+ * Is `file` a kit door: one of shadcn's own components in the catalogue
46
+ * folder, or a kit block? Editing a door is the intended use, so the paint
47
+ * checks leave doors alone.
48
+ * @param from { uiDirs, shadcn: { blockFiles } } (a profileOf() reading, or the
49
+ * plain `installedFrom` object the knowledge and the doorway carry)
50
+ * @param under how a file is matched to a folder; the guard passes its own,
51
+ * which also matches when it runs in a subdirectory
52
+ */
53
+ export function doorFile(from, file, under = prefixUnder) {
54
+ if (!from || !file) return false;
55
+ if (under(file, from.shadcn?.blockFiles)) return true;
56
+ return under(file, from.uiDirs) && isShadcnFile(file);
57
+ }
58
+
59
+ /** Is `file` installed code: a door (above), or a file in a third-party registry folder? */
60
+ export function installedFile(from, file, under = prefixUnder) {
61
+ if (!from || !file) return false;
62
+ return under(file, from.shadcn?.registryDirs) || doorFile(from, file, under);
63
+ }
64
+
65
+ /**
66
+ * The plain facts installedFile and doorFile read, for a profileOf() reading:
67
+ * what the knowledge and the guard doorway carry. Null when nothing is
68
+ * installed (no shadcn kit, or a registry, whose published folders are its
69
+ * own work).
70
+ */
71
+ export function installedFrom(P) {
72
+ if (!P?.isShadcn || P.isRegistry) return null;
73
+ return { uiDirs: [...(P.uiDirs ?? [])], shadcn: { blockFiles: [...(P.shadcn?.blockFiles ?? [])], registryDirs: [...(P.shadcn?.registryDirs ?? [])] } };
74
+ }
@@ -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
  };
@@ -9,7 +9,8 @@ import { distinctTypefaces } from '../lib/typefaces.mjs';
9
9
  import { nearColorPairs } from './../lib/nearpairs.mjs';
10
10
  import { neverImportedComponents } from '../lib/neverimported.mjs';
11
11
  import { VERSION } from '../lib/version.mjs';
12
- import { profileOf, installedDirs, splitArbitrary, ownSpacing } from '../profiles/index.mjs';
12
+ import { profileOf, installedOf, splitArbitrary, ownSpacing } from '../profiles/index.mjs';
13
+ import { themeClassFor } from '../lib/palette.mjs';
13
14
  import { rulesOn as lintRulesOn, describeRule as lintDescribe } from '../profiles/shadcn-lint.mjs';
14
15
 
15
16
  export function rulesMarkdown(h, opts = {}) {
@@ -157,7 +158,7 @@ if (neverImported.length >= 3) {
157
158
  // On a kit the values inside installed folders are shadcn's own: the
158
159
  // counts here are the team's, the shadcn section explains the rest.
159
160
  const P0 = profileOf(h);
160
- const arbitrary = P0.isShadcn ? splitArbitrary(t.tailwind?.arbitrary ?? [], installedDirs(P0)).own : (t.tailwind?.arbitrary ?? []);
161
+ const arbitrary = P0.isShadcn ? splitArbitrary(t.tailwind?.arbitrary ?? [], installedOf(P0)).own : (t.tailwind?.arbitrary ?? []);
161
162
  const arbCount = arbitrary.reduce((s, a) => s + a.count, 0);
162
163
  const offScale = ownSpacing(h).css.length;
163
164
  if (t.tailwind?.spacing?.length || offScale || arbCount) {
@@ -205,10 +206,21 @@ if (neverImported.length >= 3) {
205
206
  const paint = sc.paint ?? null;
206
207
  section('shadcn: the components and the theme');
207
208
  if (P.isRegistry) rule(`This repo publishes a shadcn registry${P.registry?.publishes ? ` (${[['components', P.registry.publishes.components], ['blocks', P.registry.publishes.blocks], ['styles', P.registry.publishes.styles]].filter(([, v]) => v).map(([k, v]) => `${v} ${k}`).join(', ')})` : ''}. A palette colour, a bracket value or a hand-written dark: colour written here ships into every repo that installs it. Hold published code to the theme variables and the scale harder than app code, and keep demos and examples out of published files.`);
208
- rule(`This is a shadcn install${sc.kit?.style ? ` (style \`${sc.kit.style}\`${sc.kit.baseColor ? `, base colour ${sc.kit.baseColor}` : ''})` : ''}. The theme is a set of CSS variables${sheetFile ? ` in \`${sheetFile}\`` : ''}: background, foreground, primary, muted, border and the rest, each with a light and a dark value. Change a colour there, never in a component.`);
209
- if ((sc.sheet?.shadcnPresent ?? 0) >= 5 || sc.kit?.cssVariables === false) rule('Use the semantic classes the theme gives you (`bg-background`, `text-muted-foreground`, `border-border`), never a palette colour like `bg-blue-500` or `text-gray-600`, and never a hand-written `dark:` colour. The variables already carry both modes.');
210
- else rule(`${sheetFile ? `\`${sheetFile}\` defines` : 'The theme file defines'} none of shadcn's colour variables, so \`bg-background\` and \`text-muted-foreground\` have nothing behind them here. Until the theme variables are adopted, stay with the palette classes the surrounding file already uses; do not introduce semantic classes with no variable behind them, and do not add a second palette.`);
211
- if (paint?.tin?.uses) lines.push(` (${paint.tin.uses} palette colour${paint.tin.uses === 1 ? '' : 's'} already sit in own code, ${paint.tin.samples.slice(0, 3).map((s) => `\`${s.value}\` ×${s.count}`).join(', ')}; do not add to them.)`);
209
+ const styleNote = sc.kit?.style ? ` (style \`${sc.kit.style}\`${sc.kit.baseColor ? `, base colour ${sc.kit.baseColor}` : ''})` : '';
210
+ if (sc.kit?.cssVariables === false) {
211
+ // Utility-class mode (9.5.0, words approved by Greg 2026-10-01): the
212
+ // components paint with Tailwind classes by design, so "use the theme
213
+ // variables, never a palette colour" was wrong advice here.
214
+ rule(`This is a shadcn install${styleNote}.`);
215
+ rule(utilityModeRule(sc));
216
+ const op = sc.ownTheme ? sc.ownThemePaint : null;
217
+ if (op?.uses) lines.push(` (${op.uses} palette colour${op.uses === 1 ? '' : 's'} the theme does not own already sit in own code, ${op.samples.slice(0, 3).map((s) => `\`${s.value}\` ×${s.count}`).join(', ')}; do not add to them.)`);
218
+ } else {
219
+ rule(`This is a shadcn install${styleNote}. The theme is a set of CSS variables${sheetFile ? ` in \`${sheetFile}\`` : ''}: background, foreground, primary, muted, border and the rest, each with a light and a dark value. Change a colour there, never in a component.`);
220
+ if ((sc.sheet?.shadcnPresent ?? 0) >= 5) rule('Use the semantic classes the theme gives you (`bg-background`, `text-muted-foreground`, `border-border`), never a palette colour like `bg-blue-500` or `text-gray-600`, and never a hand-written `dark:` colour. The variables already carry both modes.');
221
+ else rule(`${sheetFile ? `\`${sheetFile}\` defines` : 'The theme file defines'} none of shadcn's colour variables, so \`bg-background\` and \`text-muted-foreground\` have nothing behind them here. Until the theme variables are adopted, stay with the palette classes the surrounding file already uses; do not introduce semantic classes with no variable behind them, and do not add a second palette.`);
222
+ if (paint?.tin?.uses) lines.push(` (${paint.tin.uses} palette colour${paint.tin.uses === 1 ? '' : 's'} already sit in own code, ${paint.tin.samples.slice(0, 3).map((s) => `\`${s.value}\` ×${s.count}`).join(', ')}; do not add to them.)`);
223
+ }
212
224
  rule('Before adding classes to a shadcn component, use one of its variants (`variant="outline"`, `size="sm"`). `className` on a shadcn component is for layout only: width, margin, position. Never colour, never typography.');
213
225
  if (paint?.typo?.uses) lines.push(` (${paint.typo.uses} shadcn component${paint.typo.uses === 1 ? '' : 's'} get a text size or weight through className, like ${paint.typo.samples.slice(0, 2).map((s) => `\`${s.value}\``).join(' and ')}; add a size variant instead.)`);
214
226
  if (paint?.doors?.uses) lines.push(` (${paint.doors.uses} shadcn component${paint.doors.uses === 1 ? '' : 's'} already recoloured through className, like ${paint.doors.samples.slice(0, 2).map((s) => `\`${s.value}\``).join(' and ')}; do not add to them.)`);
@@ -257,3 +269,56 @@ if (neverImported.length >= 3) {
257
269
  const ruleCount = text.split('\n').filter((l) => l.startsWith('- ')).length;
258
270
  return { text, ruleCount };
259
271
  }
272
+
273
+ // ---------- shadcn in utility-class mode (words approved by Greg, 2026-10-01) ----------
274
+ const andList = (xs) => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}`);
275
+ // Tailwind names the theme gives new values, as ranges: neutral-50 to neutral-950
276
+ function retunedRanges(retuned) {
277
+ const fam = new Map();
278
+ for (const n of retuned) {
279
+ const m = /^(.*)-(\d+)$/.exec(n);
280
+ if (!m) { fam.set(n, null); continue; }
281
+ if (!fam.has(m[1])) fam.set(m[1], []);
282
+ fam.get(m[1]).push(Number(m[2]));
283
+ }
284
+ return [...fam].map(([f, steps]) => {
285
+ if (!steps) return f;
286
+ steps.sort((a, b) => a - b);
287
+ return steps.length === 1 ? `${f}-${steps[0]}` : `${f}-${steps[0]} to ${f}-${steps.at(-1)}`;
288
+ });
289
+ }
290
+ // the theme's own names worth naming: the ones a palette colour gets swapped
291
+ // for (destructive, accent), and the chart colours as a group
292
+ const NAMED_RE = /^(destructive|danger|error|negative|success|positive|warning|caution|info|accent|brand|highlight)$/;
293
+ function themeWords(names, retuned) {
294
+ const tuned = new Set(retuned);
295
+ const base = [...new Set(names.filter((n) => !tuned.has(n)).map((n) => n.replace(/-\d+$/, '')))];
296
+ const words = base.filter((n) => NAMED_RE.test(n));
297
+ const chart = base.some((n) => /^chart(-|$)/.test(n));
298
+ const picked = words.length ? words : base.filter((n) => !/(^|-)foreground$|^chart(-|$)/.test(n)).slice(0, 3);
299
+ return chart ? [...picked, 'the chart colours'] : picked;
300
+ }
301
+ function utilityModeRule(sc) {
302
+ const head = 'shadcn is installed without CSS variables here (components.json: cssVariables false), so the components paint with';
303
+ const own = sc.ownTheme;
304
+ if (!own) {
305
+ const shades = sc.doorShades ?? [];
306
+ // the install's base colour first (zinc on a zinc install), so the example is the everyday shade, not the destructive red
307
+ const base = sc.kit?.baseColor ? `-${sc.kit.baseColor}-` : null;
308
+ const first = (util) => (base && shades.find((s) => s.value.startsWith(util) && s.value.includes(base))) ?? shades.find((s) => s.value.startsWith(util));
309
+ const pick = [first('bg-'), first('text-')].filter(Boolean);
310
+ return `${head} Tailwind's palette classes by design. Stay with the shades they already use${pick.length ? ` (${pick.map((s) => `\`${s.value}\``).join(', ')})` : ''}. Never a hex, an rgb() or a bracket colour.`;
311
+ }
312
+ const ranges = retunedRanges(own.retuned ?? []);
313
+ const words = themeWords(own.names ?? [], own.retuned ?? []);
314
+ const gives = ranges.length ? `gives ${andList(ranges)} this repo's own values` : '';
315
+ const namesPart = words.length ? `names ${andList(words)}` : '';
316
+ const theme = gives || namesPart ? ` The theme file, \`${own.file}\`, ${[gives, namesPart].filter(Boolean).join(', and ')}. Use those classes.` : '';
317
+ const samples = sc.ownThemePaint?.samples ?? [];
318
+ const example = (samples.find((s) => !s.value.includes(':')) ?? samples[0])?.value ?? null;
319
+ const swap = example ? themeClassFor(example, own.names ?? [], own.values ?? {}) : null;
320
+ const never = example
321
+ ? ` Never a palette colour the theme does not own, such as \`${example}\`; ${swap ? `use \`${swap}\`, or add` : 'add'} the colour to the theme once.`
322
+ : ' Never a palette colour the theme does not own; add the colour to the theme once.';
323
+ return `${head} Tailwind classes.${theme}${never}`;
324
+ }