roast-my-design-system 8.4.6 → 8.6.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
@@ -221,7 +221,7 @@ It writes a first draft anyway and runs `roast_validate` before saving:
221
221
  Second draft: `color: 'text.secondary'`, `p: 1.5`, the radius read from the theme. `roast_validate` again:
222
222
 
223
223
  ```
224
- No measured violations found. Checked: hardcoded colours vs the token set, near-identical colour twins, off-scale spacing, off-scale radii, font sizes and shadows, typefaces outside the system, arbitrary bracket values, static inline style blocks, !important, duplicate component definitions, colours and pixel sizes written onto kit components where the theme has a value.
224
+ No measured violations found. Checked: hardcoded colours vs the token set, near-identical colour twins, off-scale spacing, off-scale radii, font sizes and shadows, typefaces outside the system, arbitrary bracket values, static inline style blocks, !important, duplicate component definitions, new colour tokens that twin an existing token, imports of a duplicate the canonical copy replaces, colours and pixel sizes written onto kit components where the theme has a value.
225
225
  ```
226
226
 
227
227
  Four calls, under 800 tokens in total, and the new component reads the theme instead of adding colour number 44. `roast_review` then checks the whole diff the same way before the agent says it is done.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "8.4.6",
3
+ "version": "8.6.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
 
@@ -624,12 +640,23 @@ export function harvestTokens(root, styleFiles, codeFiles) {
624
640
  for (const [f, n] of e.files) head.files.set(f, (head.files.get(f) ?? 0) + n);
625
641
  }
626
642
  const mergedColors = [...byCanon.values()].sort((a, b) => b.count - a.count);
643
+ // The token names that state each colour first (dark and density variants
644
+ // left out, as in the counts). Two differently named tokens holding the same
645
+ // colour are how drift gets promoted into the palette; the near-pair count
646
+ // reads these names (lib/nearpairs.mjs, lib/tokentwins.mjs).
647
+ const namesByCanon = new Map();
648
+ for (const d of baseDefs) {
649
+ const list = namesByCanon.get(d.canon) ?? [];
650
+ if (!list.includes(`--${d.name}`)) list.push(`--${d.name}`);
651
+ namesByCanon.set(d.canon, list);
652
+ }
627
653
  const colorList = mergedColors.map((e) => ({
628
654
  value: e.value,
629
655
  count: e.count,
630
656
  files: [...e.files.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([file, count]) => ({ file, count })),
631
657
  isToken: e.spellings.some((v) => tokenDefined.has(v)),
632
658
  ...(e.spellings.length > 1 ? { spellings: e.spellings } : {}),
659
+ ...(namesByCanon.has(canonical(e.value)) ? { names: namesByCanon.get(canonical(e.value)).slice(0, 6) } : {}),
633
660
  }));
634
661
 
635
662
  // Worst offenders: files carrying the most stray styling (hardcoded colours
@@ -750,6 +777,8 @@ export function harvestTokens(root, styleFiles, codeFiles) {
750
777
  radii: radii.toJSON(),
751
778
  fontSizes: fontSizes.toJSON(),
752
779
  fontFamilies: fontFamilies.toJSON(),
780
+ // faces a Tailwind v4 @theme declares: the system's own, not counted
781
+ themeFonts: themeFonts.toJSON(),
753
782
  shadows: shadows.toJSON(),
754
783
  tailwind: {
755
784
  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));
@@ -0,0 +1,93 @@
1
+ /**
2
+ * An import of the copy roast says to avoid. roast_find_component answers
3
+ * "Canonical: src/ui/Button.tsx … Avoid: src/features/invoices/ButtonV2.tsx",
4
+ * but on the Ledgerly demo (2026-09-24) an agent asked to match a spec colour
5
+ * imported the avoided copy anyway, because its hard-coded blue matched, and
6
+ * nothing in the live checks said a word: the colour lives inside the copy,
7
+ * not in the file under review. This names the canonical copy instead.
8
+ *
9
+ * Only a clear canon is named: the same tie rule roast_find_component uses
10
+ * (the most used copy must lead the next by half again). Two copies in equal
11
+ * use have no wrong pick, and saying otherwise would be inventing one.
12
+ */
13
+ import { posix } from 'node:path';
14
+
15
+ const CODE_EXT_RE = /\.(?:tsx|ts|jsx|js|mjs|cjs|vue|svelte)$/;
16
+ const stem = (f) => f.replace(CODE_EXT_RE, '').replace(/\/index$/, '');
17
+
18
+ /** Each name a text imports, with where from: [{ name, spec, index }]. */
19
+ export function importsOf(text) {
20
+ const out = [];
21
+ for (const m of String(text ?? '').matchAll(/(?:^|\n)\s*import\s+(?:type\s+)?([^'";]*?)\s+from\s+['"]([^'"]+)['"]/g)) {
22
+ const [, clause, spec] = m;
23
+ const index = m.index + m[0].indexOf('import');
24
+ const named = /\{([^}]*)\}/.exec(clause);
25
+ for (const part of named ? named[1].split(',') : []) {
26
+ const name = part.trim().replace(/^type\s+/, '').split(/\s+as\s+/)[0].trim();
27
+ if (name) out.push({ name, spec, index });
28
+ }
29
+ // the default import takes the copy's own name
30
+ const def = clause.replace(/\{[^}]*\}/, '').replace(/\*\s+as\s+\w+/, '').replace(/,/g, ' ').trim();
31
+ if (/^[A-Z]\w*$/.test(def)) out.push({ name: def, spec, index, isDefault: true });
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /** The file among `candidates` an import specifier points at, or null. */
37
+ export function resolveSpec(spec, file, candidates) {
38
+ if (spec.startsWith('.') && file) {
39
+ const target = posix.normalize(posix.join(posix.dirname(file), spec));
40
+ return candidates.find((c) => stem(c) === target || stem(c) === stem(target)) ?? null;
41
+ }
42
+ // an alias (@/features/invoices/ButtonV2) or no file to resolve against:
43
+ // the path's tail must name the copy, folder included
44
+ const tail = stem(spec.replace(/^(?:\.{1,2}\/)+/, '').replace(/^[@~#]\//, ''));
45
+ if (!tail.includes('/') && !spec.startsWith('.')) return null; // a package, not a file
46
+ return candidates.find((c) => stem(c) === tail || stem(c).endsWith(`/${tail}`)) ?? null;
47
+ }
48
+
49
+ /**
50
+ * The clear canon among the copies of a name, or null on a tie.
51
+ * copies: [{ file, usageCount }]
52
+ */
53
+ export function canonicalCopy(copies) {
54
+ const [top, second] = [...copies].sort((a, b) => b.usageCount - a.usageCount);
55
+ if (!top || !top.usageCount) return null;
56
+ if (second && second.usageCount > 0 && top.usageCount / second.usageCount < 1.5) return null;
57
+ return top;
58
+ }
59
+
60
+ /**
61
+ * Findings for imports of a non-canonical duplicate that the change ADDS.
62
+ * @param dupes Map<name, { copies: [{ file, usageCount }], strays?: Map<file, [colour]> }>
63
+ * @param before the file before the change: null for a new file, undefined
64
+ * when unknown (every import is then judged)
65
+ * @returns [{ rule: 'avoided-copy', index, name, from, canonical, message, fix }]
66
+ */
67
+ export function avoidedImportFindings(text, { file = null, before, dupes } = {}) {
68
+ if (!dupes?.size) return [];
69
+ const key = (i) => {
70
+ const d = dupes.get(i.name);
71
+ const hit = d ? resolveSpec(i.spec, file, d.copies.map((c) => c.file)) : null;
72
+ return hit ? `${i.name}|${hit}` : null;
73
+ };
74
+ const had = new Set(before == null ? [] : importsOf(before).map(key).filter(Boolean));
75
+ const out = [], seen = new Set();
76
+ for (const i of importsOf(text)) {
77
+ const d = dupes.get(i.name);
78
+ if (!d) continue;
79
+ const from = resolveSpec(i.spec, file, d.copies.map((c) => c.file));
80
+ if (!from || from === file || had.has(`${i.name}|${from}`) || seen.has(`${i.name}|${from}`)) continue;
81
+ seen.add(`${i.name}|${from}`);
82
+ const canon = canonicalCopy(d.copies);
83
+ if (!canon || canon.file === from) continue;
84
+ const mine = d.copies.find((c) => c.file === from);
85
+ const strays = d.strays?.get(from) ?? [];
86
+ out.push({
87
+ rule: 'avoided-copy', index: i.index, name: i.name, from, canonical: canon.file,
88
+ message: `Imports <${i.name}> from ${from}, one of ${d.copies.length} competing copies. The canonical one is ${canon.file} (used ${canon.usageCount}x; this copy ${mine?.usageCount ?? 0}x).${strays.length ? ` This copy hard-codes ${strays.slice(0, 3).join(', ')}${strays.length > 3 ? ` and ${strays.length - 3} more` : ''}.` : ''}`,
89
+ fix: `Import <${i.name}> from ${canon.file}. If the design needs something it lacks, add it there, not to the copy.`,
90
+ });
91
+ }
92
+ return out;
93
+ }
@@ -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
+ }
@@ -48,6 +48,14 @@ export { PALETTE_CLASS_RE, blankComments };
48
48
  // index of each in the text; null when the file is not a kit file, { exempt }
49
49
  // when it is not judged. Only meaningful when system.profile.kit is set.
50
50
  export { kitPaintFindings };
51
+ // Twin tokens and avoided copies (8.6.0). tokenTwinFindings(text, { before,
52
+ // others, tailwind }) words the colour tokens a stylesheet ADDS that twin one
53
+ // the repo already has (tokenDefsOf reads a stylesheet's tokens, light and
54
+ // dark, for `others`). avoidedImportFindings(text, { file, before, dupes })
55
+ // words a new import of a duplicate where a clear canonical copy exists.
56
+ // Same words as roast_validate, roast_review and --check.
57
+ export { tokenTwinFindings, tokenDefsOf } from './tokentwins.mjs';
58
+ export { avoidedImportFindings, canonicalCopy } from './avoidedimports.mjs';
51
59
 
52
60
  // Radius, font size, shadow and typeface: the patterns, so both checkers agree
53
61
  // on what a declaration is and what counts as a disciplined value.
@@ -1,11 +1,26 @@
1
1
  /**
2
2
  * Near-identical colour pairs (#f5f5f5 next to #f6f6f6): copy-paste, not
3
3
  * decisions. Hex-parseable colours only; a pair is "near" when every channel
4
- * sits within 8 of its twin and alpha matches. Two TOKENS sitting close are a
5
- * designed ramp (every good grey scale has near neighbours) and are skipped;
6
- * drift needs at least one hardcoded stray. Shared by diagnose and rules.
4
+ * sits within 8 of its twin and alpha matches. Two TOKENS sitting close are
5
+ * usually a designed ramp (every good grey scale has near neighbours) and are
6
+ * skipped; drift needs at least one hardcoded stray. The exception (8.6.0):
7
+ * two tokens named for different roles (overdue-soft next to warning-soft) are
8
+ * one colour under two names. Without it, a stray "fixed" by minting a twin
9
+ * token left the count and the score went up (Ledgerly, 2026-09-24).
10
+ * Shared by diagnose and rules.
7
11
  */
8
12
  import { parseColor } from './color.mjs';
13
+ import { canonical } from './color.mjs';
14
+ import { isRoleName, familyOf, tokenTwinColours } from './tokentwins.mjs';
15
+
16
+ // Two token colours whose names are roles in different families: a twin.
17
+ // Colours harvested before 8.6.0 carry no names and never pair this way.
18
+ // Same test as the live check (lib/tokentwins.mjs), so both say the same.
19
+ function twinTokens(x, y) {
20
+ const a = (x.names ?? []).filter(isRoleName), b = (y.names ?? []).filter(isRoleName);
21
+ return a.some((n) => b.some((m) => familyOf(n) !== familyOf(m)))
22
+ && tokenTwinColours(canonical(x.value), canonical(y.value));
23
+ }
9
24
 
10
25
  // Kept under its historical name (the MCP engine imports it): since 5.10.0 it
11
26
  // parses every literal colour space, not just hex, so an hsl or oklch
@@ -23,13 +38,14 @@ export function nearColorPairs(colorList) {
23
38
  for (let j = i + 1; j < hexes.length; j++) {
24
39
  const a = hexes[i].rgb, b = hexes[j].rgb;
25
40
  if (a.a !== b.a) continue;
26
- if (hexes[i].isToken && hexes[j].isToken) continue;
41
+ const tokens = hexes[i].isToken && hexes[j].isToken;
42
+ if (tokens && !twinTokens(hexes[i], hexes[j])) continue;
27
43
  // Entries are distinct strings by construction, so d = 0 means the same
28
44
  // colour written in two notations (hsl token, rgb stray): the strongest
29
45
  // twin there is, not a non-event. Rounded so the report never prints
30
46
  // 7.9199999.
31
47
  const d = Math.round(Math.max(Math.abs(a.r - b.r), Math.abs(a.g - b.g), Math.abs(a.b - b.b)) * 10) / 10;
32
- if (d <= 8) pairs.push({ a: hexes[i], b: hexes[j], d });
48
+ if (d <= 8) pairs.push({ a: hexes[i], b: hexes[j], d, ...(tokens ? { tokens } : {}) });
33
49
  }
34
50
  }
35
51
  // One twin per stray. A stray sitting inside a dense token ramp is within 8
@@ -38,15 +54,18 @@ export function nearColorPairs(colorList) {
38
54
  // finding is "this stray has a token twin", and the fix is one redirect,
39
55
  // so each stray keeps only its closest twin (a stray-stray pair survives
40
56
  // when it is the closest for either side).
41
- const best = new Map();
57
+ // Twin tokens are thinned the same way among themselves: each token keeps
58
+ // its closest twin.
59
+ const best = new Map(), bestTwin = new Map();
42
60
  for (const p of pairs) {
43
61
  for (const side of ['a', 'b']) {
44
62
  const c = p[side];
45
- if (c.isToken) continue;
46
- const cur = best.get(c.value);
47
- if (!cur || p.d < cur.d) best.set(c.value, p);
63
+ const into = p.tokens ? bestTwin : best;
64
+ if (c.isToken && !p.tokens) continue;
65
+ const cur = into.get(c.value);
66
+ if (!cur || p.d < cur.d) into.set(c.value, p);
48
67
  }
49
68
  }
50
- const kept = [...new Set(best.values())];
69
+ const kept = [...new Set([...best.values(), ...bestTwin.values()])];
51
70
  return kept.sort((x, y) => (y.a.count + y.b.count) - (x.a.count + x.b.count));
52
71
  }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Twin tokens: a new colour token whose value is a near-identical copy of a
3
+ * token the system already has (--color-overdue-soft: #fff4e5 next to
4
+ * --color-warning-soft: #fdf5e6). Found on the Ledgerly demo (2026-09-24): an
5
+ * agent asked to "match the spec exactly" minted six such tokens, the strays
6
+ * in an old component then equalled a token and stopped counting, and the
7
+ * score read the drift as progress. The live checks judge a stylesheet with
8
+ * this file; the report counts the same pairs through lib/nearpairs.mjs.
9
+ *
10
+ * Near means every channel within 8, as everywhere else in the engine, and,
11
+ * because both sides are deliberate tokens, closer than the eye can tell
12
+ * (TWIN_OKLAB). A shared dark value counts too, because two tokens that agree in
13
+ * the dark are one colour even when their light values drifted a little further
14
+ * apart (up to DARK_TWIN_STEPS).
15
+ *
16
+ * Only named roles take part. A ramp stop (gray-100, blue-500) sits close to
17
+ * its neighbours by design, a restated palette (slate-50 beside gray-50) is
18
+ * Tailwind's, and the rows shadcn's theming docs define share values on
19
+ * purpose (card and popover are both white). Two names in one family
20
+ * (brand, brand-strong) are a designed pair, not twins.
21
+ */
22
+ import { canonical, oklab } from './color.mjs';
23
+ import { KNOWN_ROWS } from '../profiles/shadcn-data.mjs';
24
+
25
+ export const TWIN_STEPS = 8;
26
+ // Two tokens are deliberate, so a pair of them must also be closer than the
27
+ // eye can tell (OKLab distance 0.01; a just-noticeable difference is about
28
+ // 0.02). The channel rule alone paired Ledgerly's pale red and pale amber
29
+ // surfaces (6 steps, 0.019) and its canvas with its muted surface (4 steps,
30
+ // 0.012), each a designed neighbour; the six tokens the agent minted sat at
31
+ // 0.003 to 0.009.
32
+ export const TWIN_OKLAB = 0.01;
33
+ // A shared dark value makes two tokens one colour when their light values are
34
+ // close enough that roast_find_token would already say "use the token" (24
35
+ // steps). Past that, two roles that meet in the dark are a design decision.
36
+ export const DARK_TWIN_STEPS = 24;
37
+
38
+ const bare = (name) => name.replace(/^--/, '').replace(/^(?:colou?rs?|clr)-/, '');
39
+ /** A token named for a role (warning-soft), not a ramp stop or a kit row. */
40
+ export function isRoleName(name) {
41
+ const b = bare(name);
42
+ return !KNOWN_ROWS.has(b) && !/(^|-)\d+$/.test(b);
43
+ }
44
+ /** The first word of the role: overdue-soft and overdue-title are one family. */
45
+ export const familyOf = (name) => bare(name).split('-')[0];
46
+
47
+ const rgbOf = (canon) => {
48
+ if (!canon) return null;
49
+ const [r, g, b, a] = canon.split(',').map(Number);
50
+ return { r, g, b, a };
51
+ };
52
+ const okOf = (canon) => {
53
+ const c = rgbOf(canon);
54
+ return c ? oklab(`rgb(${c.r} ${c.g} ${c.b})`) : null;
55
+ };
56
+ /** Straight-line OKLab distance between two canonical colours, or null. */
57
+ export function okDistance(x, y) {
58
+ const a = okOf(x), b = okOf(y);
59
+ return a && b ? Math.hypot(a.L - b.L, a.a - b.a, a.b - b.b) : null;
60
+ }
61
+ /** Two token colours close enough to be one colour under two names. */
62
+ export function tokenTwinColours(x, y) {
63
+ const d = channelSteps(x, y);
64
+ return d !== null && d <= TWIN_STEPS && (okDistance(x, y) ?? 1) <= TWIN_OKLAB;
65
+ }
66
+
67
+ /** Largest channel difference, or null when the alphas differ. */
68
+ export function channelSteps(x, y) {
69
+ const a = rgbOf(x), b = rgbOf(y);
70
+ if (!a || !b || a.a !== b.a) return null;
71
+ return Math.max(Math.abs(a.r - b.r), Math.abs(a.g - b.g), Math.abs(a.b - b.b));
72
+ }
73
+
74
+ const DARK_RE = /dark/i;
75
+
76
+ /**
77
+ * Every colour custom property a stylesheet defines, by name: the base value
78
+ * (the first statement outside a dark block) and the dark value (the first
79
+ * statement inside one: .dark, [data-theme="dark"], a prefers-color-scheme
80
+ * query). Values are kept as written and as a canonical colour; a name whose
81
+ * value is not a literal colour (an alias to var(--x)) is left out.
82
+ * @returns Map<name, { value, canon, index, darkValue?, darkCanon? }>
83
+ */
84
+ export function tokenDefsOf(raw) {
85
+ // comments blanked, offsets kept: a retired token in a comment is not a token
86
+ const text = String(raw ?? '').replace(/\/\*[\s\S]*?\*\//g, (m) => ' '.repeat(m.length));
87
+ // the blocks, each with the selector that opened it
88
+ const blocks = [], stack = [];
89
+ let from = 0;
90
+ for (let i = 0; i < text.length; i++) {
91
+ const ch = text[i];
92
+ if (ch === '{') {
93
+ stack.push({ start: i, selector: text.slice(from, i).trim().split('\n').pop().trim() });
94
+ from = i + 1;
95
+ } else if (ch === '}') {
96
+ const b = stack.pop();
97
+ if (b) blocks.push({ ...b, end: i });
98
+ from = i + 1;
99
+ } else if (ch === ';') from = i + 1;
100
+ }
101
+ const inDark = (i) => blocks.some((b) => b.start < i && i < b.end && DARK_RE.test(b.selector));
102
+ const defs = new Map();
103
+ for (const m of text.matchAll(/(--[\w-]+)\s*:\s*([^;{}]+)[;}]/g)) {
104
+ const [, name, rawValue] = m;
105
+ const value = rawValue.trim();
106
+ const canon = canonical(value);
107
+ if (!canon) continue;
108
+ const e = defs.get(name) ?? {};
109
+ if (inDark(m.index)) {
110
+ if (!e.darkValue) Object.assign(e, { darkValue: value, darkCanon: canon });
111
+ } else if (!e.value) Object.assign(e, { value, canon, index: m.index });
112
+ defs.set(name, e);
113
+ }
114
+ // a name stated only inside a dark block has no base to compare
115
+ for (const [name, e] of defs) if (!e.canon) defs.delete(name);
116
+ return defs;
117
+ }
118
+
119
+ /**
120
+ * The existing token a token is a twin of, or null. `pool` is
121
+ * [{ name, canon, value, darkCanon?, darkValue?, file? }].
122
+ */
123
+ export function twinOf(name, def, pool) {
124
+ if (!isRoleName(name)) return null;
125
+ const fam = familyOf(name);
126
+ let best = null;
127
+ for (const e of pool) {
128
+ if (e.name === name || !isRoleName(e.name) || familyOf(e.name) === fam) continue;
129
+ const d = channelSteps(def.canon, e.canon);
130
+ const near = tokenTwinColours(def.canon, e.canon);
131
+ const darkSame = !!def.darkCanon && def.darkCanon === e.darkCanon;
132
+ if (!near && !(darkSame && d !== null && d <= DARK_TWIN_STEPS)) continue;
133
+ // the closest light value wins; a shared dark value breaks a tie
134
+ const rank = (near ? 0 : 1000) + (d ?? 999) - (darkSame ? 0.5 : 0);
135
+ if (!best || rank < best.rank) best = { ...e, d, near, darkSame, rank };
136
+ }
137
+ return best;
138
+ }
139
+
140
+ // the utility a role name suits, for the class the fix names
141
+ const utilFor = (name) => (/border|edge|line|ring|divider|outline/.test(name) ? 'border'
142
+ : /text|title|ink|fg|foreground|label/.test(name) ? 'text' : 'bg');
143
+
144
+ /**
145
+ * Worded findings for the tokens a stylesheet ADDS that twin an existing one.
146
+ * @param text the stylesheet as it stands now
147
+ * @param before the same file before the change (the last commit), null for
148
+ * a new file, undefined when unknown: every token in it is
149
+ * then judged, against the rest of the repo and each other
150
+ * @param others tokens other files define: [{ name, canon, value, darkCanon?, darkValue?, file }]
151
+ * @param tailwind true when --color-* names are Tailwind theme classes
152
+ * @returns [{ rule: 'twin-token', index, name, twin, message, fix }]
153
+ */
154
+ export function tokenTwinFindings(text, { before, others = [], tailwind = false } = {}) {
155
+ const now = tokenDefsOf(text);
156
+ if (!now.size) return [];
157
+ const prev = before === undefined ? null : tokenDefsOf(before ?? '');
158
+ const isNew = (name) => !prev || !prev.has(name);
159
+ // what already exists: other files, and this file's own tokens that were
160
+ // there before the change (at today's values)
161
+ const pool = [...others.filter((o) => !now.has(o.name))];
162
+ for (const [name, e] of now) if (!isNew(name)) pool.push({ name, ...e });
163
+ const out = [];
164
+ for (const [name, def] of [...now].sort((a, b) => a[1].index - b[1].index)) {
165
+ if (!isNew(name)) continue;
166
+ const twin = twinOf(name, def, pool);
167
+ // judged in file order: a later new token may twin an earlier new one
168
+ pool.push({ name, ...def, added: true });
169
+ if (!twin) continue;
170
+ const cls = tailwind && /^--colou?r-/.test(twin.name) ? `${utilFor(bare(name))}-${bare(twin.name)}` : null;
171
+ const where = twin.file ? ` in ${twin.file}` : '';
172
+ // "existing" only when it is: without a before, nothing in this file is known to be old
173
+ const whose = twin.added ? (prev ? 'the new' : 'the token') : prev || twin.file ? 'the existing' : 'the token';
174
+ const lead = twin.near
175
+ ? `${name} (${def.value}) is a twin of ${whose} ${twin.name} (${twin.value})${where}: ${twin.d === 0 ? 'the same colour' : `${twin.d} channel step${twin.d === 1 ? '' : 's'} apart`}${twin.darkSame ? `, and the same dark value (${def.darkValue})` : ''}.`
176
+ : `${name} has the same dark value as ${whose} ${twin.name}${where} (${def.darkValue}), and its light value is only ${twin.d} channel steps away (${def.value} against ${twin.value}).`;
177
+ out.push({
178
+ rule: 'twin-token', index: def.index, name, twin: twin.name,
179
+ message: `${lead} Two names for one colour: the next agent cannot tell which is right.`,
180
+ fix: `Use ${twin.name}${cls ? ` (as a class: ${cls})` : ''} and remove ${name}. Do not add a token that duplicates an existing one.`,
181
+ });
182
+ }
183
+ return out;
184
+ }
@@ -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.6.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,10 @@ 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';
23
+ import { tokenTwinFindings } from '../lib/tokentwins.mjs';
24
+ import { avoidedImportFindings } from '../lib/avoidedimports.mjs';
21
25
 
22
26
  // What this engine measures — shipped with every result, clean or not.
23
27
  export const CHECKS = [
@@ -30,6 +34,8 @@ export const CHECKS = [
30
34
  'static inline style blocks',
31
35
  '!important',
32
36
  'duplicate component definitions',
37
+ 'new colour tokens that twin an existing token',
38
+ 'imports of a duplicate the canonical copy replaces',
33
39
  ];
34
40
  // What a kit or a Tailwind theme adds to the list, so a clean result on an
35
41
  // MUI repo says the kit check ran.
@@ -75,13 +81,16 @@ function nearestToken(value, k) {
75
81
 
76
82
  /**
77
83
  * Validate one piece of content against the repo's knowledge.
78
- * @param content { text, file? } — file name decides css-vs-code mode and
79
- * lets the duplicate check excuse a component's own existing file
84
+ * @param content { text, file?, before? } — file name decides css-vs-code
85
+ * mode and lets the duplicate check excuse a component's own existing file;
86
+ * before is the same file at the last commit (null: a new file; left out:
87
+ * unknown), so a token or an import the change ADDS can be told from one
88
+ * that was already there
80
89
  * @returns { findings: [{ rule, severity, line, message, fix? }], checked,
81
90
  * exempt? } — exempt is a sentence saying why the file was not judged
82
91
  */
83
92
  export function validateContent(content, k) {
84
- const { text, file = null } = content;
93
+ const { text, file = null, before } = content;
85
94
  // Some files cannot be on-system by their nature. Judging them is how a
86
95
  // checker earns its reputation for crying wolf, and a checker people
87
96
  // distrust gets switched off. Say nothing, and say why nothing was said.
@@ -145,6 +154,7 @@ export function validateContent(content, k) {
145
154
  // foreground name, a bg- class a surface or background name
146
155
  const names = paletteRule === 'tailwind' ? (tw.names ?? []) : ['primary', 'foreground', 'muted-foreground', 'background', 'border'];
147
156
  const pick = (re) => names.find((n) => re.test(n)) ?? names[0] ?? 'brand';
157
+ const values = paletteRule === 'tailwind' ? tw.values ?? {} : {};
148
158
  // a class named in a comment paints nothing; blanked, not cut, so the
149
159
  // line numbers below still hold
150
160
  const code = blankComments(text);
@@ -155,12 +165,20 @@ export function validateContent(content, k) {
155
165
  for (const m of hits.sort((a, b) => a.index - b.index)) {
156
166
  const cls = m[0];
157
167
  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;
168
+ const shade = cls.replace(/^(?:[\w-]+:)*[a-z]+-/, '').replace(/\/\d+$/, '');
169
+ const role = utilRole(util);
170
+ // the theme colour nearest by value among the names that suit the
171
+ // utility; the old pick by name alone only when none is close
172
+ const example = nearestThemeName(shade, role, values)
173
+ ?? (role === 'text' ? pick(/ink|text|fg|foreground/) : role === 'surface' ? pick(/surface|bg|background|canvas/) : pick(/border|edge|line|ring/));
174
+ if (retuned.has(shade)) continue;
160
175
  if (!hasFamily(cls)) continue;
176
+ const word = hueWord(shade, names);
161
177
  add('palette-class', 'violation', m.index,
162
178
  `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.`);
179
+ `Use a theme token as the class (${util}-${example}). ${names.includes(word)
180
+ ? `If no token fits, add ${/^[aeiou]/.test(word) ? 'an' : 'a'} ${word} shade to the theme once and use it by name.`
181
+ : `If no token fits, add one to the theme once, for example ${word}, and use ${util}-${word}.`}`);
164
182
  }
165
183
  }
166
184
 
@@ -272,6 +290,19 @@ export function validateContent(content, k) {
272
290
  }
273
291
  }
274
292
 
293
+ // ---------- a new token that twins an existing one ----------
294
+ // Minting a second name for a colour the system already has is drift
295
+ // promoted into the token set: the strays that matched it stop counting and
296
+ // the palette reads as tidier than it is (Ledgerly, 2026-09-24).
297
+ if (css) {
298
+ const twins = tokenTwinFindings(text, {
299
+ before,
300
+ others: (k.tokenDefs ?? []).filter((d) => d.file !== file),
301
+ tailwind: !!k.tailwind || /@theme\b/.test(text),
302
+ });
303
+ for (const f of twins) add(f.rule, 'violation', f.index, f.message, f.fix);
304
+ }
305
+
275
306
  // ---------- discipline ----------
276
307
  for (const b of got.inlineBlocks) {
277
308
  add('inline-style', 'violation', b.index,
@@ -303,9 +334,69 @@ export function validateContent(content, k) {
303
334
  }
304
335
  }
305
336
 
337
+ // ---------- an import of the copy the canon replaces ----------
338
+ if (!css) {
339
+ for (const f of avoidedImportFindings(text, { file, before, dupes: k.dupeCopies })) {
340
+ add(f.rule, 'violation', f.index, f.message, f.fix);
341
+ }
342
+ }
343
+
306
344
  return { findings, checked: checksFor(k) };
307
345
  }
308
346
 
347
+ // ---------- the theme colour a palette class stands in for ----------
348
+ // Which theme names suit a utility, read from the name: a text class wants an
349
+ // ink, a bg class a surface, a border class an edge. A name that says none of
350
+ // these (brand, warning, positive) is an accent and suits every utility.
351
+ const TEXT_NAME_RE = /ink|text|fg|foreground/;
352
+ const SURFACE_NAME_RE = /surface|(^|-)bg(-|$)|background|canvas|soft|tint|wash/;
353
+ const EDGE_NAME_RE = /border|edge|line|ring|divider|outline/;
354
+ function utilRole(util) {
355
+ if (/(^|:)(?:text|placeholder|caret|decoration|fill|stroke)$/.test(util)) return 'text';
356
+ if (/(^|:)bg$/.test(util)) return 'surface';
357
+ if (/(^|:)(?:border|ring|outline|divide)$/.test(util)) return 'edge';
358
+ return 'any';
359
+ }
360
+ function suits(name, role) {
361
+ const text = TEXT_NAME_RE.test(name), surface = SURFACE_NAME_RE.test(name), edge = EDGE_NAME_RE.test(name);
362
+ if (role === 'any' || (!text && !surface && !edge)) return true;
363
+ return role === 'text' ? text && !surface && !edge : role === 'surface' ? surface : edge;
364
+ }
365
+ // Distance in OKLab with the two colour axes doubled, so a pale amber lands on
366
+ // the pale warning surface rather than on the near-white canvas beside it.
367
+ const hueDistance = (x, y) => Math.hypot(x.L - y.L, 2 * (x.a - y.a), 2 * (x.b - y.b));
368
+ // Past this, the nearest theme colour is a different colour, not a stand-in
369
+ // (bg-blue-600 against a theme of greys), and the old pick by name answers.
370
+ const CLOSE = 0.2;
371
+ /** The suitable theme name nearest to a palette shade's Tailwind value, or null. */
372
+ function nearestThemeName(shade, role, values) {
373
+ const from = oklab(TAILWIND_DEFAULTS[shade] ?? '');
374
+ if (!from) return null;
375
+ let best = null;
376
+ for (const [name, value] of Object.entries(values)) {
377
+ if (!suits(name, role)) continue;
378
+ const to = oklab(value);
379
+ if (!to) continue;
380
+ const d = hueDistance(from, to);
381
+ if (!best || d < best.d) best = { name, d };
382
+ }
383
+ return best && best.d <= CLOSE ? best.name : null;
384
+ }
385
+ // The word for a new token follows the hue, in the theme's own vocabulary when
386
+ // it has one (Ledgerly says positive and negative, not success and danger).
387
+ const HUE_WORDS = [
388
+ [/^(?:red|rose)-/, ['negative', 'danger', 'error', 'destructive']],
389
+ [/^(?:orange|amber|yellow)-/, ['warning', 'caution']],
390
+ [/^(?:green|emerald|lime)-/, ['positive', 'success']],
391
+ [/^(?:blue|sky|cyan)-/, ['info']],
392
+ ];
393
+ function hueWord(shade, names) {
394
+ const words = HUE_WORDS.find(([re]) => re.test(shade))?.[1];
395
+ if (!words) return GREY_HUE_RE.test(`-${shade}`) ? 'muted' : 'accent';
396
+ return words.find((w) => names.some((n) => n === w || n.startsWith(`${w}-`)))
397
+ ?? { negative: 'danger', warning: 'warning', positive: 'success', info: 'info' }[words[0]];
398
+ }
399
+
309
400
  function looksLikeCss(text) {
310
401
  // no JSX tags, has a `{ ...: ` declaration shape → treat as stylesheet.
311
402
  // Sniffed on the head of the text with bounded patterns: the old version
@@ -12,9 +12,9 @@
12
12
  */
13
13
  import { statSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
- import { walkRepo, profileRepo } from '../harvest/walk.mjs';
15
+ import { walkRepo, profileRepo, readSource } from '../harvest/walk.mjs';
16
16
  import { harvestComponents } from '../harvest/components.mjs';
17
- import { harvestTokens } from '../harvest/tokens.mjs';
17
+ import { harvestTokens, extractStyling } from '../harvest/tokens.mjs';
18
18
  import { findDuplicates } from '../harvest/duplicates.mjs';
19
19
  import { harvestContext } from '../harvest/context.mjs';
20
20
  import { loadExclusions } from '../lib/exclusions.mjs';
@@ -24,6 +24,8 @@ import { typefaceOf } from '../lib/typefaces.mjs';
24
24
  import { hexRgb } from '../lib/nearpairs.mjs';
25
25
  import { decideProfile, profileOf } from '../profiles/index.mjs';
26
26
  import { KITS } from '../profiles/kit-common.mjs';
27
+ import { tokenDefsOf } from '../lib/tokentwins.mjs';
28
+ import { canonicalCopy } from '../lib/avoidedimports.mjs';
27
29
 
28
30
  const MAX_DEPTH = 14; // same ruler as the harvest CLI
29
31
 
@@ -88,7 +90,9 @@ export function loadKnowledge(root) {
88
90
  const fontSizesSeen = new Map((tokens.fontSizes ?? []).map((f) => [f.value, f.count]));
89
91
  const shadowsSeen = new Map((tokens.shadows ?? []).map((s) => [s.value, s.count]));
90
92
  const faceCounts = new Map();
91
- for (const f of tokens.fontFamilies ?? []) {
93
+ // a face the @theme declares is on-system, so a stylesheet using it is not
94
+ // "a new typeface" (tokens.themeFonts, harvest/tokens.mjs)
95
+ for (const f of [...(tokens.themeFonts ?? []), ...(tokens.fontFamilies ?? [])]) {
92
96
  const face = typefaceOf(f.value);
93
97
  if (face) faceCounts.set(face, (faceCounts.get(face) ?? 0) + f.count);
94
98
  }
@@ -117,6 +121,31 @@ export function loadKnowledge(root) {
117
121
 
118
122
  const neverImported = neverImportedComponents(components, profile.uiDir);
119
123
 
124
+ // The copies of each duplicated name with their usage, and the colours each
125
+ // copy hard-codes: what the avoided-import check names (lib/avoidedimports).
126
+ const dupeCopies = new Map();
127
+ for (const d of hardDupes) {
128
+ const paths = d.files.map((f) => (typeof f === 'string' ? f : f.file));
129
+ const copies = paths.map((file) => ({ file, usageCount: (byName.get(d.name) ?? []).find((c) => c.file === file)?.usageCount ?? 0 }));
130
+ const canon = canonicalCopy(copies);
131
+ const strays = new Map();
132
+ for (const file of paths) {
133
+ if (file === canon?.file) continue;
134
+ const text = readSource(join(root, file));
135
+ if (text) strays.set(file, [...new Set(extractStyling(text).colors.map((c) => c.value))]);
136
+ }
137
+ dupeCopies.set(d.name, { copies, strays });
138
+ }
139
+
140
+ // Every colour token each stylesheet defines, light and dark, so a new one
141
+ // can be compared with the ones already there (lib/tokentwins).
142
+ const tokenDefs = [];
143
+ for (const file of files.styles) {
144
+ const text = readSource(join(root, file));
145
+ if (!text || !text.includes('--')) continue;
146
+ for (const [name, e] of tokenDefsOf(text)) tokenDefs.push({ name, file, ...e });
147
+ }
148
+
120
149
  return {
121
150
  root,
122
151
  scannedAt: new Date().toISOString(),
@@ -142,6 +171,8 @@ export function loadKnowledge(root) {
142
171
  byName,
143
172
  canonical,
144
173
  dupeByName,
174
+ dupeCopies,
175
+ tokenDefs,
145
176
  neverImported,
146
177
  // stock, not debt: see profiles/index.mjs
147
178
  vendoredUi: P.vendoredUi,
@@ -78,7 +78,7 @@ const PROMPTS = [
78
78
  {
79
79
  name: 'roast-build-ui',
80
80
  description: 'Build UI in this repo the way the repo already does it',
81
- text: 'Before implementing any UI in this repository: call roast_get_context (pass the folder you will work in), check roast_find_component for anything you are about to create, and roast_find_token for any raw colour or size value. Build. Then call roast_validate on what you wrote and fix every finding, and finish with roast_review. Do not invent components, colours or spacing values this repository does not already have.',
81
+ text: 'Before implementing any UI in this repository: call roast_get_context (pass the folder you will work in), check roast_find_component for anything you are about to create, and roast_find_token for any raw colour or size value. Build. Then call roast_validate on what you wrote and fix every finding, and finish with roast_review. Do not invent components, colours or spacing values this repository does not already have, and do not add a token that duplicates an existing one; reuse it.',
82
82
  },
83
83
  {
84
84
  name: 'roast-review-ui',
@@ -50,7 +50,7 @@ export function getContext(k, { path = null } = {}) {
50
50
  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.`);
51
51
  } else if (t.tokenFile) {
52
52
  const strays = t.colors.length - k.tokenColors.length;
53
- 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.${strays ? ` (${strays} hardcoded strays already exist; do not add more.)` : ''}`);
53
+ 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.)` : ''}`);
54
54
  } else if (t.colors.length) {
55
55
  L.push(`TOKENS: none defined. ${t.colors.length} distinct colours already in play; reuse one, never invent another.`);
56
56
  }
@@ -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');
@@ -230,12 +273,35 @@ function toPxLocal(len) {
230
273
  return m[2] === 'px' ? parseFloat(m[1]) : parseFloat(m[1]) * 16;
231
274
  }
232
275
 
276
+ // ---------- the file before the change ----------
277
+ // The last commit's copy of a file, so a check can tell a token or an import
278
+ // the change ADDS from one that was already there: null when the file is new
279
+ // (git has a HEAD and no copy of it), undefined when there is nothing to ask
280
+ // (not a repository, no commits yet). gitPath is relative to the git root.
281
+ function headVersion(gitRoot, gitPath) {
282
+ try {
283
+ return execFileSync('git', ['show', `HEAD:${gitPath}`], { cwd: gitRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024 });
284
+ } catch {
285
+ try {
286
+ execFileSync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: gitRoot, stdio: 'ignore' });
287
+ return null;
288
+ } catch { return undefined; }
289
+ }
290
+ }
291
+ function beforeOf(k, file) {
292
+ if (!file) return undefined;
293
+ try {
294
+ const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: k.root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
295
+ return headVersion(gitRoot, relative(realpathSync.native(gitRoot), join(realpathSync.native(k.root), file)).split('\\').join('/'));
296
+ } catch { return undefined; }
297
+ }
298
+
233
299
  // ---------- roast_validate ----------
234
300
  export function validate(k, { code, file = null } = {}) {
235
301
  if (typeof code !== 'string' || !code.trim()) return invalidInput('Send the code you are about to save (and ideally its file path).');
236
302
  if (file != null && typeof file !== 'string') return invalidInput('file is optional, but when sent it must be a repo-relative path as a string.');
237
303
  if (code.length > MAX_VALIDATE_CHARS) return invalidInput(`That is ${Math.round(code.length / 1000)}k characters; send the part you changed (up to ${MAX_VALIDATE_CHARS / 1000}k).`);
238
- const { findings, exempt } = validateContent({ text: code, file }, k);
304
+ const { findings, exempt } = validateContent({ text: code, file, before: beforeOf(k, file) }, k);
239
305
  if (exempt) return `Not judged: ${file} is exempt because ${exempt}. Nothing here was checked.`;
240
306
  if (!findings.length) return cleanResultText(k);
241
307
  const L = [`${findings.length} finding${findings.length === 1 ? '' : 's'}:`];
@@ -280,7 +346,7 @@ export function reviewData(k) {
280
346
  let text;
281
347
  try { text = readFileSync(join(gitRoot, f), 'utf8'); } catch { continue; }
282
348
  // knowledge paths are scanned-root-relative; git paths are toplevel-relative
283
- const { findings, exempt } = validateContent({ text, file: relative(realRoot, join(gitRoot, f)) }, k);
349
+ const { findings, exempt } = validateContent({ text, file: relative(realRoot, join(gitRoot, f)), before: headVersion(gitRoot, f) }, k);
284
350
  if (exempt) { exemptCount++; continue; }
285
351
  if (findings.length) { perFile.push({ f, findings }); total += findings.length; }
286
352
  }
@@ -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