roast-my-design-system 9.1.1 → 9.2.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
@@ -63,9 +63,9 @@ This tool draws the map.
63
63
 
64
64
  ## What you get
65
65
 
66
- ### Where agents will invent
66
+ ### Where will your agent have to guess?
67
67
 
68
- The places where the repo has no answer yet.
68
+ The first thing the report says: the places where the repo has no answer yet, or "No gaps found".
69
69
 
70
70
  - the gap, in one line
71
71
  - the files that prove it
@@ -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 the verdict on where the agent will have to guess, the Where agents will invent section, the What the repo teaches the agent analysis written by Claude, priced Fix what makes agents guess moves each with 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.1.1)
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 What the repo teaches the agent analysis written by Claude, priced Fix what makes agents guess moves each with 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.1.2)
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.1.1)
149
+ ![The diagnosis report in light mode](assets/report-light-hero.png?v=9.1.2)
150
150
 
151
151
  ## Why the numbers hold
152
152
 
@@ -245,7 +245,7 @@ Four calls, under 800 tokens, and the new component reads the theme instead of a
245
245
 
246
246
  Charts get their own rule, because a chart needs several colours that differ from each other and most design systems never name them. Where a repo keeps a chart palette, a colour written by hand in a chart file is a finding that names the palette. Where a repo has charts but no palette, a new chart that paints by hand gets one warning that names the existing chart doing the same and asks for the palette once. That warning is the gap report, live.
247
247
 
248
- The server reads the repo the way the report does. On a product built on MUI, Mantine, Chakra UI or Ant Design, the context names the theme file and the kit's own way of reading it, `roast_find_token` answers in spacing steps, and the checks flag a colour or a pixel size written onto a kit component where the theme has a value. On a Tailwind theme or a shadcn repo they flag a palette class such as `text-gray-500` where the theme names a colour of that kind.
248
+ The server reads the repo the way the report does. On a product built on MUI, Mantine, Chakra UI or Ant Design, the context names the theme file and the kit's own way of reading it, `roast_find_token` answers in spacing steps, and the checks flag a colour or a pixel size written onto a kit component where the theme has a value. On a Tailwind theme or a shadcn repo they flag a palette class such as `text-gray-500` where the theme names a colour of that kind. A token's raw value pasted into a component is flagged with the token's name, and a button built from scratch where the repo already has a well-used Button gets a warning that names the import line to use.
249
249
 
250
250
  The `roast-fix` prompt serves the top Where-to-start move from a fresh scan, byte-identical to the report's copy buttons. In Claude Code type `/mcp__roast__roast-fix`, add a number to jump the queue. Fix it, ask again, and the next move has risen to the top: the scan is the progress bar.
251
251
 
@@ -348,7 +348,7 @@ You get the roast in chat plus `design-system-roast.html` at your repo root, a s
348
348
  - a **light/dark theme toggle** in one file
349
349
  - the usage-weighted palette bar, the grey ramp, the off-scale spacing receipts, the duplicate-component receipts with clickable file paths, and the worst-offenders ledger
350
350
  - a **Fix what makes agents guess** list: up to 3 moves derived from your repo's own numbers, each with a file-path receipt
351
- - a **Where agents will invent** section, first under the verdict: the gaps where the repo has no answer yet, with the files that prove each one and the move that closes it, or the honest empty state when none is found
351
+ - **Where will your agent have to guess?** opens the report: the gaps where the repo has no answer yet, with the files that prove each one and the move that closes it, or "No gaps found"
352
352
  - **Give the agent the answers**: `design-system-rules.md` wrapped inside the report as a present. Unwrap, then copy or download the agent rules generated from your scan
353
353
 
354
354
  After the roast, the skill offers to write `design-system-rules.md` to disk and merge it into your CLAUDE.md, `.cursor/rules` or AGENTS.md.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "9.1.1",
3
+ "version": "9.2.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": [
@@ -354,13 +354,20 @@ let verdict = noSystemLikely
354
354
  // The lead line answers the report's first question, where will the agent
355
355
  // have to guess, before the measured verdict does. A repo with an answer gets
356
356
  // copied; a repo with no answer gets guessed at. A gap named here is one
357
- // lib/gaps admitted after a fleet probe; nothing else is called a gap.
357
+ // lib/gaps admitted after a fleet probe; nothing else is called a gap. The
358
+ // card never talks about the scan's reach: a reader came for a diagnosis,
359
+ // not for the limits of the instrument (Greg, 28 Sep 2026).
358
360
  const gapLead = (noSystemLikely || fresh) ? '' : (() => {
359
361
  const gaps = h.gaps ?? [];
360
- if (!gaps.length) return 'Nowhere that this scan checks: your repo gives the agent a clear answer to copy.';
361
- const named = gaps.map((g) => g.title.charAt(0).toLowerCase() + g.title.slice(1));
362
- return `${gaps.length === 1 ? 'In one place' : `In ${gaps.length} places`} your repo gives the agent no clear answer: ${esc(named.join('; '))}. Asked to build there, it has to decide for itself.`;
362
+ return gaps.length ? `${gaps.length} gap${gaps.length === 1 ? '' : 's'} found.` : 'No gaps found.';
363
363
  })();
364
+ // Under the headline: the condition, and nothing about the scan's reach.
365
+ // What the fixes do to the score is said once, in the fixes section.
366
+ function gapSub() {
367
+ return (h.gaps ?? []).length
368
+ ? 'The agent has no clear answer for these parts of the system.'
369
+ : 'Your repo gives the agent a clear answer for every check in this scan.';
370
+ }
364
371
  if (bench && findings.length && !fresh) {
365
372
  const core = [['colors', tokenLed ? colorStrays : colors.length], ['greys', tokenLed ? greyStrays : greys.length],
366
373
  ['spacing', spacingTotal], ['typefaces', typefaces.length], ['exactDuplicates', hardDupes.length], ['inlineStyles', inline.count]];
@@ -1214,9 +1221,9 @@ function whereToStartSection() {
1214
1221
  for (const item of top3) if (item.metric && item.after !== undefined && item.delta > 0) applied.set(item.metric, item.after);
1215
1222
  const after = projectedScore(applied);
1216
1223
  startProjection = { count: top3.length, after };
1217
- const words = ['One tweak', 'Two tweaks', 'Three tweaks'][top3.length - 1];
1224
+ const words = ['One fix', 'Two fixes', 'Three fixes'][top3.length - 1];
1218
1225
  const head = healthScore !== null && after > healthScore
1219
- ? `${words} · <b class="proj">${healthScore} &rarr; ${after}</b>`
1226
+ ? `${words} could raise the design system health score from <b class="proj">${healthScore} to ${after}</b>.`
1220
1227
  : `${words} to increase your score.`;
1221
1228
  const chip = (item) => item.delta > 0
1222
1229
  ? `<span class="delta" title="once all of them are done">+${item.delta}</span>`
@@ -1258,24 +1265,17 @@ function whereToStartSection() {
1258
1265
  // A gap is a place where the next piece of UI has nothing to reuse (lib/gaps).
1259
1266
  // The September 2026 runs showed agents inventing exactly there; the section
1260
1267
  // names the gap, the files that prove it and the one move that closes it.
1261
- function gapsSection() {
1268
+ function gapsList() {
1262
1269
  if (noSystemLikely || fresh) return '';
1263
1270
  const gaps = h.gaps ?? [];
1264
- if (!gaps.length) {
1265
- return `<section class="glass pad" style="margin-top:16px">
1266
- ${sectionHead('Where agents will invent', 'No gap found: the gaps where a design system has no answer yet are all closed here. This scan checks for one kind of gap so far, a chart palette, and this repo either has one or has no charts. The list grows as more gap kinds are measured on real agent runs; a clean result here says the known gaps are closed, and no more than that.')}
1267
- </section>`;
1268
- }
1269
- const head = `The gaps where your design system has no answer yet. ${gaps.length === 1 ? 'One place' : `${gaps.length} places`} where this repo gives an agent nothing to copy; asked to build there, an agent invents, because that is what the repo taught it. Nothing here is a violation: there is no rule yet to break. Define the answer once and every agent after that inherits it.`;
1270
- return `<section class="glass pad" style="margin-top:16px">
1271
- ${sectionHead('Where agents will invent', head)}
1272
- <div class="ledger">${gaps.map((g, i) => `
1271
+ if (!gaps.length) return '';
1272
+ return `<div class="ledger gaps-list">${gaps.map((g, i) => `
1273
1273
  <div class="ledger-row gap-row">
1274
1274
  <span class="ledger-idx">${String(i + 1).padStart(2, '0')}</span>
1275
1275
  <div class="start-body"><div class="start-head"><div class="start-title">${esc(g.title)}</div></div><div class="sub">${esc(g.detail)}</div>
1276
1276
  <div class="sub" style="margin-top:6px"><b>Close it:</b> ${esc(g.fix)}</div>
1277
1277
  ${g.files?.length ? `<div class="chips-row" style="margin-top:8px">${g.files.map((f) => `<span class="chip mono">${esc(f)}</span>`).join('')}</div>` : ''}</div>
1278
- </div>`).join('')}</div></section>`;
1278
+ </div>`).join('')}</div>`;
1279
1279
  }
1280
1280
 
1281
1281
  // ---------- package by package ----------
@@ -1561,16 +1561,16 @@ function ogTags() {
1561
1561
  // index of what rendered, and what is not the team's and not counted.
1562
1562
  function sidePanel() {
1563
1563
  const date = esc((h.harvestedAt ?? '').slice(0, 10));
1564
- const def = 'How safely an AI agent can build on this repo without going off-system.';
1564
+ const def = 'How well this repo follows the design system rules Roast checks.';
1565
1565
  const lift = startProjection && healthScore !== null && startProjection.after > healthScore
1566
- ? ` <b>${['One fix lifts', 'Two fixes lift', 'Three fixes lift'][startProjection.count - 1] ?? 'The fixes lift'} it to ${startProjection.after}.</b>` : '';
1566
+ ? ` <b>${['One fix', 'Two fixes', 'Three fixes'][startProjection.count - 1] ?? 'The fixes'} could raise it to ${startProjection.after}.</b>` : '';
1567
1567
  const rp = P.shadcn?.registryPaint ?? null;
1568
1568
  const bd = breakdown.ownScore !== null && breakdown.installedPoints > 0 && rp
1569
1569
  ? `<div class="bd">Of which <b>${breakdown.installedPoints} point${breakdown.installedPoints === 1 ? '' : 's'}</b> come from installed code you did not write: ${esc(rp.dirs.map((d) => basename(d)).join(', '))} (${n(rp.files)} file${rp.files === 1 ? '' : 's'}, ${n(rp.tinUses)} palette colour${rp.tinUses === 1 ? '' : 's'}). Your own code alone would score <b>${breakdown.ownScore}</b>. Kept in the score because your agent reads those files like everything else; left out of the fixes because they are not yours to edit.</div>` : '';
1570
1570
  const scoreBlock = healthScore !== null
1571
- ? `<div class="score">${eyebrow('Health score')}<div class="val">${healthScore}<span class="slash">/</span><span class="of">100</span></div><div class="def">${def}${lift}</div>${bd}</div>`
1571
+ ? `<div class="score">${eyebrow('Design system health')}<div class="val">${healthScore}<span class="slash">/</span><span class="of">100</span></div><div class="def">${def}${lift}</div>${bd}</div>`
1572
1572
  : noSystemLikely
1573
- ? `<div class="score muted">${eyebrow('Health score')}<div class="val">&mdash;</div><div class="note">nothing here was measured · see the note</div><div class="def">${def}</div></div>` : '';
1573
+ ? `<div class="score muted">${eyebrow('Design system health')}<div class="val">&mdash;</div><div class="note">nothing here was measured · see the note</div><div class="def">${def}</div></div>` : '';
1574
1574
  const chips = `<div class="chips">${stack.map((c) => `<span class="chip">${esc(c)}</span>`).join('')}${(P.shadcn?.sheet?.tweakcnPresent ?? 0) >= 10 ? '<span class="chip">tweakcn theme</span>' : ''}${P.shadcn?.lint ? '<span class="chip">shadcn/lint</span>' : ''}${dsUnrecognised ? '<span class="chip chip-dim">design system: unrecognised</span>' : ''}${legacyChip ? `<span class="chip chip-dim">${esc(legacyChip)}</span>` : ''}${agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)}</span>`).join('')}</div>`;
1575
1575
  const facts = [
1576
1576
  shadcnReceipt(),
@@ -1788,6 +1788,7 @@ ${ogTags()}
1788
1788
  .verdict-card .b2 { left:-40px; bottom:-80px; width:224px; height:224px; background:var(--blob2); filter:blur(48px); }
1789
1789
  .verdict { position:relative; font:600 clamp(18px,2.6vw,24px)/1.45 var(--disp); letter-spacing:-.01em; margin-top:10px; }
1790
1790
  .verdict-sub { position:relative; margin-top:12px; font-size:14.5px; line-height:1.6; color:var(--dim); }
1791
+ .verdict-card .gaps-list { position:relative; margin-top:18px; }
1791
1792
  .gap-empty { padding:0 26px 22px; color:var(--dim); font-size:14px; line-height:1.6; }
1792
1793
  .gap-why { margin-top:6px; color:var(--dim); font-size:13.5px; }
1793
1794
 
@@ -2112,11 +2113,9 @@ ${sidePanel()}
2112
2113
  <div class="glass verdict-card">
2113
2114
  <div class="blob b1"></div><div class="blob b2"></div>
2114
2115
  ${eyebrow(noSystemLikely || fresh ? 'Summary' : 'Where will your agent have to guess?')}
2115
- ${gapLead ? `<div class="verdict">${gapLead}</div><div class="verdict-sub">${esc(verdict)}</div>` : `<div class="verdict">${esc(verdict)}</div>`}
2116
+ ${gapLead ? `<div class="verdict">${gapLead}</div><div class="verdict-sub">${gapSub()}</div>${gapsList()}` : `<div class="verdict">${esc(verdict)}</div>`}
2116
2117
  </div>
2117
2118
 
2118
- ${gapsSection()}
2119
-
2120
2119
  ${notesSection()}
2121
2120
 
2122
2121
  ${extraSectionsHtml()}
@@ -6,6 +6,37 @@
6
6
  */
7
7
  import { join, basename, posix } from 'node:path';
8
8
  import { readSource } from './walk.mjs';
9
+ import { resolveWorkspaces } from '../lib/workspaces.mjs';
10
+
11
+ /**
12
+ * Import aliases a tsconfig declares: "@sentry/scraps/*": ["./static/app/
13
+ * components/core/*"] means a file importing @sentry/scraps/button means the
14
+ * copy under static/app/components/core. Read tolerantly (tsconfig is JSON
15
+ * with comments and trailing commas); a bare "*" catch-all is not an alias.
16
+ * @returns [{ prefix, dir }] longest prefix first
17
+ */
18
+ export function tsconfigAliases(root) {
19
+ let text;
20
+ try { text = readSource(join(root, 'tsconfig.json')); } catch { return []; }
21
+ if (!text) return [];
22
+ let cfg;
23
+ try {
24
+ cfg = JSON.parse(text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1'));
25
+ } catch { return []; }
26
+ const paths = cfg?.compilerOptions?.paths;
27
+ if (!paths || typeof paths !== 'object') return [];
28
+ const base = (cfg.compilerOptions.baseUrl ?? '.').replace(/^\.\/?/, '').replace(/\/$/, '');
29
+ const out = [];
30
+ for (const [key, targets] of Object.entries(paths)) {
31
+ if (key === '*' || !Array.isArray(targets) || typeof targets[0] !== 'string') continue;
32
+ const prefix = key.replace(/\/?\*$/, '');
33
+ if (!prefix) continue;
34
+ const target = targets[0].replace(/^\.\/?/, '').replace(/\/?\*$/, '').replace(/\/$/, '');
35
+ const dir = posix.normalize(base ? `${base}/${target}` : target).replace(/^\.\/?/, '');
36
+ if (dir && dir !== '.') out.push({ prefix, dir });
37
+ }
38
+ return out.sort((a, b) => b.prefix.length - a.prefix.length);
39
+ }
9
40
 
10
41
  const QUOTES = new Set(['"', "'", '`']);
11
42
 
@@ -169,7 +200,7 @@ const stripExt = (f) => f.replace(/\.[cm]?[jt]sx?$/, '');
169
200
  * (`../ui` → ui/index.ts) resolves to the copy inside that folder. A file that
170
201
  * defines the name itself means its own. Returns [def] or null when unsure.
171
202
  */
172
- function importedDef(src, file, name, defs) {
203
+ function importedDef(src, file, name, defs, workspaces = [], aliases = []) {
173
204
  const own = defs.find((d) => d.file === file);
174
205
  if (own) return [own];
175
206
  for (const m of src.matchAll(/import\s+(?:type\s+)?([^'";]*?)\s+from\s*['"]([^'"]+)['"]/g)) {
@@ -181,7 +212,25 @@ function importedDef(src, file, name, defs) {
181
212
  const spec = m[2];
182
213
  let target = null, suffix = null;
183
214
  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; }
215
+ else {
216
+ const a = /^(?:@|~|#)\/(.+)$/.exec(spec);
217
+ // a tsconfig alias first: it is the repo's own statement of where the
218
+ // specifier points, and it can name a folder no workspace declares
219
+ const al = aliases.find((x) => spec === x.prefix || spec.startsWith(`${x.prefix}/`));
220
+ if (al) {
221
+ const rest = spec === al.prefix ? '' : spec.slice(al.prefix.length + 1);
222
+ target = posix.normalize(rest ? `${al.dir}/${rest}` : al.dir).replace(/\.[cm]?[jt]sx?$/, '');
223
+ } else if (a) suffix = a[1];
224
+ else {
225
+ // A workspace package by name (twenty-ui/input, @calcom/ui): the copy
226
+ // inside that package's folder. Until now every copy was credited,
227
+ // so Twenty's marketing-site Button carried the app's 274 uses too.
228
+ const ws = workspaces.find((w) => spec === w.name || spec.startsWith(`${w.name}/`));
229
+ if (!ws) return null;
230
+ const inside = defs.filter((d) => d.file.startsWith(`${ws.dir}/`));
231
+ return inside.length === 1 ? inside : null;
232
+ }
233
+ }
185
234
  const hit = defs.filter((d) => {
186
235
  const f = stripExt(d.file);
187
236
  if (target !== null) return f === target || f === `${target}/index` || d.file.startsWith(`${target}/`);
@@ -226,6 +275,8 @@ export function harvestComponents(root, codeFiles) {
226
275
  }
227
276
 
228
277
  // Pass 2: usages (skip the defining file's own render of itself is fine to count)
278
+ const workspaces = resolveWorkspaces(root);
279
+ const aliases = tsconfigAliases(root);
229
280
  const byName = new Map();
230
281
  for (const c of components) {
231
282
  if (!byName.has(c.name)) byName.set(c.name, []);
@@ -244,7 +295,7 @@ export function harvestComponents(root, codeFiles) {
244
295
  // features/invoices each "used 12x" (8 and 4 in truth), so neither read
245
296
  // as the canon (Ledgerly, 2026-09-24). Unresolved imports keep the old
246
297
  // rule and credit every copy.
247
- const owners = defs.length > 1 ? importedDef(src, file, name, defs) ?? defs : defs;
298
+ const owners = defs.length > 1 ? importedDef(src, file, name, defs, workspaces, aliases) ?? defs : defs;
248
299
  for (const def of owners) {
249
300
  if (def.file === file) continue; // internal render/recursion, not adoption
250
301
  def.usageCount += n;
@@ -377,6 +377,7 @@ export function harvestTokens(root, styleFiles, codeFiles) {
377
377
  return owner;
378
378
  };
379
379
  const paletteFiles = new Set();
380
+ const tailwindConfigs = new Set();
380
381
  const listShare = new Map(); // code file → share of its colour literals inside 8+ colour arrays
381
382
  const nsDefs = new Map(); // --telekom-x: value → 'telekom' (definitions)
382
383
  const nsRefs = new Map(); // var(--telekom-x) → 'telekom' (references)
@@ -536,6 +537,7 @@ export function harvestTokens(root, styleFiles, codeFiles) {
536
537
  // equivalent of --var definitions). Its fontFamily block is the typeface
537
538
  // declaration for the whole app.
538
539
  if (/(^|\/)tailwind\.config\.[mc]?[jt]s$/.test(f)) {
540
+ tailwindConfigs.add(f);
539
541
  for (const m of src.matchAll(HEX_RE)) { tokenDefined.add(normalizeHex(m[0])); colors.add(normalizeHex(m[0]), f); }
540
542
  const famBlock = src.match(/fontFamily\s*:\s*\{([\s\S]*?)\n\s*\}/);
541
543
  if (famBlock) {
@@ -708,6 +710,11 @@ export function harvestTokens(root, styleFiles, codeFiles) {
708
710
  // stylesheet is never a list, whatever shape its values take.
709
711
  const isList = (f) => /\.[jt]sx?$/.test(f) && !/(^|\/)tailwind\.config\.[mc]?[jt]s$/.test(f) && (listShare.get(f) ?? 0) >= 0.5;
710
712
  const candidates = new Set([...tokenColorDefsPerFile.keys(), ...paletteFiles]);
713
+ // Every file that states a token colour: a stylesheet defining --vars, a
714
+ // Tailwind config, a palette file. A checker reading one of these must not
715
+ // tell it to use itself; anywhere else a token's raw value is a paste of
716
+ // the value where the name belongs (Twenty, Haiku with the hook, 2026-09-29).
717
+ const tokenSources = [...new Set([...candidates, ...tailwindConfigs])].sort();
711
718
  const tokenFile = [...candidates]
712
719
  .map((f) => ({ f, tokens: tokenColoursIn.get(f) ?? 0, strays: straysIn.get(f) ?? 0, list: isList(f) ? 1 : 0 }))
713
720
  .filter((c) => c.tokens >= 3)
@@ -773,6 +780,7 @@ export function harvestTokens(root, styleFiles, codeFiles) {
773
780
  }
774
781
  return {
775
782
  tokenFile,
783
+ tokenSources,
776
784
  foreignStyles,
777
785
  namespaces,
778
786
  colors: colorList,
@@ -19,12 +19,14 @@ import { KITS } from '../profiles/kit-common.mjs';
19
19
  import { kitPaintFindings } from './kitpaint.mjs';
20
20
  import { PALETTE_CLASS_RE, blankComments } from '../harvest/paint.mjs';
21
21
  import { harvestTokens, extractStyling, normalizeHex, isGrey } from '../harvest/tokens.mjs';
22
- import { harvestComponents, definedComponents } from '../harvest/components.mjs';
22
+ import { harvestComponents, definedComponents, tsconfigAliases } from '../harvest/components.mjs';
23
23
  import { findDuplicates } from '../harvest/duplicates.mjs';
24
24
  import { repoTokenDefs } from './tokentwins.mjs';
25
25
  import { dupeCopiesOf } from './avoidedimports.mjs';
26
26
  import { loadExclusions } from './exclusions.mjs';
27
27
  import { WIDGET_CONFIG_RE } from './exempt.mjs';
28
+ import { buttonCandidates } from './handmade.mjs';
29
+ import { resolveWorkspaces } from './workspaces.mjs';
28
30
  import { hexRgb } from './nearpairs.mjs';
29
31
  import { typefaceOf, GENERIC_FONTS } from './typefaces.mjs';
30
32
 
@@ -63,6 +65,9 @@ export { kitPaintFindings };
63
65
  export { tokenTwinFindings, tokenDefsOf } from './tokentwins.mjs';
64
66
  export { avoidedImportFindings, canonicalCopy } from './avoidedimports.mjs';
65
67
  export { isChartFile, chartSystemOf, chartTier, chartFindings } from './charts.mjs';
68
+ // a button from scratch where the repo has a Button (9.2.0); learnSystem
69
+ // hands over `buttons`, `workspaces` and `packageDeps` for buttonFor
70
+ export { handmadeButtons, handmadeButtonFindings, buttonCandidates, buttonFor, BUTTON_MIN_USES } from './handmade.mjs';
66
71
 
67
72
  // Radius, font size, shadow and typeface: the patterns, so both checkers agree
68
73
  // on what a declaration is and what counts as a disciplined value.
@@ -137,8 +142,26 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
137
142
  }
138
143
  }
139
144
 
145
+ // the Button the repo answers with, per package (lib/handmade.mjs)
146
+ const workspaces = resolveWorkspaces(repoRoot);
147
+ const buttons = buttonCandidates(ledger, read, workspaces, tsconfigAliases(repoRoot));
148
+ const packageDeps = new Map();
149
+ for (const w of workspaces) {
150
+ let pj = null;
151
+ try { pj = JSON.parse(read(`${w.dir}/package.json`) ?? 'null'); } catch { /* not a package after all */ }
152
+ packageDeps.set(w.dir, new Set(Object.keys({ ...pj?.dependencies, ...pj?.devDependencies, ...pj?.peerDependencies })));
153
+ }
154
+
140
155
  return {
141
156
  tokenFile: t.tokenFile,
157
+ buttons,
158
+ workspaces,
159
+ packageDeps,
160
+ // The files that state the palette: token stylesheets, a Tailwind config,
161
+ // palette data, a kit theme. Only there is a token's raw value the
162
+ // definition; in any other file it is a paste of the value where the
163
+ // name belongs, and a guard should say so (9.1.3).
164
+ tokenSources: [...new Set([...(t.tokenSources ?? []), ...(kit?.themeFiles ?? []), t.tokenFile].filter(Boolean))],
142
165
  colors: t.colors,
143
166
  // Every named colour, variants included — not the counted palette. The
144
167
  // report counts a token's base statement only, because a dark theme is
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Hand-made buttons where the repo already has a Button.
3
+ *
4
+ * The bad-neighbour tests on Twenty (2026-09-29): with the edit hook on,
5
+ * Haiku reached zero value findings and still hand-made the button four
6
+ * times in five, with clean token colours. The value checks cannot see the
7
+ * choice; this one does. Buttons only, for now.
8
+ *
9
+ * Fleet probe, 204 clones (2026-09-29): 52 have a Button, 38 of those still
10
+ * hand-make buttons in page files, but most of those are clickable rows,
11
+ * tabs, toggles and "show more" links on a button tag for keyboard access,
12
+ * which nobody wants replaced. So the trigger is narrow: an element DRESSED
13
+ * as a button (a real background plus padding, a border or a radius), or
14
+ * one that calls itself a button by name and renders the raw tag. A reset
15
+ * (background: none; border: 0) is a row, not a button, and stays silent.
16
+ *
17
+ * The Button it points at must be visible from the file's own package. In
18
+ * Twenty the most-used Button is the marketing site's (313 uses); the app
19
+ * imports its own from twenty-ui. Pointing the app at the website's button
20
+ * would be worse than silence.
21
+ */
22
+
23
+ import { posix } from 'node:path';
24
+
25
+ const stripExt = (f) => f.replace(/\.[jt]sx?$/, '').replace(/\/index$/, '');
26
+
27
+ /** Imports of a Button below this are a stray, not the repo's answer. */
28
+ export const BUTTON_MIN_USES = 20;
29
+
30
+ const NOT_OWN_CODE_RE = /(^|\/)(stories|__stories__|__tests__|__mocks__|test|tests|spec|docs?|examples?|playground|storybook|showcase|sandbox|demo|fixtures?)\/|\.(stories|test|spec)\.[jt]sx?$|(Sandbox|Showcase|Demo|Playground)\.[jt]sx?$/;
31
+ // the system's own layer: a Button-ish thing here is the system being built
32
+ const SYSTEM_LAYER_RE = /(^|\/)(ui|design-system|primitives|core)\/|[\w.-]*button[\w.-]*\.[jt]sx?$/i;
33
+ // A name that says the element is not the action button: a close cross, a
34
+ // chevron, a tab, a chip, a row that happens to be focusable. The fleet
35
+ // probe (five repos, 2026-09-29) found these were most of the styled
36
+ // buttons in page files, and none of them wants the Button.
37
+ const OTHER_ROLE_RE = /(Icon|Close|Toggle|Expand|Collapse|Tab|Pill|Chip|Row|Item|Menu|Delete|Remove|Clear|Trigger|Header|Segment|Switch|Mode|Dot|Hidden|Tooltip|Info|Avatar|Handle|Link|Nav|Thumb|Step|Card|Tile|Option|Cell|Accordion|Summary|Tree|Folder|Image|Query|Checkbox|Radio|Swatch|Uploader|Container|Place|Bar|Control|Filter|Select|Value|Indicator)/;
38
+
39
+ // Dressed as a button: real padding, and a real background or a real border.
40
+ // A reset (background: none; border: 0; padding: 0) is a row, not a button.
41
+ // An outline button has a border and padding but no background, so either
42
+ // paint counts (Twenty's wizard SecondaryButton, 2026-09-29).
43
+ // `\s*(?!\s)` pins the value's start: without it the regex backtracks past
44
+ // the space after the colon and reads "background: none" as paint
45
+ const REAL_BG = /(?:^|[\s;{])background(?:-color)?\s*:\s*(?!\s)(?!(?:none|transparent|inherit|unset|initial)\s*[;}])[^;}]*[;}]/i;
46
+ const REAL_BORDER = /(?:^|[\s;{])border(?:-(?:top|bottom|left|right))?\s*:\s*(?!\s)(?!(?:none|0|0px|unset)\s*[;}])[^;}]*[;}]/i;
47
+ const REAL_PAD = /(?:^|[\s;{])padding(?:-(?:top|bottom|left|right|inline|block))?\s*:\s*(?!\s)(?!0(?:px)?\s*[;}])[^;}]*[;}]/i;
48
+ // a pseudo-state block (&:hover { background: ... }) is a state, not the
49
+ // resting paint; a class variant block (&.primary { ... }) still counts
50
+ // `${...}` interpolations carry braces of their own (arrow functions,
51
+ // destructured props), so they are blanked first, by brace count, and each
52
+ // reads as a value, not as an empty one
53
+ const blankInterpolations = (body) => {
54
+ let out = '', i = 0;
55
+ while (i < body.length) {
56
+ if (body[i] === '$' && body[i + 1] === '{') {
57
+ let depth = 0, j = i + 1;
58
+ for (; j < body.length; j++) { if (body[j] === '{') depth++; else if (body[j] === '}' && --depth === 0) break; }
59
+ out += '_'; i = j + 1;
60
+ } else out += body[i++];
61
+ }
62
+ return out;
63
+ };
64
+ const stripStates = (body) => blankInterpolations(body).replace(/&?(?::[\w-]+(?:\([^)]*\))?|\[[^\]]*\])+\s*\{[^{}]*\}/g, ' ');
65
+ // An action button carries a label the designer set (a weight, a size, a
66
+ // transform). A card, a select trigger or a list row on a button tag reads
67
+ // differently: text to the left, content spread apart, a column, a stretch
68
+ // to fill the row, or a fixed position. Fleet probe, fifteen repos
69
+ // (2026-09-29): those tells were nearly every innocent hit.
70
+ const LABEL_CSS = /(?:^|[\s;{])(?:font-weight|font-size|text-transform|font-family|letter-spacing)\s*:/i;
71
+ const OTHER_SHAPE_CSS = /(?:^|[\s;{])(?:text-align\s*:\s*left|justify-content\s*:\s*space-between|flex-direction\s*:\s*column|flex\s*:\s*1\b|width\s*:\s*100%|position\s*:\s*(?:absolute|fixed))/i;
72
+ const dressedCss = (raw) => {
73
+ const body = stripStates(raw);
74
+ return REAL_PAD.test(body) && (REAL_BG.test(body) || REAL_BORDER.test(body)) && LABEL_CSS.test(body) && !OTHER_SHAPE_CSS.test(body);
75
+ };
76
+
77
+ // class strings: a bare bg- or border class (a hover: or focus: variant is a
78
+ // state, not the resting paint) plus non-zero padding
79
+ const TW_BG = /(?:^|\s)bg-(?!transparent|inherit|none)[\w[\]#/.-]/;
80
+ const TW_BORDER = /(?:^|\s)border(?:\s|$|-(?!none|0(?:\s|$)|transparent)[\w[\]#/.-]*)/;
81
+ // vertical padding of a full step or more: a badge (py-0.5), an avatar ring
82
+ // (p-0.5) or a select trigger sized by height alone is not a button
83
+ const TW_PAD = /(?:^|\s)p(?:y)?-(?:[1-9]\d*(?:\.\d+)?|\[)/;
84
+ const TW_LABEL = /(?:^|\s)(?:font-(?:medium|semibold|bold|black)|text-white|text-[\w-]*foreground|uppercase)(?:\s|$)/;
85
+ const TW_OTHER_SHAPE = /(?:^|\s)(?:text-left|justify-between|flex-col|items-start|placeholder:[\w-]+|absolute|fixed|hidden|invisible|border-dashed|rounded-full|(?:h|w|size)-(?:\d+|\[[^\]]*\])(?=\s|$))/;
86
+ const dressedClasses = (cls) => TW_PAD.test(cls) && (TW_BG.test(cls) || TW_BORDER.test(cls)) && TW_LABEL.test(cls) && !TW_OTHER_SHAPE.test(cls);
87
+
88
+ const INLINE_BG = /background(?:Color)?\s*:\s*(?!\s)['"`](?!transparent|none|inherit)/;
89
+ const INLINE_BORDER = /border(?:Top|Bottom|Left|Right)?\s*:\s*(?!\s)['"`](?!none|0)/;
90
+ const INLINE_PAD = /padding(?:Top|Bottom|Left|Right)?\s*:\s*(?!\s)(?!0(?:[,\s}]|$)|['"`]0(?:px)?['"`])/;
91
+ const INLINE_LABEL = /(?:fontWeight|fontSize|textTransform|fontFamily)\s*:/;
92
+ const dressedInline = (style) => INLINE_PAD.test(style) && (INLINE_BG.test(style) || INLINE_BORDER.test(style)) && INLINE_LABEL.test(style);
93
+
94
+ /**
95
+ * The hand-made buttons in a piece of component code, each with where it
96
+ * starts and how it gave itself away.
97
+ * @returns [{ index, name, how }] how: 'styled' | 'tag'
98
+ */
99
+ export function handmadeButtons(text) {
100
+ const out = [];
101
+ const src = String(text ?? '');
102
+
103
+ // const StyledX = styled.button`...` / styled('button')({...})
104
+ for (const m of src.matchAll(/(?:const|let|var)\s+(\w+)\s*=\s*styled(?:\.button|\(\s*['"]button['"]\s*\))(?:<[^`(]*?>)?\s*(?:`([\s\S]*?)`|\(\s*\{([\s\S]*?)\}\s*\))/g)) {
105
+ const name = m[1];
106
+ if (OTHER_ROLE_RE.test(name)) continue;
107
+ // an object style ({ backgroundColor: '#fff', padding: 8 }) read as CSS
108
+ const body = m[2] ?? `${(m[3] ?? '').replace(/,/g, ';').replace(/([A-Z])/g, (c) => `-${c.toLowerCase()}`)};`;
109
+ if (dressedCss(body)) out.push({ index: m.index, name, how: 'styled' });
110
+ }
111
+
112
+ // <button className="bg-brand px-4 py-2"> / <button style={{ background: ..., padding: ... }}>
113
+ for (const m of src.matchAll(/<button\b([^>]*)>/g)) {
114
+ const attrs = m[1];
115
+ const cls = /className\s*=\s*(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`\})/.exec(attrs);
116
+ const style = /style\s*=\s*\{\{([^}]*)\}\}/.exec(attrs);
117
+ if ((cls && dressedClasses(cls[1] ?? cls[2] ?? cls[3] ?? '')) || (style && dressedInline(style[1]))) out.push({ index: m.index, name: null, how: 'tag' });
118
+ }
119
+ return out.sort((a, b) => a.index - b.index);
120
+ }
121
+
122
+ /**
123
+ * How the repo imports THIS Button, read from the files that use it: the
124
+ * most common specifier that resolves to the definition. A relative path
125
+ * resolves by position, an alias (@/ui/Button) by its tail, a package name
126
+ * by the workspace it names. The harvest credits every same-named copy when
127
+ * it cannot resolve a package import, so its usedIn for the website's
128
+ * Button holds app files importing twenty-ui/input; asking "which spec
129
+ * resolves to this file" is what keeps the answer honest.
130
+ */
131
+ function importSpecOf(def, read, workspaces, aliases = []) {
132
+ const counts = new Map();
133
+ const stem = stripExt(def.file);
134
+ const resolves = (spec, from) => {
135
+ if (spec.startsWith('.')) {
136
+ const target = posix.normalize(posix.join(posix.dirname(from), spec));
137
+ return stem === target || stem === `${target}/index` || def.file.startsWith(`${target}/`);
138
+ }
139
+ // a tsconfig path alias (@sentry/scraps/button → static/app/components/core/button)
140
+ const al = aliases.find((x) => spec === x.prefix || spec.startsWith(`${x.prefix}/`));
141
+ if (al) {
142
+ const target = posix.normalize(spec === al.prefix ? al.dir : `${al.dir}/${spec.slice(al.prefix.length + 1)}`).replace(/\.[cm]?[jt]sx?$/, '');
143
+ return stem === target || stem === `${target}/index` || def.file.startsWith(`${target}/`);
144
+ }
145
+ const alias = /^[@~#]\/(.+)$/.exec(spec);
146
+ if (alias) return stem.endsWith(`/${alias[1]}`) || stem.endsWith(`/${alias[1]}/index`) || def.file.includes(`/${alias[1]}/`);
147
+ const ws = (workspaces ?? []).find((w) => spec === w.name || spec.startsWith(`${w.name}/`));
148
+ return !!ws && def.file.startsWith(`${ws.dir}/`);
149
+ };
150
+ for (const f of def.usedIn ?? []) {
151
+ const text = read(f);
152
+ if (!text) continue;
153
+ for (const m of text.matchAll(/import\s+(?:type\s+)?([^'";]*?)\s+from\s+['"]([^'"]+)['"]/g)) {
154
+ if (!/\bButton\b/.test(m[1])) continue;
155
+ if (resolves(m[2], f)) counts.set(m[2], (counts.get(m[2]) ?? 0) + 1);
156
+ break;
157
+ }
158
+ }
159
+ return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
160
+ }
161
+
162
+ /**
163
+ * The Buttons a repo could answer with: every reusable component called
164
+ * Button, in the team's own code, imported at least BUTTON_MIN_USES times.
165
+ * @returns [{ file, usageCount, spec }] spec: how the repo imports it, proven
166
+ */
167
+ export function buttonCandidates(components, read, workspaces = [], aliases = []) {
168
+ return components
169
+ .filter((c) => c.name === 'Button' && !c.isPage && c.usageCount >= BUTTON_MIN_USES && !NOT_OWN_CODE_RE.test(c.file))
170
+ .map((c) => ({ file: c.file, usageCount: c.usageCount, spec: importSpecOf(c, read, workspaces, aliases) }))
171
+ // no file provably imports it (an alias the repo resolves elsewhere, or a
172
+ // name the harvest credited from other copies): no answer to point at
173
+ .filter((c) => c.spec)
174
+ .sort((a, b) => b.usageCount - a.usageCount);
175
+ }
176
+
177
+ /** The workspace package a file belongs to, or null. */
178
+ export const packageOf = (file, workspaces) =>
179
+ (workspaces ?? []).filter((w) => file === w.dir || file.startsWith(`${w.dir}/`)).sort((a, b) => b.dir.length - a.dir.length)[0] ?? null;
180
+
181
+ /**
182
+ * The Button a file should reach for: the most-used candidate visible from
183
+ * the file's package (its own, one it depends on, or code outside every
184
+ * package). Null when the repo has no answer for this file.
185
+ */
186
+ export function buttonFor(file, { buttons = [], workspaces = [], packageDeps = new Map() } = {}) {
187
+ if (!buttons.length) return null;
188
+ const mine = file ? packageOf(file, workspaces) : null;
189
+ const deps = mine ? packageDeps.get(mine.dir) ?? new Set() : new Set();
190
+ const visible = buttons.filter((b) => {
191
+ const theirs = packageOf(b.file, workspaces);
192
+ if (!mine || !theirs) return true;
193
+ return theirs.dir === mine.dir || deps.has(theirs.name);
194
+ });
195
+ return visible[0] ?? null;
196
+ }
197
+
198
+ /**
199
+ * Findings for the hand-made buttons in a file, against the Button the repo
200
+ * answers with. Warnings: the check has not yet earned a violation.
201
+ */
202
+ export function handmadeButtonFindings(text, { file = null } = {}, k) {
203
+ if (!file || SYSTEM_LAYER_RE.test(file) || NOT_OWN_CODE_RE.test(file)) return [];
204
+ const button = buttonFor(file, k);
205
+ if (!button) return [];
206
+ const out = [];
207
+ for (const h of handmadeButtons(text)) {
208
+ const what = h.how === 'styled' ? `A styled button (${h.name})` : 'A button tag styled by hand';
209
+ out.push({
210
+ rule: 'handmade-button', severity: 'warning', index: h.index,
211
+ message: `${what} where the repo already has <Button> (imported ${button.usageCount}x${button.spec ? ` from ${button.spec}` : `, ${button.file}`}).`,
212
+ fix: `Use ${button.spec ? `import { Button } from '${button.spec}'` : `the Button in ${button.file}`}. If it needs a kind the Button lacks, add a variant there rather than a new button here.`,
213
+ });
214
+ }
215
+ return out;
216
+ }
@@ -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.1.1';
4
+ export const VERSION = '9.2.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.
@@ -57,14 +57,37 @@ const memo = new Map();
57
57
  const mtime = (p) => { try { return statSync(p).mtimeMs; } catch { return 0; } };
58
58
 
59
59
  export function resolveWorkspaces(root) {
60
- const key = `${mtime(join(root, 'package.json'))}:${mtime(join(root, 'pnpm-workspace.yaml'))}`;
60
+ const sub = workspaceSubroot(root);
61
+ const at = sub ? join(root, sub) : root;
62
+ const key = `${sub ?? ''}:${mtime(join(at, 'package.json'))}:${mtime(join(at, 'pnpm-workspace.yaml'))}`;
61
63
  const hit = memo.get(root);
62
64
  if (hit && hit.key === key) return hit.value.map((w) => ({ ...w }));
63
- const value = resolveWorkspacesUncached(root);
65
+ const value = resolveWorkspacesUncached(at).map((w) => (sub ? { ...w, dir: `${sub}/${w.dir}` } : w));
64
66
  memo.set(root, { key, value });
65
67
  return value.map((w) => ({ ...w }));
66
68
  }
67
69
 
70
+ const declaresWorkspaces = (dir) => {
71
+ const pkg = readJSON(join(dir, 'package.json'));
72
+ return !!(Array.isArray(pkg?.workspaces) || Array.isArray(pkg?.workspaces?.packages) || existsSync(join(dir, 'pnpm-workspace.yaml')));
73
+ };
74
+ /**
75
+ * A polyglot repo keeps its JavaScript one level down: Mattermost's root is
76
+ * Go and webapp/package.json declares the workspaces (2026-09-29). When the
77
+ * root declares none, the first immediate subfolder that does is the
78
+ * workspace root, and every package dir is reported under it.
79
+ */
80
+ function workspaceSubroot(root) {
81
+ if (declaresWorkspaces(root)) return null;
82
+ let entries;
83
+ try { entries = readdirSync(root, { withFileTypes: true }); } catch { return null; }
84
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
85
+ if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules') continue;
86
+ if (declaresWorkspaces(join(root, e.name))) return e.name;
87
+ }
88
+ return null;
89
+ }
90
+
68
91
  function resolveWorkspacesUncached(root) {
69
92
  const patterns = [];
70
93
  const pkg = readJSON(join(root, 'package.json'));
@@ -23,6 +23,7 @@ import { oklab } from '../lib/color.mjs';
23
23
  import { tokenTwinFindings } from '../lib/tokentwins.mjs';
24
24
  import { avoidedImportFindings } from '../lib/avoidedimports.mjs';
25
25
  import { isChartFile, chartFindings } from '../lib/charts.mjs';
26
+ import { handmadeButtonFindings } from '../lib/handmade.mjs';
26
27
 
27
28
  // What this engine measures — shipped with every result, clean or not.
28
29
  export const CHECKS = [
@@ -43,10 +44,11 @@ export const CHECKS = [
43
44
  // MUI repo says the kit check ran.
44
45
  export const KIT_CHECK = 'colours and pixel sizes written onto kit components where the theme has a value';
45
46
  export const PALETTE_CHECK = 'palette classes where the theme names a colour';
47
+ export const BUTTON_CHECK = 'hand-made buttons where the repo has a Button';
46
48
  export function checksFor(k) {
47
- if (k?.kit) return [...CHECKS, KIT_CHECK];
48
- if (k?.tailwind || k?.shadcn) return [...CHECKS, PALETTE_CHECK];
49
- return CHECKS;
49
+ const list = k?.kit ? [...CHECKS, KIT_CHECK] : (k?.tailwind || k?.shadcn) ? [...CHECKS, PALETTE_CHECK] : [...CHECKS];
50
+ if (k?.buttons?.length) list.push(BUTTON_CHECK);
51
+ return list;
50
52
  }
51
53
  // "an MUI component", "an Ant Design component", "a Mantine component"
52
54
 
@@ -197,18 +199,27 @@ export function validateContent(content, k) {
197
199
 
198
200
  // ---------- colours ----------
199
201
  const seenHere = new Set();
202
+ const statesTokens = !!file && k.tokenSources?.has(file);
200
203
  for (const c of got.colors) {
201
204
  if (seenHere.has(`${c.value}:${c.index}`)) continue;
202
205
  seenHere.add(`${c.value}:${c.index}`);
203
206
  if (kitOwnsColours || chartFile) continue; // the kit judge or the chart rule said it, or judged it not paint
204
207
  const info = k.colorInfo.get(c.value);
205
- if (info?.isToken) continue; // it IS the token's value — the system can see it
208
+ // A token's raw value is the definition only inside a file that states
209
+ // the palette (a token stylesheet, a Tailwind config, a kit theme). In a
210
+ // component it is the value pasted where the name belongs: the system
211
+ // cannot see it, and the next reader copies the hex. Until 9.1.3 every
212
+ // exact match walked free, and hooked agents on Twenty wrote
213
+ // background: #4a38f5 with a clean bill (2026-09-29).
214
+ if (info?.isToken && statesTokens) continue;
206
215
  const near = nearestToken(c.value, k);
207
216
  const priors = discount(info?.count, localColor, c.value);
208
217
  if (near && near.d === 0) {
218
+ const names = k.tokenNames?.get(c.value) ?? [];
219
+ const by = names.length ? `${css ? `var(${names[0]})` : names[0]}` : `the token that already holds ${near.value}`;
209
220
  add('hardcoded-colour', 'violation', c.index,
210
221
  `Hardcoded colour ${c.value} duplicates an existing token value.`,
211
- `Use the token that already holds ${near.value}${k.tokens.tokenFile ? ` (defined in ${k.tokens.tokenFile})` : ''}.`);
222
+ `Use ${by}${k.tokens.tokenFile ? ` (defined in ${k.tokens.tokenFile})` : ''}.`);
212
223
  } else if (near && near.d <= 8) {
213
224
  add('near-token-twin', 'violation', c.index,
214
225
  `${c.value} is visually identical to the token ${near.value}.`,
@@ -224,6 +235,11 @@ export function validateContent(content, k) {
224
235
  }
225
236
  }
226
237
 
238
+ // ---------- the choice, not the value: a button from scratch ----------
239
+ // The value checks above cannot see it. Hooked agents on Twenty passed
240
+ // them and still hand-made the button four times in five (2026-09-29).
241
+ if (!css) for (const f of handmadeButtonFindings(text, { file }, k)) add(f.rule, f.severity, f.index, f.message, f.fix);
242
+
227
243
  // ---------- spacing ----------
228
244
  for (const s of got.spacing) {
229
245
  if (kitPx.has(s.value)) continue; // the kit line above said it
@@ -19,6 +19,8 @@ import { findDuplicates } from '../harvest/duplicates.mjs';
19
19
  import { harvestContext } from '../harvest/context.mjs';
20
20
  import { loadExclusions } from '../lib/exclusions.mjs';
21
21
  import { resolveWorkspaces } from '../lib/workspaces.mjs';
22
+ import { buttonCandidates } from '../lib/handmade.mjs';
23
+ import { tsconfigAliases } from '../harvest/components.mjs';
22
24
  import { neverImportedComponents } from '../lib/neverimported.mjs';
23
25
  import { typefaceOf } from '../lib/typefaces.mjs';
24
26
  import { hexRgb } from '../lib/nearpairs.mjs';
@@ -80,6 +82,14 @@ export function loadKnowledge(root) {
80
82
  const colorInfo = new Map(); // value → { count, isToken }
81
83
  for (const c of tokens.colors) colorInfo.set(c.value, { count: c.count, isToken: c.isToken });
82
84
  const tokenColors = [...colorInfo.entries()].filter(([, i]) => i.isToken).map(([v]) => v);
85
+ // value → the custom-property names that hold it, so a check can say
86
+ // "use var(--color-brand)" rather than only "use the token"
87
+ const tokenNames = new Map();
88
+ for (const c of tokens.colors) if (c.names?.length) tokenNames.set(c.value, c.names);
89
+ // the files that state the palette (token stylesheets, a Tailwind config,
90
+ // a kit theme, palette data): the only files where a token's raw value is
91
+ // the definition rather than a paste of it
92
+ const tokenSources = new Set([...(tokens.tokenSources ?? []), ...(kit?.themeFiles ?? []), tokens.tokenFile].filter(Boolean));
83
93
 
84
94
  // spacing the repo already uses: raw CSS values with counts, plus whether
85
95
  // the repo styles spacing through Tailwind at all (decides what "on-scale" means)
@@ -123,6 +133,18 @@ export function loadKnowledge(root) {
123
133
 
124
134
  const neverImported = neverImportedComponents(components, profile.uiDir);
125
135
 
136
+ // The Button the repo answers with, per package: the candidates, and what
137
+ // each workspace package depends on, so a file is pointed at a Button it
138
+ // can actually import (lib/handmade.mjs).
139
+ const readK = (f) => readSource(join(root, f));
140
+ const buttons = buttonCandidates(components, readK, workspaces, tsconfigAliases(root));
141
+ const packageDeps = new Map();
142
+ for (const w of workspaces) {
143
+ let pj = null;
144
+ try { pj = JSON.parse(readK(`${w.dir}/package.json`) ?? 'null'); } catch { /* not a package after all */ }
145
+ packageDeps.set(w.dir, new Set(Object.keys({ ...pj?.dependencies, ...pj?.devDependencies, ...pj?.peerDependencies })));
146
+ }
147
+
126
148
  // The copies of each duplicated name with their usage, and the colours each
127
149
  // copy hard-codes: what the avoided-import check names (lib/avoidedimports).
128
150
  const read = (file) => readSource(join(root, file));
@@ -152,6 +174,10 @@ export function loadKnowledge(root) {
152
174
  workspaces,
153
175
  // indexes
154
176
  colorInfo,
177
+ buttons,
178
+ packageDeps,
179
+ tokenNames,
180
+ tokenSources,
155
181
  tokenColors,
156
182
  tokenColorRgb: tokenColors.map((v) => ({ value: v, rgb: hexRgb(v) })).filter((t) => t.rgb),
157
183
  spacingSeen,