roast-my-design-system 8.4.6 → 8.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/harvest/components.mjs +39 -2
- package/skills/roast-my-design-system/scripts/harvest/tokens.mjs +18 -0
- package/skills/roast-my-design-system/scripts/harvest/walk.mjs +17 -5
- package/skills/roast-my-design-system/scripts/lib/color.mjs +21 -0
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/mcp/engine.mjs +67 -3
- package/skills/roast-my-design-system/scripts/mcp/knowledge.mjs +3 -1
- package/skills/roast-my-design-system/scripts/mcp/tools.mjs +55 -12
- package/skills/roast-my-design-system/scripts/profiles/tailwind.mjs +13 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.5.0",
|
|
4
4
|
"mcpName": "io.github.gregkozakiewicz/roast-my-design-system",
|
|
5
5
|
"description": "Your AI can write the UI. This makes sure it writes your UI. A deterministic scanner scores your design system 0-100 against 112 public repos, reads React and web components (Stencil, Lit), hands you a copy-paste fix prompt for each top finding, writes rules for Claude, Cursor, Copilot and Windsurf with --apply, and runs as a local MCP server with --mcp.",
|
|
6
6
|
"keywords": [
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* compile-skill.mjs — brace/string-aware regex parsing,
|
|
5
5
|
* no AST dependency — then extended to scan the whole repo, not one ui/ dir.
|
|
6
6
|
*/
|
|
7
|
-
import { join, basename } from 'node:path';
|
|
7
|
+
import { join, basename, posix } from 'node:path';
|
|
8
8
|
import { readSource } from './walk.mjs';
|
|
9
9
|
|
|
10
10
|
const QUOTES = new Set(['"', "'", '`']);
|
|
@@ -161,6 +161,37 @@ function tagCounts(src, re) {
|
|
|
161
161
|
return counts;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
const stripExt = (f) => f.replace(/\.[cm]?[jt]sx?$/, '');
|
|
165
|
+
/**
|
|
166
|
+
* Which of several same-named definitions a file's `<Name` means, read from
|
|
167
|
+
* its imports: `import { Button } from '../ui'` or `'./ButtonV2'`, relative
|
|
168
|
+
* or through an alias (`@/ui/Button`, matched as a path suffix). A barrel
|
|
169
|
+
* (`../ui` → ui/index.ts) resolves to the copy inside that folder. A file that
|
|
170
|
+
* defines the name itself means its own. Returns [def] or null when unsure.
|
|
171
|
+
*/
|
|
172
|
+
function importedDef(src, file, name, defs) {
|
|
173
|
+
const own = defs.find((d) => d.file === file);
|
|
174
|
+
if (own) return [own];
|
|
175
|
+
for (const m of src.matchAll(/import\s+(?:type\s+)?([^'";]*?)\s+from\s*['"]([^'"]+)['"]/g)) {
|
|
176
|
+
const clause = m[1];
|
|
177
|
+
const named = /\{([^}]*)\}/.exec(clause)?.[1] ?? '';
|
|
178
|
+
const imports = named.split(',').map((x) => x.trim().replace(/^type\s+/, '')).some((x) => x === name || new RegExp(`\\s+as\\s+${name}$`).test(x))
|
|
179
|
+
|| new RegExp(`^${name}\\b`).test(clause.trim());
|
|
180
|
+
if (!imports) continue;
|
|
181
|
+
const spec = m[2];
|
|
182
|
+
let target = null, suffix = null;
|
|
183
|
+
if (spec.startsWith('.')) target = posix.normalize(posix.join(posix.dirname(file), spec));
|
|
184
|
+
else { const a = /^(?:@|~|#)\/(.+)$/.exec(spec); if (a) suffix = a[1]; else return null; }
|
|
185
|
+
const hit = defs.filter((d) => {
|
|
186
|
+
const f = stripExt(d.file);
|
|
187
|
+
if (target !== null) return f === target || f === `${target}/index` || d.file.startsWith(`${target}/`);
|
|
188
|
+
return f === suffix || f.endsWith(`/${suffix}`) || f.endsWith(`/${suffix}/index`) || d.file.includes(`/${suffix}/`);
|
|
189
|
+
});
|
|
190
|
+
return hit.length === 1 ? hit : null;
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
164
195
|
/**
|
|
165
196
|
* Harvest all components: definitions (with variants/props) + usages across the
|
|
166
197
|
* repo. `codeFiles` are relative paths; returns { components, totalDefined }.
|
|
@@ -208,7 +239,13 @@ export function harvestComponents(root, codeFiles) {
|
|
|
208
239
|
for (const [name, n] of tagCounts(src, /<([A-Z]\w*)(?=[\s/>]|$)/g)) {
|
|
209
240
|
const defs = byName.get(name);
|
|
210
241
|
if (!defs) continue;
|
|
211
|
-
|
|
242
|
+
// Two components of one name: the use belongs to the one the file
|
|
243
|
+
// imports. Crediting both made src/ui/Button and a copy in
|
|
244
|
+
// features/invoices each "used 12x" (8 and 4 in truth), so neither read
|
|
245
|
+
// as the canon (Ledgerly, 2026-09-24). Unresolved imports keep the old
|
|
246
|
+
// rule and credit every copy.
|
|
247
|
+
const owners = defs.length > 1 ? importedDef(src, file, name, defs) ?? defs : defs;
|
|
248
|
+
for (const def of owners) {
|
|
212
249
|
if (def.file === file) continue; // internal render/recursion, not adoption
|
|
213
250
|
def.usageCount += n;
|
|
214
251
|
if (def.usedIn.length < 8) def.usedIn.push(file);
|
|
@@ -308,6 +308,7 @@ export function harvestTokens(root, styleFiles, codeFiles) {
|
|
|
308
308
|
fontSizes = new Tally(), fontFamilies = new Tally(), shadows = new Tally();
|
|
309
309
|
const twColors = new Tally(), twSpacing = new Tally(), twRadii = new Tally(), twTextSizes = new Tally();
|
|
310
310
|
const twArbitrary = new Tally();
|
|
311
|
+
const themeFonts = new Tally(); // stacks a Tailwind @theme declares
|
|
311
312
|
let inlineStyleCount = 0;
|
|
312
313
|
const inlineStyleFiles = new Map();
|
|
313
314
|
// !important is the cascade admitting defeat; counted per style file.
|
|
@@ -456,6 +457,21 @@ export function harvestTokens(root, styleFiles, codeFiles) {
|
|
|
456
457
|
if (isTokenRef(v)) continue;
|
|
457
458
|
fontFamilies.add(v, file);
|
|
458
459
|
}
|
|
460
|
+
// Tailwind v4 declares the system's typefaces as --font-* rows in @theme,
|
|
461
|
+
// with no font-family line anywhere: Ledgerly's Inter lived only there, so
|
|
462
|
+
// one legacy "Helvetica Neue" read as the system face (2026-09-24). Kept
|
|
463
|
+
// apart from the declarations the report counts: a theme naming its faces
|
|
464
|
+
// is the system, not sprawl, and Ghost's theme carries a 21-font picker
|
|
465
|
+
// its users choose from. --font-weight-* is a weight scale, and the
|
|
466
|
+
// feature and variation settings are settings on a face.
|
|
467
|
+
for (const b of text.matchAll(/@theme[^{]*\{([\s\S]*?)\n\}/g)) {
|
|
468
|
+
for (const d of b[1].matchAll(/--font-([\w-]+)\s*:\s*([^;{}]+)[;}]/g)) {
|
|
469
|
+
if (d[1].includes('--') || /^(?:weight|feature|variation)(-|$)/.test(d[1])) continue;
|
|
470
|
+
const v = d[2].trim().replace(/\s+/g, ' ');
|
|
471
|
+
if (isTokenRef(v) || v === 'initial' || v === 'inherit') continue;
|
|
472
|
+
themeFonts.add(v, file);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
459
475
|
for (const m of text.matchAll(SHADOW_PROPS)) shadows.add(m[1].trim().replace(/\s+/g, ' '), file);
|
|
460
476
|
};
|
|
461
477
|
|
|
@@ -750,6 +766,8 @@ export function harvestTokens(root, styleFiles, codeFiles) {
|
|
|
750
766
|
radii: radii.toJSON(),
|
|
751
767
|
fontSizes: fontSizes.toJSON(),
|
|
752
768
|
fontFamilies: fontFamilies.toJSON(),
|
|
769
|
+
// faces a Tailwind v4 @theme declares: the system's own, not counted
|
|
770
|
+
themeFonts: themeFonts.toJSON(),
|
|
753
771
|
shadows: shadows.toJSON(),
|
|
754
772
|
tailwind: {
|
|
755
773
|
colors: twColors.toJSON(),
|
|
@@ -197,6 +197,11 @@ const STYLING_DEPS = [
|
|
|
197
197
|
{ pkg: 'class-variance-authority', label: 'CVA' },
|
|
198
198
|
];
|
|
199
199
|
|
|
200
|
+
// Plain names a team gives its own shared component folder, checked in order.
|
|
201
|
+
// A bare ui/ at the root is left out: there it is usually the whole front
|
|
202
|
+
// end (Azure-ipam), not a shelf of shared parts.
|
|
203
|
+
const OWN_UI_DIRS = ['src/ui', 'app/ui', 'src/shared/ui', 'shared/ui', 'src/lib/ui', 'src/design-system'];
|
|
204
|
+
|
|
200
205
|
/** Framework + design-system classification (1.0 logic, widened for monorepos). */
|
|
201
206
|
export function profileRepo(root, files) {
|
|
202
207
|
const pkg = readJSON(join(root, 'package.json')) || {};
|
|
@@ -251,21 +256,28 @@ export function profileRepo(root, files) {
|
|
|
251
256
|
}
|
|
252
257
|
|
|
253
258
|
const componentsJson = readJSON(join(root, 'components.json'));
|
|
254
|
-
const
|
|
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));
|
|
@@ -196,3 +196,24 @@ const HSL_TRIPLET =
|
|
|
196
196
|
/^\s*(-?\d+(?:\.\d+)?)(?:deg)?\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%(?:\s*\/\s*(\d+(?:\.\d+)?%?))?\s*$/;
|
|
197
197
|
// bare RGB channels (dub: --bg-default: 255 255 255), see harvest/tokens.mjs
|
|
198
198
|
const RGB_TRIPLET = /^\s*(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})(?:\s*\/\s*\d+(?:\.\d+)?%?)?\s*$/;
|
|
199
|
+
|
|
200
|
+
// Gamma-encoded sRGB (0-255) → linear, the inverse of `gamma` above.
|
|
201
|
+
const linear = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); };
|
|
202
|
+
/**
|
|
203
|
+
* A colour in OKLab ({ L, a, b }), where a straight-line distance tracks what
|
|
204
|
+
* the eye sees; null when the value is not a literal colour. Used to find the
|
|
205
|
+
* theme colour nearest to a palette class, hue included.
|
|
206
|
+
*/
|
|
207
|
+
export function oklab(value) {
|
|
208
|
+
const c = parseColor(value);
|
|
209
|
+
if (!c) return null;
|
|
210
|
+
const r = linear(c.r), g = linear(c.g), b = linear(c.b);
|
|
211
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
212
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
213
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
214
|
+
return {
|
|
215
|
+
L: 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
|
|
216
|
+
a: 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
|
|
217
|
+
b: 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Single version constant for the engine — imported by diagnose (report
|
|
2
2
|
// footer) and rules (generated-by line). This is the bump spot that used to
|
|
3
3
|
// live as a const inside diagnose/index.mjs.
|
|
4
|
-
export const VERSION = '8.
|
|
4
|
+
export const VERSION = '8.5.0';
|
|
5
5
|
// The shape of harvest.json and summary.json. Bumped only when a field is
|
|
6
6
|
// renamed, removed or changes meaning; a new field is not a new schema. A
|
|
7
7
|
// tool comparing two scans compares like with like by this number, not by VERSION.
|
|
@@ -18,6 +18,8 @@ import { extraDeclarations, fontDeclarations } from '../lib/declarations.mjs';
|
|
|
18
18
|
import { typefaceOf, GENERIC_FONTS } from '../lib/typefaces.mjs';
|
|
19
19
|
import { kitPaintFindings } from '../lib/kitpaint.mjs';
|
|
20
20
|
import { PALETTE_CLASS_RE, GREY_HUE_RE, DARK_WB_RE, DEMO_PATH_RE, blankComments } from '../harvest/paint.mjs';
|
|
21
|
+
import { TAILWIND_DEFAULTS } from '../profiles/tailwind-defaults.mjs';
|
|
22
|
+
import { oklab } from '../lib/color.mjs';
|
|
21
23
|
|
|
22
24
|
// What this engine measures — shipped with every result, clean or not.
|
|
23
25
|
export const CHECKS = [
|
|
@@ -145,6 +147,7 @@ export function validateContent(content, k) {
|
|
|
145
147
|
// foreground name, a bg- class a surface or background name
|
|
146
148
|
const names = paletteRule === 'tailwind' ? (tw.names ?? []) : ['primary', 'foreground', 'muted-foreground', 'background', 'border'];
|
|
147
149
|
const pick = (re) => names.find((n) => re.test(n)) ?? names[0] ?? 'brand';
|
|
150
|
+
const values = paletteRule === 'tailwind' ? tw.values ?? {} : {};
|
|
148
151
|
// a class named in a comment paints nothing; blanked, not cut, so the
|
|
149
152
|
// line numbers below still hold
|
|
150
153
|
const code = blankComments(text);
|
|
@@ -155,12 +158,20 @@ export function validateContent(content, k) {
|
|
|
155
158
|
for (const m of hits.sort((a, b) => a.index - b.index)) {
|
|
156
159
|
const cls = m[0];
|
|
157
160
|
const util = cls.replace(/^((?:[\w-]+:)*[a-z]+)-.*$/, '$1');
|
|
158
|
-
const
|
|
159
|
-
|
|
161
|
+
const shade = cls.replace(/^(?:[\w-]+:)*[a-z]+-/, '').replace(/\/\d+$/, '');
|
|
162
|
+
const role = utilRole(util);
|
|
163
|
+
// the theme colour nearest by value among the names that suit the
|
|
164
|
+
// utility; the old pick by name alone only when none is close
|
|
165
|
+
const example = nearestThemeName(shade, role, values)
|
|
166
|
+
?? (role === 'text' ? pick(/ink|text|fg|foreground/) : role === 'surface' ? pick(/surface|bg|background|canvas/) : pick(/border|edge|line|ring/));
|
|
167
|
+
if (retuned.has(shade)) continue;
|
|
160
168
|
if (!hasFamily(cls)) continue;
|
|
169
|
+
const word = hueWord(shade, names);
|
|
161
170
|
add('palette-class', 'violation', m.index,
|
|
162
171
|
`Palette class ${cls} where the theme names its colours (${themeFile}).`,
|
|
163
|
-
`Use a theme token as the class (${util}-${example}).
|
|
172
|
+
`Use a theme token as the class (${util}-${example}). ${names.includes(word)
|
|
173
|
+
? `If no token fits, add ${/^[aeiou]/.test(word) ? 'an' : 'a'} ${word} shade to the theme once and use it by name.`
|
|
174
|
+
: `If no token fits, add one to the theme once, for example ${word}, and use ${util}-${word}.`}`);
|
|
164
175
|
}
|
|
165
176
|
}
|
|
166
177
|
|
|
@@ -306,6 +317,59 @@ export function validateContent(content, k) {
|
|
|
306
317
|
return { findings, checked: checksFor(k) };
|
|
307
318
|
}
|
|
308
319
|
|
|
320
|
+
// ---------- the theme colour a palette class stands in for ----------
|
|
321
|
+
// Which theme names suit a utility, read from the name: a text class wants an
|
|
322
|
+
// ink, a bg class a surface, a border class an edge. A name that says none of
|
|
323
|
+
// these (brand, warning, positive) is an accent and suits every utility.
|
|
324
|
+
const TEXT_NAME_RE = /ink|text|fg|foreground/;
|
|
325
|
+
const SURFACE_NAME_RE = /surface|(^|-)bg(-|$)|background|canvas|soft|tint|wash/;
|
|
326
|
+
const EDGE_NAME_RE = /border|edge|line|ring|divider|outline/;
|
|
327
|
+
function utilRole(util) {
|
|
328
|
+
if (/(^|:)(?:text|placeholder|caret|decoration|fill|stroke)$/.test(util)) return 'text';
|
|
329
|
+
if (/(^|:)bg$/.test(util)) return 'surface';
|
|
330
|
+
if (/(^|:)(?:border|ring|outline|divide)$/.test(util)) return 'edge';
|
|
331
|
+
return 'any';
|
|
332
|
+
}
|
|
333
|
+
function suits(name, role) {
|
|
334
|
+
const text = TEXT_NAME_RE.test(name), surface = SURFACE_NAME_RE.test(name), edge = EDGE_NAME_RE.test(name);
|
|
335
|
+
if (role === 'any' || (!text && !surface && !edge)) return true;
|
|
336
|
+
return role === 'text' ? text && !surface && !edge : role === 'surface' ? surface : edge;
|
|
337
|
+
}
|
|
338
|
+
// Distance in OKLab with the two colour axes doubled, so a pale amber lands on
|
|
339
|
+
// the pale warning surface rather than on the near-white canvas beside it.
|
|
340
|
+
const hueDistance = (x, y) => Math.hypot(x.L - y.L, 2 * (x.a - y.a), 2 * (x.b - y.b));
|
|
341
|
+
// Past this, the nearest theme colour is a different colour, not a stand-in
|
|
342
|
+
// (bg-blue-600 against a theme of greys), and the old pick by name answers.
|
|
343
|
+
const CLOSE = 0.2;
|
|
344
|
+
/** The suitable theme name nearest to a palette shade's Tailwind value, or null. */
|
|
345
|
+
function nearestThemeName(shade, role, values) {
|
|
346
|
+
const from = oklab(TAILWIND_DEFAULTS[shade] ?? '');
|
|
347
|
+
if (!from) return null;
|
|
348
|
+
let best = null;
|
|
349
|
+
for (const [name, value] of Object.entries(values)) {
|
|
350
|
+
if (!suits(name, role)) continue;
|
|
351
|
+
const to = oklab(value);
|
|
352
|
+
if (!to) continue;
|
|
353
|
+
const d = hueDistance(from, to);
|
|
354
|
+
if (!best || d < best.d) best = { name, d };
|
|
355
|
+
}
|
|
356
|
+
return best && best.d <= CLOSE ? best.name : null;
|
|
357
|
+
}
|
|
358
|
+
// The word for a new token follows the hue, in the theme's own vocabulary when
|
|
359
|
+
// it has one (Ledgerly says positive and negative, not success and danger).
|
|
360
|
+
const HUE_WORDS = [
|
|
361
|
+
[/^(?:red|rose)-/, ['negative', 'danger', 'error', 'destructive']],
|
|
362
|
+
[/^(?:orange|amber|yellow)-/, ['warning', 'caution']],
|
|
363
|
+
[/^(?:green|emerald|lime)-/, ['positive', 'success']],
|
|
364
|
+
[/^(?:blue|sky|cyan)-/, ['info']],
|
|
365
|
+
];
|
|
366
|
+
function hueWord(shade, names) {
|
|
367
|
+
const words = HUE_WORDS.find(([re]) => re.test(shade))?.[1];
|
|
368
|
+
if (!words) return GREY_HUE_RE.test(`-${shade}`) ? 'muted' : 'accent';
|
|
369
|
+
return words.find((w) => names.some((n) => n === w || n.startsWith(`${w}-`)))
|
|
370
|
+
?? { negative: 'danger', warning: 'warning', positive: 'success', info: 'info' }[words[0]];
|
|
371
|
+
}
|
|
372
|
+
|
|
309
373
|
function looksLikeCss(text) {
|
|
310
374
|
// no JSX tags, has a `{ ...: ` declaration shape → treat as stylesheet.
|
|
311
375
|
// Sniffed on the head of the text with bounded patterns: the old version
|
|
@@ -88,7 +88,9 @@ export function loadKnowledge(root) {
|
|
|
88
88
|
const fontSizesSeen = new Map((tokens.fontSizes ?? []).map((f) => [f.value, f.count]));
|
|
89
89
|
const shadowsSeen = new Map((tokens.shadows ?? []).map((s) => [s.value, s.count]));
|
|
90
90
|
const faceCounts = new Map();
|
|
91
|
-
|
|
91
|
+
// a face the @theme declares is on-system, so a stylesheet using it is not
|
|
92
|
+
// "a new typeface" (tokens.themeFonts, harvest/tokens.mjs)
|
|
93
|
+
for (const f of [...(tokens.themeFonts ?? []), ...(tokens.fontFamilies ?? [])]) {
|
|
92
94
|
const face = typefaceOf(f.value);
|
|
93
95
|
if (face) faceCounts.set(face, (faceCounts.get(face) ?? 0) + f.count);
|
|
94
96
|
}
|
|
@@ -85,7 +85,9 @@ export function getContext(k, { path = null } = {}) {
|
|
|
85
85
|
? `SPACING: this repo has no scale, just ${k.spacingSeen.size} values in circulation. Reuse one rather than inventing value number ${k.spacingSeen.size + 1}; do not treat them as a system.`
|
|
86
86
|
: 'SPACING: nothing on record yet. Whatever you pick, the next agent will copy; pick something divisible by 4.');
|
|
87
87
|
|
|
88
|
-
|
|
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');
|
|
@@ -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
|