roast-my-design-system 8.4.6 → 8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "8.4.6",
3
+ "version": "8.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": [
@@ -4,7 +4,7 @@
4
4
  * compile-skill.mjs — brace/string-aware regex parsing,
5
5
  * no AST dependency — then extended to scan the whole repo, not one ui/ dir.
6
6
  */
7
- import { join, basename } from 'node:path';
7
+ import { join, basename, posix } from 'node:path';
8
8
  import { readSource } from './walk.mjs';
9
9
 
10
10
  const QUOTES = new Set(['"', "'", '`']);
@@ -161,6 +161,37 @@ function tagCounts(src, re) {
161
161
  return counts;
162
162
  }
163
163
 
164
+ const stripExt = (f) => f.replace(/\.[cm]?[jt]sx?$/, '');
165
+ /**
166
+ * Which of several same-named definitions a file's `<Name` means, read from
167
+ * its imports: `import { Button } from '../ui'` or `'./ButtonV2'`, relative
168
+ * or through an alias (`@/ui/Button`, matched as a path suffix). A barrel
169
+ * (`../ui` → ui/index.ts) resolves to the copy inside that folder. A file that
170
+ * defines the name itself means its own. Returns [def] or null when unsure.
171
+ */
172
+ function importedDef(src, file, name, defs) {
173
+ const own = defs.find((d) => d.file === file);
174
+ if (own) return [own];
175
+ for (const m of src.matchAll(/import\s+(?:type\s+)?([^'";]*?)\s+from\s*['"]([^'"]+)['"]/g)) {
176
+ const clause = m[1];
177
+ const named = /\{([^}]*)\}/.exec(clause)?.[1] ?? '';
178
+ const imports = named.split(',').map((x) => x.trim().replace(/^type\s+/, '')).some((x) => x === name || new RegExp(`\\s+as\\s+${name}$`).test(x))
179
+ || new RegExp(`^${name}\\b`).test(clause.trim());
180
+ if (!imports) continue;
181
+ const spec = m[2];
182
+ let target = null, suffix = null;
183
+ if (spec.startsWith('.')) target = posix.normalize(posix.join(posix.dirname(file), spec));
184
+ else { const a = /^(?:@|~|#)\/(.+)$/.exec(spec); if (a) suffix = a[1]; else return null; }
185
+ const hit = defs.filter((d) => {
186
+ const f = stripExt(d.file);
187
+ if (target !== null) return f === target || f === `${target}/index` || d.file.startsWith(`${target}/`);
188
+ return f === suffix || f.endsWith(`/${suffix}`) || f.endsWith(`/${suffix}/index`) || d.file.includes(`/${suffix}/`);
189
+ });
190
+ return hit.length === 1 ? hit : null;
191
+ }
192
+ return null;
193
+ }
194
+
164
195
  /**
165
196
  * Harvest all components: definitions (with variants/props) + usages across the
166
197
  * repo. `codeFiles` are relative paths; returns { components, totalDefined }.
@@ -208,7 +239,13 @@ export function harvestComponents(root, codeFiles) {
208
239
  for (const [name, n] of tagCounts(src, /<([A-Z]\w*)(?=[\s/>]|$)/g)) {
209
240
  const defs = byName.get(name);
210
241
  if (!defs) continue;
211
- for (const def of defs) {
242
+ // Two components of one name: the use belongs to the one the file
243
+ // imports. Crediting both made src/ui/Button and a copy in
244
+ // features/invoices each "used 12x" (8 and 4 in truth), so neither read
245
+ // as the canon (Ledgerly, 2026-09-24). Unresolved imports keep the old
246
+ // rule and credit every copy.
247
+ const owners = defs.length > 1 ? importedDef(src, file, name, defs) ?? defs : defs;
248
+ for (const def of owners) {
212
249
  if (def.file === file) continue; // internal render/recursion, not adoption
213
250
  def.usageCount += n;
214
251
  if (def.usedIn.length < 8) def.usedIn.push(file);
@@ -308,6 +308,7 @@ export function harvestTokens(root, styleFiles, codeFiles) {
308
308
  fontSizes = new Tally(), fontFamilies = new Tally(), shadows = new Tally();
309
309
  const twColors = new Tally(), twSpacing = new Tally(), twRadii = new Tally(), twTextSizes = new Tally();
310
310
  const twArbitrary = new Tally();
311
+ const themeFonts = new Tally(); // stacks a Tailwind @theme declares
311
312
  let inlineStyleCount = 0;
312
313
  const inlineStyleFiles = new Map();
313
314
  // !important is the cascade admitting defeat; counted per style file.
@@ -456,6 +457,21 @@ export function harvestTokens(root, styleFiles, codeFiles) {
456
457
  if (isTokenRef(v)) continue;
457
458
  fontFamilies.add(v, file);
458
459
  }
460
+ // Tailwind v4 declares the system's typefaces as --font-* rows in @theme,
461
+ // with no font-family line anywhere: Ledgerly's Inter lived only there, so
462
+ // one legacy "Helvetica Neue" read as the system face (2026-09-24). Kept
463
+ // apart from the declarations the report counts: a theme naming its faces
464
+ // is the system, not sprawl, and Ghost's theme carries a 21-font picker
465
+ // its users choose from. --font-weight-* is a weight scale, and the
466
+ // feature and variation settings are settings on a face.
467
+ for (const b of text.matchAll(/@theme[^{]*\{([\s\S]*?)\n\}/g)) {
468
+ for (const d of b[1].matchAll(/--font-([\w-]+)\s*:\s*([^;{}]+)[;}]/g)) {
469
+ if (d[1].includes('--') || /^(?:weight|feature|variation)(-|$)/.test(d[1])) continue;
470
+ const v = d[2].trim().replace(/\s+/g, ' ');
471
+ if (isTokenRef(v) || v === 'initial' || v === 'inherit') continue;
472
+ themeFonts.add(v, file);
473
+ }
474
+ }
459
475
  for (const m of text.matchAll(SHADOW_PROPS)) shadows.add(m[1].trim().replace(/\s+/g, ' '), file);
460
476
  };
461
477
 
@@ -750,6 +766,8 @@ export function harvestTokens(root, styleFiles, codeFiles) {
750
766
  radii: radii.toJSON(),
751
767
  fontSizes: fontSizes.toJSON(),
752
768
  fontFamilies: fontFamilies.toJSON(),
769
+ // faces a Tailwind v4 @theme declares: the system's own, not counted
770
+ themeFonts: themeFonts.toJSON(),
753
771
  shadows: shadows.toJSON(),
754
772
  tailwind: {
755
773
  colors: twColors.toJSON(),
@@ -197,6 +197,11 @@ const STYLING_DEPS = [
197
197
  { pkg: 'class-variance-authority', label: 'CVA' },
198
198
  ];
199
199
 
200
+ // Plain names a team gives its own shared component folder, checked in order.
201
+ // A bare ui/ at the root is left out: there it is usually the whole front
202
+ // end (Azure-ipam), not a shelf of shared parts.
203
+ const OWN_UI_DIRS = ['src/ui', 'app/ui', 'src/shared/ui', 'shared/ui', 'src/lib/ui', 'src/design-system'];
204
+
200
205
  /** Framework + design-system classification (1.0 logic, widened for monorepos). */
201
206
  export function profileRepo(root, files) {
202
207
  const pkg = readJSON(join(root, 'package.json')) || {};
@@ -251,21 +256,28 @@ export function profileRepo(root, files) {
251
256
  }
252
257
 
253
258
  const componentsJson = readJSON(join(root, 'components.json'));
254
- const uiDir = ['src/components/ui', 'components/ui', 'app/components/ui'].find((d) => existsSync(join(root, d)));
255
- const isShadcn = Boolean(componentsJson) || Boolean(uiDir);
259
+ const shadcnUiDir = ['src/components/ui', 'components/ui', 'app/components/ui'].find((d) => existsSync(join(root, d)));
260
+ const isShadcn = Boolean(componentsJson) || Boolean(shadcnUiDir);
261
+ // A team's own shared folder under a plainer name (src/ui, shared/ui) is the
262
+ // UI folder too, when it holds components: Ledgerly keeps its 13 in src/ui
263
+ // and read as having none (2026-09-24). It names where the components live;
264
+ // it never makes the repo a shadcn one or its folder installed stock.
265
+ const ownUiDir = shadcnUiDir ? null : OWN_UI_DIRS.find((d) => !existsSync(join(root, d, 'package.json'))
266
+ && files.code.filter((f) => f.startsWith(`${d}/`) && /\.(tsx|jsx)$/.test(f)).length >= 3);
267
+ const uiDir = shadcnUiDir ?? ownUiDir;
256
268
 
257
269
  // Catalogue filenames as shipped by `shadcn add --all` (61 as of 2026-09).
258
270
  // Only used to recognise a vendored folder, never to judge one.
259
271
  const SHADCN_CATALOGUE = CATALOGUE;
260
272
  let catalogueNames = 0;
261
- if (uiDir) {
273
+ if (shadcnUiDir) {
262
274
  try {
263
- for (const f of readdirSync(join(root, uiDir))) {
275
+ for (const f of readdirSync(join(root, shadcnUiDir))) {
264
276
  if (SHADCN_CATALOGUE.has(f.replace(/\.[cm]?[jt]sx?$/, ''))) catalogueNames += 1;
265
277
  }
266
278
  } catch { /* unreadable */ }
267
279
  }
268
- const vendoredUi = Boolean(uiDir) && (Boolean(componentsJson) || catalogueNames >= 8);
280
+ const vendoredUi = Boolean(shadcnUiDir) && (Boolean(componentsJson) || catalogueNames >= 8);
269
281
 
270
282
  const knownLib = KNOWN_LIBRARIES.find((d) => deps[d.pkg]);
271
283
  const homegrown = files.code.filter((f) => /(^|\/)components\//.test(f) && !/\/components\/ui\//.test(f) && /\.(tsx|jsx)$/.test(f));
@@ -196,3 +196,24 @@ const HSL_TRIPLET =
196
196
  /^\s*(-?\d+(?:\.\d+)?)(?:deg)?\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%(?:\s*\/\s*(\d+(?:\.\d+)?%?))?\s*$/;
197
197
  // bare RGB channels (dub: --bg-default: 255 255 255), see harvest/tokens.mjs
198
198
  const RGB_TRIPLET = /^\s*(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})(?:\s*\/\s*\d+(?:\.\d+)?%?)?\s*$/;
199
+
200
+ // Gamma-encoded sRGB (0-255) → linear, the inverse of `gamma` above.
201
+ const linear = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
202
+ /**
203
+ * A colour in OKLab ({ L, a, b }), where a straight-line distance tracks what
204
+ * the eye sees; null when the value is not a literal colour. Used to find the
205
+ * theme colour nearest to a palette class, hue included.
206
+ */
207
+ export function oklab(value) {
208
+ const c = parseColor(value);
209
+ if (!c) return null;
210
+ const r = linear(c.r), g = linear(c.g), b = linear(c.b);
211
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
212
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
213
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
214
+ return {
215
+ L: 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
216
+ a: 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
217
+ b: 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
218
+ };
219
+ }
@@ -1,7 +1,7 @@
1
1
  // Single version constant for the engine — imported by diagnose (report
2
2
  // footer) and rules (generated-by line). This is the bump spot that used to
3
3
  // live as a const inside diagnose/index.mjs.
4
- export const VERSION = '8.4.6';
4
+ export const VERSION = '8.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,6 +18,8 @@ import { extraDeclarations, fontDeclarations } from '../lib/declarations.mjs';
18
18
  import { typefaceOf, GENERIC_FONTS } from '../lib/typefaces.mjs';
19
19
  import { kitPaintFindings } from '../lib/kitpaint.mjs';
20
20
  import { PALETTE_CLASS_RE, GREY_HUE_RE, DARK_WB_RE, DEMO_PATH_RE, blankComments } from '../harvest/paint.mjs';
21
+ import { TAILWIND_DEFAULTS } from '../profiles/tailwind-defaults.mjs';
22
+ import { oklab } from '../lib/color.mjs';
21
23
 
22
24
  // What this engine measures — shipped with every result, clean or not.
23
25
  export const CHECKS = [
@@ -145,6 +147,7 @@ export function validateContent(content, k) {
145
147
  // foreground name, a bg- class a surface or background name
146
148
  const names = paletteRule === 'tailwind' ? (tw.names ?? []) : ['primary', 'foreground', 'muted-foreground', 'background', 'border'];
147
149
  const pick = (re) => names.find((n) => re.test(n)) ?? names[0] ?? 'brand';
150
+ const values = paletteRule === 'tailwind' ? tw.values ?? {} : {};
148
151
  // a class named in a comment paints nothing; blanked, not cut, so the
149
152
  // line numbers below still hold
150
153
  const code = blankComments(text);
@@ -155,12 +158,20 @@ export function validateContent(content, k) {
155
158
  for (const m of hits.sort((a, b) => a.index - b.index)) {
156
159
  const cls = m[0];
157
160
  const util = cls.replace(/^((?:[\w-]+:)*[a-z]+)-.*$/, '$1');
158
- const example = /(^|:)(?:text|placeholder|caret|decoration)$/.test(util) ? pick(/ink|text|fg|foreground/) : /(^|:)bg$/.test(util) ? pick(/surface|bg|background|canvas/) : pick(/border|edge|line|ring/) ;
159
- if (retuned.has(cls.replace(/^(?:[\w-]+:)*[a-z]+-/, '').replace(/\/\d+$/, ''))) continue;
161
+ const shade = cls.replace(/^(?:[\w-]+:)*[a-z]+-/, '').replace(/\/\d+$/, '');
162
+ const role = utilRole(util);
163
+ // the theme colour nearest by value among the names that suit the
164
+ // utility; the old pick by name alone only when none is close
165
+ const example = nearestThemeName(shade, role, values)
166
+ ?? (role === 'text' ? pick(/ink|text|fg|foreground/) : role === 'surface' ? pick(/surface|bg|background|canvas/) : pick(/border|edge|line|ring/));
167
+ if (retuned.has(shade)) continue;
160
168
  if (!hasFamily(cls)) continue;
169
+ const word = hueWord(shade, names);
161
170
  add('palette-class', 'violation', m.index,
162
171
  `Palette class ${cls} where the theme names its colours (${themeFile}).`,
163
- `Use a theme token as the class (${util}-${example}). If no token fits, add one to the theme once, for example success, and use ${util}-success.`);
172
+ `Use a theme token as the class (${util}-${example}). ${names.includes(word)
173
+ ? `If no token fits, add ${/^[aeiou]/.test(word) ? 'an' : 'a'} ${word} shade to the theme once and use it by name.`
174
+ : `If no token fits, add one to the theme once, for example ${word}, and use ${util}-${word}.`}`);
164
175
  }
165
176
  }
166
177
 
@@ -306,6 +317,59 @@ export function validateContent(content, k) {
306
317
  return { findings, checked: checksFor(k) };
307
318
  }
308
319
 
320
+ // ---------- the theme colour a palette class stands in for ----------
321
+ // Which theme names suit a utility, read from the name: a text class wants an
322
+ // ink, a bg class a surface, a border class an edge. A name that says none of
323
+ // these (brand, warning, positive) is an accent and suits every utility.
324
+ const TEXT_NAME_RE = /ink|text|fg|foreground/;
325
+ const SURFACE_NAME_RE = /surface|(^|-)bg(-|$)|background|canvas|soft|tint|wash/;
326
+ const EDGE_NAME_RE = /border|edge|line|ring|divider|outline/;
327
+ function utilRole(util) {
328
+ if (/(^|:)(?:text|placeholder|caret|decoration|fill|stroke)$/.test(util)) return 'text';
329
+ if (/(^|:)bg$/.test(util)) return 'surface';
330
+ if (/(^|:)(?:border|ring|outline|divide)$/.test(util)) return 'edge';
331
+ return 'any';
332
+ }
333
+ function suits(name, role) {
334
+ const text = TEXT_NAME_RE.test(name), surface = SURFACE_NAME_RE.test(name), edge = EDGE_NAME_RE.test(name);
335
+ if (role === 'any' || (!text && !surface && !edge)) return true;
336
+ return role === 'text' ? text && !surface && !edge : role === 'surface' ? surface : edge;
337
+ }
338
+ // Distance in OKLab with the two colour axes doubled, so a pale amber lands on
339
+ // the pale warning surface rather than on the near-white canvas beside it.
340
+ const hueDistance = (x, y) => Math.hypot(x.L - y.L, 2 * (x.a - y.a), 2 * (x.b - y.b));
341
+ // Past this, the nearest theme colour is a different colour, not a stand-in
342
+ // (bg-blue-600 against a theme of greys), and the old pick by name answers.
343
+ const CLOSE = 0.2;
344
+ /** The suitable theme name nearest to a palette shade's Tailwind value, or null. */
345
+ function nearestThemeName(shade, role, values) {
346
+ const from = oklab(TAILWIND_DEFAULTS[shade] ?? '');
347
+ if (!from) return null;
348
+ let best = null;
349
+ for (const [name, value] of Object.entries(values)) {
350
+ if (!suits(name, role)) continue;
351
+ const to = oklab(value);
352
+ if (!to) continue;
353
+ const d = hueDistance(from, to);
354
+ if (!best || d < best.d) best = { name, d };
355
+ }
356
+ return best && best.d <= CLOSE ? best.name : null;
357
+ }
358
+ // The word for a new token follows the hue, in the theme's own vocabulary when
359
+ // it has one (Ledgerly says positive and negative, not success and danger).
360
+ const HUE_WORDS = [
361
+ [/^(?:red|rose)-/, ['negative', 'danger', 'error', 'destructive']],
362
+ [/^(?:orange|amber|yellow)-/, ['warning', 'caution']],
363
+ [/^(?:green|emerald|lime)-/, ['positive', 'success']],
364
+ [/^(?:blue|sky|cyan)-/, ['info']],
365
+ ];
366
+ function hueWord(shade, names) {
367
+ const words = HUE_WORDS.find(([re]) => re.test(shade))?.[1];
368
+ if (!words) return GREY_HUE_RE.test(`-${shade}`) ? 'muted' : 'accent';
369
+ return words.find((w) => names.some((n) => n === w || n.startsWith(`${w}-`)))
370
+ ?? { negative: 'danger', warning: 'warning', positive: 'success', info: 'info' }[words[0]];
371
+ }
372
+
309
373
  function looksLikeCss(text) {
310
374
  // no JSX tags, has a `{ ...: ` declaration shape → treat as stylesheet.
311
375
  // Sniffed on the head of the text with bounded patterns: the old version
@@ -88,7 +88,9 @@ export function loadKnowledge(root) {
88
88
  const fontSizesSeen = new Map((tokens.fontSizes ?? []).map((f) => [f.value, f.count]));
89
89
  const shadowsSeen = new Map((tokens.shadows ?? []).map((s) => [s.value, s.count]));
90
90
  const faceCounts = new Map();
91
- for (const f of tokens.fontFamilies ?? []) {
91
+ // a face the @theme declares is on-system, so a stylesheet using it is not
92
+ // "a new typeface" (tokens.themeFonts, harvest/tokens.mjs)
93
+ for (const f of [...(tokens.themeFonts ?? []), ...(tokens.fontFamilies ?? [])]) {
92
94
  const face = typefaceOf(f.value);
93
95
  if (face) faceCounts.set(face, (faceCounts.get(face) ?? 0) + f.count);
94
96
  }
@@ -85,7 +85,9 @@ export function getContext(k, { path = null } = {}) {
85
85
  ? `SPACING: this repo has no scale, just ${k.spacingSeen.size} values in circulation. Reuse one rather than inventing value number ${k.spacingSeen.size + 1}; do not treat them as a system.`
86
86
  : 'SPACING: nothing on record yet. Whatever you pick, the next agent will copy; pick something divisible by 4.');
87
87
 
88
- const faces = distinctTypefaces(t.fontFamilies ?? []);
88
+ // the faces the theme declares come first: they are the system, whatever a
89
+ // stylesheet repeats more often
90
+ const faces = distinctTypefaces([...(t.themeFonts ?? []), ...(t.fontFamilies ?? [])]);
89
91
  if (faces.length) L.push(`TYPE: ${faces.slice(0, 3).join(', ')}. No new typefaces, no hand-declared font stacks.`);
90
92
  L.push('DISCIPLINE: no static style={{ }}, no !important, no new arbitrary values. Match the neighbouring component.');
91
93
  if (k.agentFiles.length === 0) L.push('NOTE: this repo has no agent rules file; these lines are the only guardrail. Suggest running npx roast-my-design-system@latest --apply.');
@@ -112,24 +114,66 @@ export function getContext(k, { path = null } = {}) {
112
114
  // ---------- roast_find_component ----------
113
115
  const camelWords = (name) => name.split(/(?=[A-Z])/).map((w) => w.toLowerCase()).filter(Boolean);
114
116
 
115
- export function findComponent(k, { query } = {}) {
116
- if (typeof query !== 'string' || !query.trim()) return invalidInput('Give me a component name or intent, like "icon button" or "Modal".');
117
- const q = query.trim();
118
- const qWords = q.toLowerCase().split(/[\s_-]+/).filter(Boolean);
117
+ // Other names people give the same piece of UI. Asked only when nothing in the
118
+ // repo carries the word itself, and the answer says it went by purpose: an
119
+ // agent asking for a "banner" should hear that <Alert> is this repo's one
120
+ // (Ledgerly, 2026-09-24), never that none exists.
121
+ const SYNONYMS = {
122
+ banner: ['alert', 'callout', 'notice'], notice: ['alert', 'callout', 'banner'], callout: ['alert', 'notice', 'banner'],
123
+ alert: ['banner', 'callout', 'notice'], flash: ['alert', 'toast'], notification: ['toast', 'alert'],
124
+ snackbar: ['toast'], toast: ['snackbar', 'notification'],
125
+ dropdown: ['select', 'combobox', 'menu'], picker: ['select', 'combobox'], combobox: ['select'], listbox: ['select'],
126
+ select: ['dropdown', 'combobox', 'picker'],
127
+ modal: ['dialog'], dialog: ['modal'], popup: ['popover', 'modal', 'dialog'], lightbox: ['modal', 'dialog'],
128
+ drawer: ['sheet'], sheet: ['drawer'], flyout: ['popover', 'drawer'],
129
+ tooltip: ['hint'], hint: ['tooltip'],
130
+ chip: ['badge', 'tag', 'pill'], tag: ['badge', 'chip', 'pill'], pill: ['badge', 'chip', 'tag'], badge: ['tag', 'chip', 'pill'],
131
+ spinner: ['loader', 'loading'], loader: ['spinner', 'loading'],
132
+ toggle: ['switch'], switch: ['toggle'],
133
+ textbox: ['input'], textfield: ['input'], field: ['input'],
134
+ tile: ['card'], panel: ['card'],
135
+ datatable: ['table'], grid: ['table'],
136
+ collapse: ['accordion', 'collapsible'], collapsible: ['accordion'], accordion: ['collapsible'],
137
+ pager: ['pagination'], btn: ['button'], cta: ['button'],
138
+ };
119
139
 
140
+ function scoreComponents(k, qWords, { every = false } = {}) {
120
141
  const scored = [];
121
142
  for (const c of k.components.filter((x) => !x.isPage)) {
122
143
  const words = camelWords(c.name);
123
144
  const nameLc = c.name.toLowerCase();
124
- let s = 0;
145
+ let s = 0, missed = 0;
125
146
  if (nameLc === qWords.join('')) s += 100;
126
147
  for (const w of qWords) {
127
148
  // whole camel-word or word-prefix matches only: "board" must not match
128
149
  // inside "Onboarding", or every intent finds a wrong component
129
150
  if (words.includes(w)) s += 40;
130
151
  else if (w.length >= 3 && words.some((x) => x.startsWith(w))) s += 15;
152
+ else missed += 1;
153
+ }
154
+ if (s > 0 && !(every && missed)) scored.push({ c, s });
155
+ }
156
+ return scored;
157
+ }
158
+
159
+ export function findComponent(k, { query } = {}) {
160
+ if (typeof query !== 'string' || !query.trim()) return invalidInput('Give me a component name or intent, like "icon button" or "Modal".');
161
+ const q = query.trim();
162
+ const qWords = q.toLowerCase().split(/[\s_-]+/).filter(Boolean);
163
+
164
+ let scored = scoreComponents(k, qWords);
165
+ let bySynonym = null;
166
+ if (!scored.length) {
167
+ // one other name at a time, in the order listed: the first that names a
168
+ // real component answers, so "dropdown" finds Select before any Menu
169
+ for (const [i, w] of qWords.entries()) {
170
+ for (const alt of SYNONYMS[w] ?? []) {
171
+ // every word must match: a "date picker" is not any Select
172
+ const hit = scoreComponents(k, qWords.map((x, j) => (j === i ? alt : x)), { every: true });
173
+ if (hit.length) { scored = hit; bySynonym = alt; break; }
174
+ }
175
+ if (bySynonym) break;
131
176
  }
132
- if (s > 0) scored.push({ c, s });
133
177
  }
134
178
  if (!scored.length) {
135
179
  return `No component matching "${q}" is defined in this repo.`;
@@ -140,6 +184,7 @@ export function findComponent(k, { query } = {}) {
140
184
  .sort((a, b) => b.usageCount - a.usageCount);
141
185
  const [top, second] = relevant;
142
186
  const out = [];
187
+ if (bySynonym) out.push(`Nothing here is named "${q}". Searched for "${bySynonym}" instead, another name for the same kind of component:`);
143
188
 
144
189
  // the tie rule: two live candidates close in usage = no clear canon, say so
145
190
  const tie = second && second.usageCount > 0 && top.usageCount / Math.max(second.usageCount, 1) < 1.5
@@ -154,12 +199,10 @@ export function findComponent(k, { query } = {}) {
154
199
  out.push(`Canonical: ${componentLine(top, k).trim()}`);
155
200
  if (top.usageExample) out.push(`Most common real usage, as in ${top.usageExample.file} (${top.usageExample.matches} of ${top.usageExample.total} usages): ${top.usageExample.snippet}`);
156
201
  const dupe = k.dupeByName.get(top.name);
157
- if (dupe) {
158
- const others = dupe.files.map((f) => (typeof f === 'string' ? f : f.file)).filter((f) => f !== top.file);
159
- out.push(`Avoid: ${others.join(', ')} (same name, competing copies).`);
160
- }
202
+ const others = dupe ? dupe.files.map((f) => (typeof f === 'string' ? f : f.file)).filter((f) => f !== top.file) : [];
203
+ if (others.length) out.push(`Avoid: ${others.join(', ')} (same name, competing copies).`);
161
204
  for (const c of relevant.slice(1, 3)) {
162
- if (c.usageCount === 0) out.push(`Avoid <${c.name}> (${c.file}): defined but never imported.`);
205
+ if (c.usageCount === 0 && !others.includes(c.file)) out.push(`Avoid <${c.name}> (${c.file}): defined but never imported.`);
163
206
  }
164
207
  }
165
208
  return out.join('\n');
@@ -71,7 +71,9 @@ function readTheme(root, styleFiles) {
71
71
  // grey is only drift when the theme has a grey of its own to use instead
72
72
  // (hey.xyz names four brand pinks and no grey, 2026-09-16).
73
73
  const GREY_NAME_RE = /gr[ae]y|neutral|slate|zinc|stone|surface|background|foreground|(^|-)(bg|fg|text|border|line|divider|muted|ink|base|canvas|charcoal|contrast)(-|\d|$)/;
74
- function themeFamilies(root, styleFiles, names) {
74
+ /** Each theme name's value, followed through var() to a literal (null when it
75
+ * leads nowhere readable). Tailwind's own palette answers for --color-x. */
76
+ function resolveValues(root, styleFiles, names) {
75
77
  const defs = new Map();
76
78
  for (const f of styleFiles ?? []) {
77
79
  for (const d of read(join(root, f)).matchAll(/(--[\w-]+)\s*:\s*([^;{}]+);/g)) if (!defs.has(d[1])) defs.set(d[1], d[2].trim());
@@ -84,9 +86,12 @@ function themeFamilies(root, styleFiles, names) {
84
86
  const next = defs.get(m[1]) ?? (pal ? TAILWIND_DEFAULTS[pal[1]] : undefined) ?? m[2];
85
87
  return next === undefined ? null : resolve(next.trim(), depth + 1);
86
88
  };
89
+ return new Map(names.map(([n, v]) => [n, resolve(v)]));
90
+ }
91
+ function themeFamilies(values) {
87
92
  const out = { grey: false, colour: false };
88
- for (const [n, v] of names) {
89
- const c = parseColor(resolve(v) ?? '');
93
+ for (const [n, v] of values) {
94
+ const c = parseColor(v ?? '');
90
95
  const grey = c ? Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b) <= 12 : GREY_NAME_RE.test(n);
91
96
  out[grey ? 'grey' : 'colour'] = true;
92
97
  }
@@ -152,6 +157,7 @@ export default {
152
157
  : `used as classes ${uses} times: defined, but not adopted yet`,
153
158
  twRaw ? `Tailwind ${twRaw}` : 'Tailwind v4 theme block',
154
159
  ];
160
+ const values = resolveValues(root, files.styles, theme.own.map((x) => [x, theme.names.get(x)]));
155
161
  profile.designSystem = { kind: 'tailwind', name: 'a Tailwind theme', confidence: 'high', cssVariables: true };
156
162
  profile.tailwind = {
157
163
  file: theme.file,
@@ -160,7 +166,10 @@ export default {
160
166
  // Tailwind names given the repo's own colours: on-theme, never drift
161
167
  retuned: theme.tuned,
162
168
  // which palette classes have a theme colour to stand in for them
163
- families: themeFamilies(root, files.styles, theme.own.map((x) => [x, theme.names.get(x)])),
169
+ families: themeFamilies(values),
170
+ // name → literal colour, so a fix can point at the theme colour nearest
171
+ // to the palette class it replaces (bg-amber-50 → bg-warning-soft)
172
+ values: Object.fromEntries([...values].filter(([, v]) => v)),
164
173
  uses,
165
174
  usedIn,
166
175
  // a theme nobody uses is not the system yet: shown with receipts, not scored