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 +1 -1
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/harvest/components.mjs +39 -2
- package/skills/roast-my-design-system/scripts/harvest/tokens.mjs +29 -0
- package/skills/roast-my-design-system/scripts/harvest/walk.mjs +17 -5
- package/skills/roast-my-design-system/scripts/lib/avoidedimports.mjs +93 -0
- package/skills/roast-my-design-system/scripts/lib/color.mjs +21 -0
- package/skills/roast-my-design-system/scripts/lib/guard-api.mjs +8 -0
- package/skills/roast-my-design-system/scripts/lib/nearpairs.mjs +29 -10
- package/skills/roast-my-design-system/scripts/lib/tokentwins.mjs +184 -0
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/mcp/engine.mjs +97 -6
- package/skills/roast-my-design-system/scripts/mcp/knowledge.mjs +34 -3
- package/skills/roast-my-design-system/scripts/mcp/server.mjs +1 -1
- package/skills/roast-my-design-system/scripts/mcp/tools.mjs +81 -15
- package/skills/roast-my-design-system/scripts/profiles/tailwind.mjs +13 -4
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.
|
|
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
|
-
|
|
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
|
|
255
|
-
const isShadcn = Boolean(componentsJson) || Boolean(
|
|
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 (
|
|
273
|
+
if (shadcnUiDir) {
|
|
262
274
|
try {
|
|
263
|
-
for (const f of readdirSync(join(root,
|
|
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(
|
|
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
|
|
5
|
-
* designed ramp (every good grey scale has near neighbours) and are
|
|
6
|
-
* drift needs at least one hardcoded stray.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
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
|
|
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
|
|
159
|
-
|
|
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}).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
|
89
|
-
const c = parseColor(
|
|
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(
|
|
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
|