roast-my-design-system 9.7.0 → 9.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ npx roast-my-design-system@latest
17
17
  Run it at the root of a UI repo. One second later, a report opens.
18
18
  No account. No network. No telemetry. Nothing in your repo changes.
19
19
 
20
- **Current release: 9.3.** Version history in [CHANGELOG.md](CHANGELOG.md).
20
+ Version history is in [CHANGELOG.md](CHANGELOG.md).
21
21
 
22
22
  ## The idea
23
23
 
@@ -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.7.0)
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.8.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.7.0)
149
+ ![The diagnosis report in light mode](assets/report-light-hero.png?v=9.8.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.7.0",
3
+ "version": "9.8.0",
4
4
  "mcpName": "io.github.gregkozakiewicz/roast-my-design-system",
5
5
  "description": "Your AI can write the UI. This makes sure it writes your UI. A deterministic scanner scores your design system 0-100 against 112 public repos, reads React and web components (Stencil, Lit), hands you a copy-paste fix prompt for each top finding, writes rules for Claude, Cursor, Copilot and Windsurf with --apply, and runs as a local MCP server with --mcp.",
6
6
  "keywords": [
@@ -1352,10 +1352,10 @@ function whereToStartSection() {
1352
1352
  else c.push(gate === 'repaint'
1353
1353
  ? { score: 20 + pt.tin.per100 / 4, metric: 'paintTin', after: 0,
1354
1354
  title: `Repaint the ${n(pt.tin.uses)} colours from outside the theme`,
1355
- sub: `${esc(s0.value)} appears ${s0.count} times${f0 ? `, ${esc(basename(f0.file))} alone carries ${f0.count}` : ''}. The theme file already has a variable for each (a grey is <code>text-muted-foreground</code>, a status colour is a Badge variant or a variable you add). Swap the class, not the value.` }
1355
+ sub: `${esc(s0.value)} appears ${s0.count} times.${f0 ? ` ${esc(basename(f0.file))} alone has ${f0.count} palette classes.` : ''} The theme file already has a variable for each (a grey is <code>text-muted-foreground</code>, a status colour is a Badge variant or a variable you add). Swap the class, not the value.` }
1356
1356
  : { score: 20 + pt.tin.per100 / 4, metric: 'paintTin', after: 0,
1357
1357
  title: `Decide what the product paints from`,
1358
- sub: `${esc(s0.value)} appears ${s0.count} times${f0 ? `, ${esc(basename(f0.file))} alone carries ${f0.count}` : ''}, and there is no theme file to point them at: ${P.shadcn?.sheet?.file ? `${esc(P.shadcn.sheet.file)} defines` : 'the theme file defines'} none of shadcn's colour variables, so the palette is the system today. The first move is a decision, not a swap: adopt the theme variables (define them, map the palette onto them), then repaint file by file. Swapping <code>text-slate-500</code> for <code>text-muted-foreground</code> before that leaves the text with no colour.` });
1358
+ sub: `${esc(s0.value)} appears ${s0.count} times.${f0 ? ` ${esc(basename(f0.file))} alone has ${f0.count} palette classes.` : ''} There is no theme file to point them at: ${P.shadcn?.sheet?.file ? `${esc(P.shadcn.sheet.file)} defines` : 'the theme file defines'} none of shadcn's colour variables, so the palette is the system today. The first move is a decision, not a swap: adopt the theme variables (define them, map the palette onto them), then repaint file by file. Swapping <code>text-slate-500</code> for <code>text-muted-foreground</code> before that leaves the text with no colour.` });
1359
1359
  }
1360
1360
  if (pt.doors.uses >= 5) {
1361
1361
  const s0 = pt.doors.samples[0];
@@ -1600,8 +1600,15 @@ function agentSection() {
1600
1600
  // library stays "component library"; a product repo we cannot read gets the
1601
1601
  // quiet unrecognised chip, because not knowing IS a finding here.
1602
1602
  const ns = h.tokens.namespaces ?? null;
1603
+ // A product built on a kit is named by its kit, and by its second kit when
1604
+ // it has one (9.8.0; 44 of 80 kit reports said "unrecognised" before, and
1605
+ // unleash said "custom design system" over MUI). A token namespace beside a
1606
+ // kit keeps a chip of its own below.
1607
+ const kitSecond = P.kit?.second?.name ?? P.shadcn?.second?.name ?? null;
1603
1608
  const dsChip =
1604
- ds.kind === 'shadcn' ? `shadcn/ui${ds.cssVariables === false ? ' (utility classes, no CSS variables)' : ''}${P.shadcn?.kit?.style ? ` · ${P.shadcn.kit.style}` : ''}`
1609
+ ds.kind === 'kit' ? `${ds.name}${kitSecond ? ` + ${kitSecond}` : ''}`
1610
+ : ds.kind === 'shadcn' ? `shadcn/ui${ds.cssVariables === false ? ' (utility classes, no CSS variables)' : ''}${P.shadcn?.kit?.style ? ` · ${P.shadcn.kit.style}` : ''}${kitSecond ? ` + ${kitSecond}` : ''}`
1611
+ : ds.kind === 'tailwind' ? 'Tailwind theme'
1605
1612
  : ds.kind === 'library' ? ds.name
1606
1613
  : ns ? `custom design system (--${ns.primary}-*${ns.partner ? ` + --${ns.partner}-*` : ''})`
1607
1614
  // A tokenFile alone is a technicality (Lion's is one drawer style file);
@@ -1615,6 +1622,8 @@ const stack = [
1615
1622
  h.profile.typescript ? 'TypeScript' : null,
1616
1623
  ...(h.profile.stylingDeps ?? []),
1617
1624
  dsChip,
1625
+ // the kit's own variables (--mantine-*, --chakra-*) are not the team's tokens
1626
+ ds.kind === 'kit' && ns && !/^(?:mantine|chakra|mui|antd?)$/.test(ns.primary) ? `tokens --${ns.primary}-*` : null,
1618
1627
  h.profile.monorepo ? 'monorepo' : null,
1619
1628
  ].filter(Boolean);
1620
1629
  const dsUnrecognised = !dsChip && !noSystemLikely;
@@ -128,6 +128,16 @@ const COLOUR_PROP_RE = /^(?:color|background(?:-color)?|border(?:-(?:top|right|b
128
128
  const DARK_CHAIN_RE = /(^|[^a-z])dark(?![a-z])/i;
129
129
  const DARK_FILE_RE = /(^|[/._-])dark([/._-]|$)/i;
130
130
  const ROOT_RE = /(^|[\s,(])(?::root|html|:host|body)\b|@theme/;
131
+ // A selector list with a bare root selector in it is a root statement
132
+ // whatever its other members say: documenso's `:root, .dark-mode-disabled`
133
+ // was ranked dark for the word in its second selector and the sheet earned
134
+ // no receipt line (9.7.1). `:root.dark` and `html[data-theme=dark]` are not
135
+ // bare, and stay dark, and so does a :root under a dark at-rule.
136
+ const bareRoot = (chain) => {
137
+ const parts = chain.split(' > ');
138
+ if (DARK_CHAIN_RE.test(parts.slice(0, -1).join(' > '))) return false;
139
+ return parts[parts.length - 1].split(',').some((sel) => /^(?::root|html|:host|body)$/.test(sel.trim()));
140
+ };
131
141
  // theme reads this pass cannot follow (D4): a JavaScript theme object outside
132
142
  // the four kits (emotion, styled-components), and Sass or Less variables in
133
143
  // a colour property. Read by the colour words a theme object uses, at any
@@ -195,7 +205,7 @@ function cssStatements(text) {
195
205
  /** A Tailwind v3 config's colours, flattened: primary.DEFAULT -> primary,
196
206
  * brand[500] -> brand-500. String values only; spreads and computed values
197
207
  * are skipped, so a palette assembled in code reads as not defined. */
198
- function configColours(src) {
208
+ export function configColours(src) {
199
209
  const out = new Map();
200
210
  const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:'"`])\/\/[^\n]*/g, '$1');
201
211
  const re = /\bcolou?rs\s*:\s*\{/g;
@@ -392,7 +402,7 @@ export function colourUse(root, files, tokens, { email = null, P = null, palette
392
402
  const addStatements = (st, f, pkg) => {
393
403
  const fileDark = DARK_FILE_RE.test(f);
394
404
  for (const d of st.defs) {
395
- const rank = fileDark || DARK_CHAIN_RE.test(d.chain) ? 2 : ROOT_RE.test(d.chain) ? 0 : 1;
405
+ const rank = fileDark ? 2 : bareRoot(d.chain) ? 0 : DARK_CHAIN_RE.test(d.chain) ? 2 : ROOT_RE.test(d.chain) ? 0 : 1;
396
406
  const list = defs.get(d.name) ?? [];
397
407
  list.push({ pkg, value: d.value, rank, order: order++ });
398
408
  defs.set(d.name, list);
@@ -7,7 +7,7 @@
7
7
  * plan. Born from the first user feedback (Willem, 2026-08-31).
8
8
  */
9
9
 
10
- import { KITS } from '../profiles/kit-common.mjs';
10
+ import { KITS, secondKitHow } from '../profiles/kit-common.mjs';
11
11
  import '../profiles/mui.mjs';
12
12
  import '../profiles/mantine.mjs';
13
13
  import '../profiles/chakra.mjs';
@@ -45,7 +45,7 @@ export function fixPrompt({ title, sub, deltaText, repoName, metric = null, kit
45
45
  const also = second ? `- This repo also uses ${second.name}${second.pkg ? ` (${second.pkg})` : ''}.` : '';
46
46
  const secondLine = !adv || !second ? null
47
47
  : ['kitColour', 'kitPx'].includes(metric)
48
- ? `${also} Make these changes only on ${kit} components and the wrappers around them. On ${second.name} components, copy how nearby files style them.`
48
+ ? `${also} Make these changes only on ${kit} components and the wrappers around them. On ${second.name} components, ${secondKitHow(second)}`
49
49
  : metric === 'inlineStyles'
50
50
  ? `${also} On ${second.name} components, move a style the way nearby files do. Never put one kit's styling on the other's components.`
51
51
  : null;
@@ -182,15 +182,22 @@ const hueDistance = (x, y) => Math.hypot(x.L - y.L, 2 * (x.a - y.a), 2 * (x.b -
182
182
  // Past this, the nearest theme colour is a different colour, not a stand-in
183
183
  // (bg-blue-600 against a theme of greys), and the pick by name answers.
184
184
  const CLOSE = 0.2;
185
+ // A grey class stands in for a grey only. Tailwind's greys sit at a chroma
186
+ // of 0.01 to 0.045 (slate is the most tinted); a brand teal at the same
187
+ // lightness sits at 0.095 and was within CLOSE of slate-500, so formbricks'
188
+ // text-slate-500 was sent to text-brandnew (2026-10-01).
189
+ const GREY_CHROMA = 0.06;
185
190
  /** The suitable theme name nearest to a palette shade's Tailwind value, or null. */
186
191
  function nearestThemeName(shade, role, values) {
187
192
  const from = oklab(TAILWIND_DEFAULTS[shade] ?? '');
188
193
  if (!from) return null;
194
+ const grey = GREY_HUE_RE.test(`-${shade}`);
189
195
  let best = null;
190
196
  for (const [name, value] of Object.entries(values)) {
191
197
  if (!suits(name, role)) continue;
192
198
  const to = oklab(value);
193
199
  if (!to) continue;
200
+ if (grey && Math.hypot(to.a, to.b) > GREY_CHROMA) continue;
194
201
  const d = hueDistance(from, to);
195
202
  if (!best || d < best.d) best = { name, d };
196
203
  }
@@ -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.7.0';
4
+ export const VERSION = '9.8.0';
5
5
  // The shape of harvest.json and summary.json. Bumped only when a field is
6
6
  // renamed, removed or changes meaning; a new field is not a new schema. A
7
7
  // tool comparing two scans compares like with like by this number, not by VERSION.
@@ -226,6 +226,8 @@ export function loadKnowledge(root) {
226
226
  shadcn: P.isShadcn && profile.shadcn ? {
227
227
  sheet: profile.shadcn.sheet?.found ? profile.shadcn.sheet.file : null,
228
228
  doors: [...P.uiDirs, ...(profile.shadcn.blockFiles ?? [])],
229
+ // a package kit beside the folder, named and not scored (9.8.0)
230
+ second: profile.shadcn.second ?? null,
229
231
  } : null,
230
232
  agentFiles: (context ?? []).filter((c) => c.kind === 'agent-rules'),
231
233
  };
@@ -6,6 +6,7 @@
6
6
  * character budget (~400 tokens), find calls return one answer, not a ledger,
7
7
  * and review reads the git diff itself so the agent never pastes code back.
8
8
  */
9
+ import { secondKitHow } from '../profiles/kit-common.mjs';
9
10
  import { execFileSync } from 'node:child_process';
10
11
  import { readFileSync, realpathSync } from 'node:fs';
11
12
  import { join, relative } from 'node:path';
@@ -57,7 +58,7 @@ export function getContext(k, { path = null } = {}) {
57
58
  // counted in the first kit's files: some may sit on the second kit's
58
59
  // components, which the live checks name one by one
59
60
  if (c || p) L.push(` Already written onto components in ${kit.name} files: ${[c ? `${c} colour${c === 1 ? '' : 's'} (${kit.colour.samples.slice(0, 3).map((x) => x.value).join(', ')})` : '', p ? `${p} pixel size${p === 1 ? '' : 's'}` : ''].filter(Boolean).join(' and ')}; do not add more.`);
60
- L.push(` On ${sk.name} components${sk.pkg ? ` (${sk.pkg})` : ''}: copy how nearby files style them.`);
61
+ L.push(` On ${sk.name} components${sk.pkg ? ` (${sk.pkg})` : ''}: ${secondKitHow(sk)}`);
61
62
  } else if (k.kit) {
62
63
  const kit = k.kit, adv = kit.def?.advice;
63
64
  const theme = kit.themeFiles?.[0];
@@ -77,6 +78,10 @@ export function getContext(k, { path = null } = {}) {
77
78
  const pick = (re, i) => (pal?.names ?? []).find((n) => re.test(n)) ?? pal?.names?.[i] ?? 'brand';
78
79
  const examples = pal?.source === 'tailwind' ? `bg-${pick(/surface|(^|-)bg(-|$)|background|canvas/, 0)}, text-${pick(/ink|text|fg|foreground/, 1)}` : 'bg-primary, text-muted-foreground';
79
80
  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.)` : ''}`);
81
+ // a package kit beside the shadcn folder (nhost's 57 MUI files, 9.8.0):
82
+ // named so the agent does not put shadcn classes on its components
83
+ const s2 = k.shadcn?.second;
84
+ if (s2) L.push(`ALSO: ${s2.name}${s2.pkg ? ` (${s2.pkg})` : ''} in ${s2.files} files${s2.dir ? ` in ${s2.dir}` : ''}${s2.withoutFirst === s2.files ? ', none of them with shadcn' : ''}. Follow the kit the file already uses. On ${s2.name} components: ${secondKitHow(s2)} Never put one kit's styling on the other's components.`);
80
85
  } else if (t.colors.length) {
81
86
  L.push(`TOKENS: none defined. ${t.colors.length} distinct colours already in play; reuse one, never invent another.`);
82
87
  }
@@ -135,11 +140,12 @@ export function getContext(k, { path = null } = {}) {
135
140
  if (nCanon > 2) nCanon -= 1; else nDupes -= 1;
136
141
  text = assemble(nCanon, nDupes) + shortened();
137
142
  }
138
- // Two kits add three lines. Before cutting anything, drop what another call
139
- // can give back, in this order, so the KITS lines, DISCIPLINE and the
140
- // closing roast_validate line always stay. Repos with one kit keep the
141
- // cut below exactly as before (2026-10-01).
142
- if (k.kit?.second) {
143
+ // Before cutting anything, drop what another call can give back, in this
144
+ // order, so the KIT or TOKENS lines, DISCIPLINE and the closing
145
+ // roast_validate line always stay. Built for two-kit repos in 9.7.0; from
146
+ // 9.8.0 every repo gets it (19 single-kit repos went over the budget and
147
+ // lost their closing line to the straight cut, Greg agreed 2026-10-02).
148
+ {
143
149
  const drop = (re) => { for (let j = L.length - 1; j >= 0; j--) if (typeof L[j] === 'string' && re.test(L[j])) { L.splice(j, 1); return true; } return false; };
144
150
  const steps = [() => drop(/^NOTE:/), () => (nDupes > 0 ? (nDupes -= 1, true) : false), () => drop(/^GAP:/), () => drop(/^GAP:/), () => drop(/^TYPE:/), () => (nCanon > 1 ? (nCanon -= 1, true) : false)];
145
151
  for (const step of steps) {
@@ -148,15 +154,15 @@ export function getContext(k, { path = null } = {}) {
148
154
  }
149
155
  }
150
156
  const TRIM = '\n(trimmed to budget; ask roast_find_component / roast_find_token for specifics)';
151
- if (text.length > CONTEXT_BUDGET && k.kit?.second) {
152
- // still over: cut the middle, never the KITS lines at the top nor
153
- // DISCIPLINE and the closing roast_validate line at the end
157
+ if (text.length > CONTEXT_BUDGET) {
158
+ // still over: cut the middle, never the lines at the top nor DISCIPLINE
159
+ // and the closing roast_validate line at the end
154
160
  const tailAt = text.indexOf('\nDISCIPLINE:');
155
161
  const tail = tailAt >= 0 ? text.slice(tailAt) : '';
156
162
  const room = CONTEXT_BUDGET - tail.length - TRIM.length;
157
163
  const head = tailAt >= 0 ? text.slice(0, tailAt) : text;
158
164
  text = `${head.slice(0, Math.max(0, head.lastIndexOf('\n', room)))}${TRIM}${tail}`;
159
- } else if (text.length > CONTEXT_BUDGET) text = `${text.slice(0, text.lastIndexOf('\n', CONTEXT_BUDGET))}${TRIM}`;
165
+ }
160
166
  // No credit line here (8.1.1): a tool answers the question and nothing
161
167
  // else. Attribution lives on the report and in the skill's citation rule.
162
168
  return text;
@@ -22,7 +22,7 @@ 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, { readTailwindTheme } from './tailwind.mjs';
25
+ import tailwind, { readTailwindTheme, v3Retuned } from './tailwind.mjs';
26
26
  import { decidePalette } from '../lib/palette.mjs';
27
27
  import { installedFile, componentName } from './installed.mjs';
28
28
  import mui from './mui.mjs';
@@ -94,6 +94,12 @@ export function decideProfile(profile, components, files, root = null, { themeRe
94
94
  let ownTheme;
95
95
  const readOwnTheme = () => (ownTheme === undefined ? (ownTheme = readTailwindTheme({ ...profile }, ctx)?.facts ?? null) : ownTheme);
96
96
  profile.palette = decidePalette(profile, profileOf(profile), readOwnTheme);
97
+ // a palette name a v3 config gave the repo's own colour is the theme too,
98
+ // whichever vocabulary judges the rest (9.7.1)
99
+ if (profile.palette && root) {
100
+ const tuned = v3Retuned(root, files.code);
101
+ if (tuned.length) profile.palette.retuned = [...new Set([...(profile.palette.retuned ?? []), ...tuned])].sort();
102
+ }
97
103
  // shadcn in utility-class mode (components.json: cssVariables false) paints
98
104
  // with Tailwind classes by design. A theme of the repo's own beside it
99
105
  // (rybbit retunes the whole neutral scale) is what the rules file points
@@ -46,14 +46,14 @@ const nestedCatalogueImport = (src) => [...src.matchAll(SHADCN_NESTED_RE)].some(
46
46
  function importCounts(root, codeFiles) {
47
47
  if (importCache.has(codeFiles)) return importCache.get(codeFiles);
48
48
  const counts = Object.fromEntries([...Object.keys(KITS), 'shadcn'].map((k) => [k, 0]));
49
- const listed = Object.fromEntries(KIT_LIST.map((k) => [k.name, []]));
49
+ const listed = Object.fromEntries([...KIT_LIST.map((k) => [k.name, []]), ['shadcn/ui', []]]);
50
50
  const direct = Object.fromEntries(Object.keys(KITS).map((k) => [k, []]));
51
51
  for (const f of codeFiles) {
52
52
  if (!/\.[jt]sx?$/.test(f)) continue;
53
53
  const src = readSafe(join(root, f));
54
54
  for (const [k, d] of Object.entries(KITS)) if (d.importRe.test(src)) { counts[k] += 1; direct[k].push(f); }
55
55
  // a shadcn catalogue is a kit too: components/ui imports
56
- if (SHADCN_IMPORT_RE.test(src) || nestedCatalogueImport(src)) counts.shadcn += 1;
56
+ if (SHADCN_IMPORT_RE.test(src) || nestedCatalogueImport(src)) { counts.shadcn += 1; listed['shadcn/ui'].push(f); }
57
57
  for (const name of new Set(importSources(src).map(kitOfSource).filter(Boolean))) listed[name].push(f);
58
58
  }
59
59
  const out = { counts, files: { listed, direct } };
@@ -90,8 +90,10 @@ function secondKit(root, def, paint, imports, email, codeFiles) {
90
90
  // a tenth of the first kit as the words count it: its layer included
91
91
  const firstUse = paint.kitFiles;
92
92
  const usable = (f) => !SKIP_PATH_RE.test(f);
93
- const found = KIT_LIST.filter((k) => k.name !== def.name)
94
- .map((k) => ({ name: k.name, list: (imports.files.listed[k.name] ?? []).filter(usable) }))
93
+ // a shadcn folder beside a kit is a second kit too (zupass: Chakra and
94
+ // components/ui, 9.8.0); its files are the ones that import the folder
95
+ const found = [...KIT_LIST.filter((k) => k.name !== def.name).map((k) => k.name), 'shadcn/ui']
96
+ .map((name) => ({ name, list: (imports.files.listed[name] ?? []).filter(usable) }))
95
97
  .filter((c) => c.list.length >= SECOND_MIN_FILES && (c.list.length >= SECOND_FILES || c.list.length >= SECOND_SHARE * firstUse))
96
98
  .sort((a, b) => b.list.length - a.list.length || a.name.localeCompare(b.name));
97
99
  if (!found.length) return null;
@@ -103,9 +105,11 @@ function secondKit(root, def, paint, imports, email, codeFiles) {
103
105
  for (const f of s.list) {
104
106
  let src;
105
107
  try { if (statSync(join(root, f)).size > 1e6) continue; src = readFileSync(join(root, f), 'utf8'); } catch { continue; }
106
- const fromS = importSources(src).filter((p) => kitOfSource(p) === s.name);
108
+ const fromS = s.name === 'shadcn/ui'
109
+ ? [...src.matchAll(/from\s+['"]([@~./\w-]*components\/ui)\/[\w/-]+['"]/g)].map((m) => m[1])
110
+ : importSources(src).filter((p) => kitOfSource(p) === s.name);
107
111
  if (!fromS.length) continue;
108
- for (const p of fromS) pkgs.set(pkgRoot(p), (pkgs.get(pkgRoot(p)) ?? 0) + 1);
112
+ for (const p of fromS) pkgs.set(s.name === 'shadcn/ui' ? p : pkgRoot(p), (pkgs.get(s.name === 'shadcn/ui' ? p : pkgRoot(p)) ?? 0) + 1);
109
113
  if (!firstRe.test(src)) { without.push(f); continue; }
110
114
  mixed.push(f);
111
115
  const hits = kitPaintInSource(src, def, { file: f, layers: paint.layers, email });
@@ -135,8 +139,45 @@ function secondKit(root, def, paint, imports, email, codeFiles) {
135
139
  };
136
140
  }
137
141
 
142
+ // A package kit beside a shadcn folder (nhost: 631 files import the folder,
143
+ // 57 import MUI; 9.8.0): named in the rules file, the MCP context and the
144
+ // report's receipt, never scored. The same thresholds as the kit side.
145
+ export function shadcnSecondKit(root, codeFiles) {
146
+ const imports = importCounts(root, codeFiles);
147
+ const usable = (f) => !SKIP_PATH_RE.test(f);
148
+ const first = (imports.files.listed['shadcn/ui'] ?? []).filter(usable);
149
+ const found = KIT_LIST
150
+ .map((k) => ({ name: k.name, list: (imports.files.listed[k.name] ?? []).filter(usable) }))
151
+ .filter((c) => c.list.length >= SECOND_MIN_FILES && (c.list.length >= SECOND_FILES || c.list.length >= SECOND_SHARE * first.length))
152
+ .sort((a, b) => b.list.length - a.list.length || a.name.localeCompare(b.name));
153
+ if (!found.length) return null;
154
+ const [s] = found;
155
+ const firstSet = new Set(first);
156
+ const pkgs = new Map();
157
+ for (const f of s.list) {
158
+ for (const p of importSources(readSafe(join(root, f))).filter((x) => kitOfSource(x) === s.name)) pkgs.set(pkgRoot(p), (pkgs.get(pkgRoot(p)) ?? 0) + 1);
159
+ }
160
+ const without = s.list.filter((f) => !firstSet.has(f)).length;
161
+ return {
162
+ name: s.name,
163
+ pkg: [...pkgs.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? null,
164
+ registered: !!KITS[s.name],
165
+ files: s.list.length,
166
+ withoutFirst: without,
167
+ mixed: s.list.length - without,
168
+ dir: cleanFolder(s.list, first),
169
+ firstDir: cleanFolder(first, s.list),
170
+ };
171
+ }
172
+
138
173
  // ", 20 of them without Mantine", or ", none of them with Mantine" when no
139
174
  // file has both
175
+ // How to style the second kit's components, in one sentence shared by the
176
+ // rules file, the MCP context and the fix prompts. shadcn's rule is known;
177
+ // any other kit's components follow the files around them (9.8.0).
178
+ export const secondKitHow = (sk) => (sk.name === 'shadcn/ui'
179
+ ? 'use its variants and the theme classes (bg-background, text-muted-foreground), never a palette colour.'
180
+ : 'copy how nearby files style them.');
140
181
  export const withoutWords = (sk, first) => (!sk.withoutFirst ? '' : sk.withoutFirst === sk.files ? `, none of them with ${first}` : `, ${sk.withoutFirst} of them without ${first}`);
141
182
 
142
183
  export function kitProfile(def) {
@@ -28,6 +28,7 @@
28
28
  import { existsSync, readFileSync } from 'node:fs';
29
29
  import { join, dirname, basename } from 'node:path';
30
30
  import { canonical } from '../lib/color.mjs';
31
+ import { shadcnSecondKit, withoutWords } from './kit-common.mjs';
31
32
  import { CATALOGUE, BLOCK_COMPONENTS, REGISTRY_DIRS, KNOWN_ROWS, TWEAKCN_ROWS, LIGHT_ONLY_ROWS, FACTORY_SPACING,
32
33
  FRONTS, LEGACY_FRONTS, BASES, BASE_COLORS, ICON_LIBRARIES, RADIUS_MAP, THEMES, SHADCN_ROWS } from './shadcn-data.mjs';
33
34
 
@@ -124,10 +125,12 @@ function themeContract(root, files) {
124
125
  return seen.size;
125
126
  }
126
127
 
127
- /** Rows declared under a selector in a CSS text: name -> value. */
128
+ /** Rows declared under a selector in a CSS text: name -> value. The
129
+ * selector may head a list (documenso: `:root, .dark-mode-disabled`) or
130
+ * carry a :not() (`.dark:not(.dark-mode-disabled)`), 9.7.1. */
128
131
  function rowsUnder(css, selector) {
129
132
  const out = new Map();
130
- const re = new RegExp(`(^|[\\s,}])${selector}\\s*\\{([^}]*)\\}`, 'g');
133
+ const re = new RegExp(`(^|[\\s,}])${selector}(?::not\\([^)]*\\))?\\s*(?:,[^{}]*)?\\{([^}]*)\\}`, 'g');
131
134
  for (const m of css.matchAll(re)) {
132
135
  for (const d of m[2].matchAll(/--([a-z0-9-]+)\s*:\s*([^;]+);/gi)) out.set(d[1], d[2].trim());
133
136
  }
@@ -150,7 +153,7 @@ function sheetRows(css) {
150
153
  if (!/--background\s*:/.test(m[2]) || sel.startsWith('@')) continue;
151
154
  const rows = new Map();
152
155
  for (const d of m[2].matchAll(/--([a-z0-9-]+)\s*:\s*([^;]+);/gi)) rows.set(d[1], d[2].trim());
153
- if (/\.dark\b/.test(sel)) { if (!dark.has('background')) dark = rows; }
156
+ if (/\.dark(?![\w-])/.test(sel)) { if (!dark.has('background')) dark = rows; }
154
157
  else if (!light.has('background')) { light = rows; scope = sel; }
155
158
  if (light.has('background') && dark.has('background')) break;
156
159
  }
@@ -199,6 +202,10 @@ function readSheet(root, wsRoot, cssPath, files = null) {
199
202
  const rel = file.slice(root.length + 1);
200
203
  const others = [...(files.code ?? []), ...(files.styles ?? [])].filter((f) => f !== rel).slice(0, 4000);
201
204
  const pending = new Set(custom.filter((r) => !/^(font|shadow|tracking|spacing|radius|ease|breakpoint|typeset)/.test(r)));
205
+ // a read inside the sheet itself counts: --sidebar: var(--surface-2)
206
+ // makes --surface-2 part of the theme, whether or not a component names
207
+ // it (9.7.1; its own rows, light and dark, are definitions, not reads)
208
+ for (const r of [...pending]) if (new RegExp(`var\\(\\s*--${r}(?![\\w-])`).test(css)) pending.delete(r);
202
209
  for (const f of others) {
203
210
  if (!pending.size) break;
204
211
  const src = read(join(root, f));
@@ -345,7 +352,11 @@ export default {
345
352
  .filter((d) => registryNames.has(basename(d).toLowerCase()) && !profile.uiDirs.some((u) => d === u || d.startsWith(`${u}/`))))]
346
353
  .filter((d) => files.code.filter((f) => f.startsWith(`${d}/`) && /\.[jt]sx$/.test(f)).length >= 2);
347
354
  if (registryDirs.length) evidence.push(`${registryDirs.length} installed registr${registryDirs.length === 1 ? 'y' : 'ies'} beside it: ${registryDirs.map((d) => basename(d)).join(', ')}`);
355
+ // a package kit beside the folder (nhost's 57 MUI files): named, not scored
356
+ const second = shadcnSecondKit(root, files.code);
357
+ if (second) evidence.push(`${second.name} imported in ${second.files} files${withoutWords(second, 'shadcn')}: named, not scored`);
348
358
  profile.shadcn = {
359
+ second,
349
360
  installs: installs.map((i) => ({ config: i.config, uiDir: i.uiDir, catalogueNames: i.catalogueNames })),
350
361
  // the theme contract: how many of shadcn's rows any stylesheet defines,
351
362
  // and the file carrying most of them. The configured sheet can hold
@@ -20,7 +20,10 @@ import { cachedSource } from '../harvest/walk.mjs';
20
20
  import { join } from 'node:path';
21
21
  import { PALETTE } from './shadcn-data.mjs';
22
22
  import { TAILWIND_DEFAULTS } from './tailwind-defaults.mjs';
23
- import { canonical, parseColor } from '../lib/color.mjs';
23
+ import { canonical, parseColor, oklab } from '../lib/color.mjs';
24
+ import { configColours } from '../harvest/coloruse.mjs';
25
+ import { existsSync } from 'node:fs';
26
+ import { posix } from 'node:path';
24
27
 
25
28
  // the scan's own copy when it has one (harvest/walk.mjs), the disk otherwise
26
29
  const read = (p) => { const t = cachedSource(p); if (t !== undefined) return t; try { return readFileSync(p, 'utf8'); } catch { return ''; } };
@@ -40,6 +43,58 @@ function retuned(name, value) {
40
43
  return !(cv && cd && near(cv, cd));
41
44
  }
42
45
 
46
+ // A v3 config can retune a scale too: Novu points every grey at
47
+ // hsl(var(--gray-N)), workout-cool gives green its own values. The theme
48
+ // itself stays unread (a v3 config imports, spreads and computes; Greg,
49
+ // 2026-09-16), but a palette name it gives a new colour is the repo's own
50
+ // wherever it is read, so the live checks and the colour bar stop calling
51
+ // the class drift (9.7.1). Only a config the product reads counts: the root
52
+ // config, or one in a package holding a tenth of the code files (unkey
53
+ // retunes grey in an email package of 20 files, and the web app must not
54
+ // inherit it).
55
+ const TW_CONFIG_RE = /(^|\/)tailwind\.config\.[mc]?[jt]s$/;
56
+ const PALETTE_SHADE_NAME_RE = new RegExp(`^(?:${PALETTE})-(?:50|[1-9]00|950)$`);
57
+ const CONFIG_SHARE = 0.1;
58
+ // The defaults the engine keeps are v4's. v3's own values sit within 0.04
59
+ // of them in OKLab (blue-500 #3b82f6 against oklch(62.3% 0.214 259.815):
60
+ // 0.019; the widest, fuchsia-500, 0.036), while a scale a team retuned sits
61
+ // at 0.07 or more (workout-cool's green-500 at 0.076). A literal this close
62
+ // is Tailwind's own, whichever major it was copied from.
63
+ const V3_SAME = 0.05;
64
+ const v3Retune = (name, value) => {
65
+ if (!retuned(name, value)) return false;
66
+ const a = oklab(value.trim()), b = oklab(TAILWIND_DEFAULTS[name]);
67
+ return !(a && b && Math.hypot(a.L - b.L, a.a - b.a, a.b - b.b) < V3_SAME);
68
+ };
69
+ /** Palette names a v3 config the product reads gave colours of its own. */
70
+ export function v3Retuned(root, codeFiles) {
71
+ if (!root || !codeFiles?.length) return [];
72
+ const configs = codeFiles.filter((f) => TW_CONFIG_RE.test(f));
73
+ if (!configs.length) return [];
74
+ const pkgOf = (file) => {
75
+ let dir = posix.dirname(file);
76
+ while (dir && dir !== '.') {
77
+ if (existsSync(join(root, dir, 'package.json'))) return dir;
78
+ dir = posix.dirname(dir);
79
+ }
80
+ return '';
81
+ };
82
+ let sizes = null;
83
+ const sizeOf = (pkg) => {
84
+ if (!sizes) { sizes = new Map(); for (const f of codeFiles) { const p = pkgOf(f); sizes.set(p, (sizes.get(p) ?? 0) + 1); } }
85
+ return sizes.get(pkg) ?? 0;
86
+ };
87
+ const out = new Set();
88
+ for (const f of configs) {
89
+ const pkg = pkgOf(f);
90
+ if (pkg !== '' && sizeOf(pkg) < codeFiles.length * CONFIG_SHARE) continue;
91
+ for (const [n, v] of configColours(read(join(root, f)))) {
92
+ if (PALETTE_SHADE_NAME_RE.test(n) && v3Retune(n, v)) out.add(n);
93
+ }
94
+ }
95
+ return [...out].sort();
96
+ }
97
+
43
98
  // what counts as a theme worth judging
44
99
  // three, not six: a small brand palette used everywhere is still a system
45
100
  // (hey.xyz: four brand shades; ConvertX: four colours, 2026-09-16)
@@ -4,7 +4,7 @@
4
4
  * Output: { text, ruleCount } — paste-ready agent-rules markdown, every rule
5
5
  * with a receipt from the scanned repo.
6
6
  */
7
- import { KITS, withoutWords } from '../profiles/kit-common.mjs';
7
+ import { KITS, withoutWords, secondKitHow } from '../profiles/kit-common.mjs';
8
8
  import { distinctTypefaces } from '../lib/typefaces.mjs';
9
9
  import { nearColorPairs } from './../lib/nearpairs.mjs';
10
10
  import { neverImportedComponents } from '../lib/neverimported.mjs';
@@ -237,10 +237,20 @@ if (neverImported.length >= 3) {
237
237
  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.)`);
238
238
  } else {
239
239
  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.`);
240
- 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.');
240
+ // The same decision as the live checks (lib/palette.mjs): the contract
241
+ // holds when any stylesheet keeps five of shadcn's rows. Until 9.7.1
242
+ // this line read the configured sheet's :root rows alone, so formbricks
243
+ // (its names in @theme, the rows in a sibling package) was told to stay
244
+ // with the palette classes the live checks flag.
245
+ const contractHolds = P.palette?.source === 'shadcn' || (sc.sheet?.shadcnPresent ?? 0) >= 5;
246
+ const contractNote = contractHolds && (sc.sheet?.shadcnPresent ?? 0) < 5 && sc.contract?.file && sc.contract.file !== sheetFile ? ` The variables are defined in \`${sc.contract.file}\`.` : '';
247
+ if (contractHolds) 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.${contractNote}`);
241
248
  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.`);
242
249
  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.)`);
243
250
  }
251
+ // a package kit beside the folder (nhost's 57 MUI files, 9.8.0): the
252
+ // rules here are shadcn's; its components follow their own files
253
+ if (sc.second) rule(`This repo also uses ${sc.second.name}${sc.second.pkg ? ` (\`${sc.second.pkg}\`)` : ''}, imported in ${sc.second.files} files${sc.second.dir ? ` in \`${sc.second.dir}\`` : ''}${withoutWords(sc.second, 'shadcn')}. The rules in this section are for shadcn components. On ${sc.second.name} components, ${secondKitHow(sc.second)} Never put one kit's styling on the other's components.`);
244
254
  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.');
245
255
  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.)`);
246
256
  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.)`);
@@ -310,7 +320,7 @@ export function secondKitLead(k) {
310
320
  /** the kit section's own rules are the first kit's; this says so */
311
321
  export function secondKitRule(k) {
312
322
  const s = k.second;
313
- return `The rules in this section are for ${k.name} components. ${s.name}${s.pkg ? ` (\`${s.pkg}\`)` : ''} is imported in ${s.files} files${withoutWords(s, k.name)}. On ${s.name} components, copy how nearby files style them.`;
323
+ return `The rules in this section are for ${k.name} components. ${s.name}${s.pkg ? ` (\`${s.pkg}\`)` : ''} is imported in ${s.files} files${withoutWords(s, k.name)}. On ${s.name} components, ${secondKitHow(s)}`;
314
324
  }
315
325
 
316
326
  // ---------- shadcn in utility-class mode (words approved by Greg, 2026-10-01) ----------