roast-my-design-system 9.7.0 → 9.8.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 +3 -3
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/diagnose/index.mjs +12 -3
- package/skills/roast-my-design-system/scripts/harvest/coloruse.mjs +12 -2
- package/skills/roast-my-design-system/scripts/lib/fixprompt.mjs +2 -2
- package/skills/roast-my-design-system/scripts/lib/palette.mjs +7 -0
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/mcp/knowledge.mjs +2 -0
- package/skills/roast-my-design-system/scripts/mcp/tools.mjs +16 -10
- package/skills/roast-my-design-system/scripts/profiles/index.mjs +7 -1
- package/skills/roast-my-design-system/scripts/profiles/kit-common.mjs +47 -6
- package/skills/roast-my-design-system/scripts/profiles/shadcn.mjs +14 -3
- package/skills/roast-my-design-system/scripts/profiles/tailwind.mjs +56 -1
- package/skills/roast-my-design-system/scripts/rules/build.mjs +13 -3
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ npx roast-my-design-system@latest
|
|
|
17
17
|
Run it at the root of a UI repo. One second later, a report opens.
|
|
18
18
|
No account. No network. No telemetry. Nothing in your repo changes.
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
Version history is in [CHANGELOG.md](CHANGELOG.md).
|
|
21
21
|
|
|
22
22
|
## The idea
|
|
23
23
|
|
|
@@ -142,11 +142,11 @@ Eleven reports, hosted exactly as the tool writes them. Every number determinist
|
|
|
142
142
|
|
|
143
143
|
The full report for vercel/ai-chatbot. The verdict answers what the agent will learn here, the gap section comes first, then "What the repo teaches the agent", Claude's read of the scan:
|
|
144
144
|
|
|
145
|
-

|
|
146
146
|
|
|
147
147
|
The same report in light mode (one file, built-in toggle):
|
|
148
148
|
|
|
149
|
-

|
|
150
150
|
|
|
151
151
|
## Why the numbers hold
|
|
152
152
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.8.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": [
|
|
@@ -1352,10 +1352,10 @@ function whereToStartSection() {
|
|
|
1352
1352
|
else c.push(gate === 'repaint'
|
|
1353
1353
|
? { score: 20 + pt.tin.per100 / 4, metric: 'paintTin', after: 0,
|
|
1354
1354
|
title: `Repaint the ${n(pt.tin.uses)} colours from outside the theme`,
|
|
1355
|
-
sub: `${esc(s0.value)} appears ${s0.count} times
|
|
1355
|
+
sub: `${esc(s0.value)} appears ${s0.count} times.${f0 ? ` ${esc(basename(f0.file))} alone has ${f0.count} palette classes.` : ''} The theme file already has a variable for each (a grey is <code>text-muted-foreground</code>, a status colour is a Badge variant or a variable you add). Swap the class, not the value.` }
|
|
1356
1356
|
: { score: 20 + pt.tin.per100 / 4, metric: 'paintTin', after: 0,
|
|
1357
1357
|
title: `Decide what the product paints from`,
|
|
1358
|
-
sub: `${esc(s0.value)} appears ${s0.count} times
|
|
1358
|
+
sub: `${esc(s0.value)} appears ${s0.count} times.${f0 ? ` ${esc(basename(f0.file))} alone has ${f0.count} palette classes.` : ''} There is no theme file to point them at: ${P.shadcn?.sheet?.file ? `${esc(P.shadcn.sheet.file)} defines` : 'the theme file defines'} none of shadcn's colour variables, so the palette is the system today. The first move is a decision, not a swap: adopt the theme variables (define them, map the palette onto them), then repaint file by file. Swapping <code>text-slate-500</code> for <code>text-muted-foreground</code> before that leaves the text with no colour.` });
|
|
1359
1359
|
}
|
|
1360
1360
|
if (pt.doors.uses >= 5) {
|
|
1361
1361
|
const s0 = pt.doors.samples[0];
|
|
@@ -1600,8 +1600,15 @@ function agentSection() {
|
|
|
1600
1600
|
// library stays "component library"; a product repo we cannot read gets the
|
|
1601
1601
|
// quiet unrecognised chip, because not knowing IS a finding here.
|
|
1602
1602
|
const ns = h.tokens.namespaces ?? null;
|
|
1603
|
+
// A product built on a kit is named by its kit, and by its second kit when
|
|
1604
|
+
// it has one (9.8.0; 44 of 80 kit reports said "unrecognised" before, and
|
|
1605
|
+
// unleash said "custom design system" over MUI). A token namespace beside a
|
|
1606
|
+
// kit keeps a chip of its own below.
|
|
1607
|
+
const kitSecond = P.kit?.second?.name ?? P.shadcn?.second?.name ?? null;
|
|
1603
1608
|
const dsChip =
|
|
1604
|
-
ds.kind === '
|
|
1609
|
+
ds.kind === 'kit' ? `${ds.name}${kitSecond ? ` + ${kitSecond}` : ''}`
|
|
1610
|
+
: ds.kind === 'shadcn' ? `shadcn/ui${ds.cssVariables === false ? ' (utility classes, no CSS variables)' : ''}${P.shadcn?.kit?.style ? ` · ${P.shadcn.kit.style}` : ''}${kitSecond ? ` + ${kitSecond}` : ''}`
|
|
1611
|
+
: ds.kind === 'tailwind' ? 'Tailwind theme'
|
|
1605
1612
|
: ds.kind === 'library' ? ds.name
|
|
1606
1613
|
: ns ? `custom design system (--${ns.primary}-*${ns.partner ? ` + --${ns.partner}-*` : ''})`
|
|
1607
1614
|
// A tokenFile alone is a technicality (Lion's is one drawer style file);
|
|
@@ -1615,6 +1622,8 @@ const stack = [
|
|
|
1615
1622
|
h.profile.typescript ? 'TypeScript' : null,
|
|
1616
1623
|
...(h.profile.stylingDeps ?? []),
|
|
1617
1624
|
dsChip,
|
|
1625
|
+
// the kit's own variables (--mantine-*, --chakra-*) are not the team's tokens
|
|
1626
|
+
ds.kind === 'kit' && ns && !/^(?:mantine|chakra|mui|antd?)$/.test(ns.primary) ? `tokens --${ns.primary}-*` : null,
|
|
1618
1627
|
h.profile.monorepo ? 'monorepo' : null,
|
|
1619
1628
|
].filter(Boolean);
|
|
1620
1629
|
const dsUnrecognised = !dsChip && !noSystemLikely;
|
|
@@ -128,6 +128,16 @@ const COLOUR_PROP_RE = /^(?:color|background(?:-color)?|border(?:-(?:top|right|b
|
|
|
128
128
|
const DARK_CHAIN_RE = /(^|[^a-z])dark(?![a-z])/i;
|
|
129
129
|
const DARK_FILE_RE = /(^|[/._-])dark([/._-]|$)/i;
|
|
130
130
|
const ROOT_RE = /(^|[\s,(])(?::root|html|:host|body)\b|@theme/;
|
|
131
|
+
// A selector list with a bare root selector in it is a root statement
|
|
132
|
+
// whatever its other members say: documenso's `:root, .dark-mode-disabled`
|
|
133
|
+
// was ranked dark for the word in its second selector and the sheet earned
|
|
134
|
+
// no receipt line (9.7.1). `:root.dark` and `html[data-theme=dark]` are not
|
|
135
|
+
// bare, and stay dark, and so does a :root under a dark at-rule.
|
|
136
|
+
const bareRoot = (chain) => {
|
|
137
|
+
const parts = chain.split(' > ');
|
|
138
|
+
if (DARK_CHAIN_RE.test(parts.slice(0, -1).join(' > '))) return false;
|
|
139
|
+
return parts[parts.length - 1].split(',').some((sel) => /^(?::root|html|:host|body)$/.test(sel.trim()));
|
|
140
|
+
};
|
|
131
141
|
// theme reads this pass cannot follow (D4): a JavaScript theme object outside
|
|
132
142
|
// the four kits (emotion, styled-components), and Sass or Less variables in
|
|
133
143
|
// a colour property. Read by the colour words a theme object uses, at any
|
|
@@ -195,7 +205,7 @@ function cssStatements(text) {
|
|
|
195
205
|
/** A Tailwind v3 config's colours, flattened: primary.DEFAULT -> primary,
|
|
196
206
|
* brand[500] -> brand-500. String values only; spreads and computed values
|
|
197
207
|
* are skipped, so a palette assembled in code reads as not defined. */
|
|
198
|
-
function configColours(src) {
|
|
208
|
+
export function configColours(src) {
|
|
199
209
|
const out = new Map();
|
|
200
210
|
const code = src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:'"`])\/\/[^\n]*/g, '$1');
|
|
201
211
|
const re = /\bcolou?rs\s*:\s*\{/g;
|
|
@@ -392,7 +402,7 @@ export function colourUse(root, files, tokens, { email = null, P = null, palette
|
|
|
392
402
|
const addStatements = (st, f, pkg) => {
|
|
393
403
|
const fileDark = DARK_FILE_RE.test(f);
|
|
394
404
|
for (const d of st.defs) {
|
|
395
|
-
const rank = fileDark
|
|
405
|
+
const rank = fileDark ? 2 : bareRoot(d.chain) ? 0 : DARK_CHAIN_RE.test(d.chain) ? 2 : ROOT_RE.test(d.chain) ? 0 : 1;
|
|
396
406
|
const list = defs.get(d.name) ?? [];
|
|
397
407
|
list.push({ pkg, value: d.value, rank, order: order++ });
|
|
398
408
|
defs.set(d.name, list);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* plan. Born from the first user feedback (Willem, 2026-08-31).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { KITS } from '../profiles/kit-common.mjs';
|
|
10
|
+
import { KITS, secondKitHow } from '../profiles/kit-common.mjs';
|
|
11
11
|
import '../profiles/mui.mjs';
|
|
12
12
|
import '../profiles/mantine.mjs';
|
|
13
13
|
import '../profiles/chakra.mjs';
|
|
@@ -45,7 +45,7 @@ export function fixPrompt({ title, sub, deltaText, repoName, metric = null, kit
|
|
|
45
45
|
const also = second ? `- This repo also uses ${second.name}${second.pkg ? ` (${second.pkg})` : ''}.` : '';
|
|
46
46
|
const secondLine = !adv || !second ? null
|
|
47
47
|
: ['kitColour', 'kitPx'].includes(metric)
|
|
48
|
-
? `${also} Make these changes only on ${kit} components and the wrappers around them. On ${second.name} components,
|
|
48
|
+
? `${also} Make these changes only on ${kit} components and the wrappers around them. On ${second.name} components, ${secondKitHow(second)}`
|
|
49
49
|
: metric === 'inlineStyles'
|
|
50
50
|
? `${also} On ${second.name} components, move a style the way nearby files do. Never put one kit's styling on the other's components.`
|
|
51
51
|
: null;
|
|
@@ -182,15 +182,22 @@ const hueDistance = (x, y) => Math.hypot(x.L - y.L, 2 * (x.a - y.a), 2 * (x.b -
|
|
|
182
182
|
// Past this, the nearest theme colour is a different colour, not a stand-in
|
|
183
183
|
// (bg-blue-600 against a theme of greys), and the pick by name answers.
|
|
184
184
|
const CLOSE = 0.2;
|
|
185
|
+
// A grey class stands in for a grey only. Tailwind's greys sit at a chroma
|
|
186
|
+
// of 0.01 to 0.045 (slate is the most tinted); a brand teal at the same
|
|
187
|
+
// lightness sits at 0.095 and was within CLOSE of slate-500, so formbricks'
|
|
188
|
+
// text-slate-500 was sent to text-brandnew (2026-10-01).
|
|
189
|
+
const GREY_CHROMA = 0.06;
|
|
185
190
|
/** The suitable theme name nearest to a palette shade's Tailwind value, or null. */
|
|
186
191
|
function nearestThemeName(shade, role, values) {
|
|
187
192
|
const from = oklab(TAILWIND_DEFAULTS[shade] ?? '');
|
|
188
193
|
if (!from) return null;
|
|
194
|
+
const grey = GREY_HUE_RE.test(`-${shade}`);
|
|
189
195
|
let best = null;
|
|
190
196
|
for (const [name, value] of Object.entries(values)) {
|
|
191
197
|
if (!suits(name, role)) continue;
|
|
192
198
|
const to = oklab(value);
|
|
193
199
|
if (!to) continue;
|
|
200
|
+
if (grey && Math.hypot(to.a, to.b) > GREY_CHROMA) continue;
|
|
194
201
|
const d = hueDistance(from, to);
|
|
195
202
|
if (!best || d < best.d) best = { name, d };
|
|
196
203
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Single version constant for the engine — imported by diagnose (report
|
|
2
2
|
// footer) and rules (generated-by line). This is the bump spot that used to
|
|
3
3
|
// live as a const inside diagnose/index.mjs.
|
|
4
|
-
export const VERSION = '9.
|
|
4
|
+
export const VERSION = '9.8.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.
|
|
@@ -226,6 +226,8 @@ export function loadKnowledge(root) {
|
|
|
226
226
|
shadcn: P.isShadcn && profile.shadcn ? {
|
|
227
227
|
sheet: profile.shadcn.sheet?.found ? profile.shadcn.sheet.file : null,
|
|
228
228
|
doors: [...P.uiDirs, ...(profile.shadcn.blockFiles ?? [])],
|
|
229
|
+
// a package kit beside the folder, named and not scored (9.8.0)
|
|
230
|
+
second: profile.shadcn.second ?? null,
|
|
229
231
|
} : null,
|
|
230
232
|
agentFiles: (context ?? []).filter((c) => c.kind === 'agent-rules'),
|
|
231
233
|
};
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* character budget (~400 tokens), find calls return one answer, not a ledger,
|
|
7
7
|
* and review reads the git diff itself so the agent never pastes code back.
|
|
8
8
|
*/
|
|
9
|
+
import { secondKitHow } from '../profiles/kit-common.mjs';
|
|
9
10
|
import { execFileSync } from 'node:child_process';
|
|
10
11
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
11
12
|
import { join, relative } from 'node:path';
|
|
@@ -57,7 +58,7 @@ export function getContext(k, { path = null } = {}) {
|
|
|
57
58
|
// counted in the first kit's files: some may sit on the second kit's
|
|
58
59
|
// components, which the live checks name one by one
|
|
59
60
|
if (c || p) L.push(` Already written onto components in ${kit.name} files: ${[c ? `${c} colour${c === 1 ? '' : 's'} (${kit.colour.samples.slice(0, 3).map((x) => x.value).join(', ')})` : '', p ? `${p} pixel size${p === 1 ? '' : 's'}` : ''].filter(Boolean).join(' and ')}; do not add more.`);
|
|
60
|
-
L.push(` On ${sk.name} components${sk.pkg ? ` (${sk.pkg})` : ''}:
|
|
61
|
+
L.push(` On ${sk.name} components${sk.pkg ? ` (${sk.pkg})` : ''}: ${secondKitHow(sk)}`);
|
|
61
62
|
} else if (k.kit) {
|
|
62
63
|
const kit = k.kit, adv = kit.def?.advice;
|
|
63
64
|
const theme = kit.themeFiles?.[0];
|
|
@@ -77,6 +78,10 @@ export function getContext(k, { path = null } = {}) {
|
|
|
77
78
|
const pick = (re, i) => (pal?.names ?? []).find((n) => re.test(n)) ?? pal?.names?.[i] ?? 'brand';
|
|
78
79
|
const examples = pal?.source === 'tailwind' ? `bg-${pick(/surface|(^|-)bg(-|$)|background|canvas/, 0)}, text-${pick(/ink|text|fg|foreground/, 1)}` : 'bg-primary, text-muted-foreground';
|
|
79
80
|
L.push(`TOKENS: ${k.tokenColors.length} colour tokens in ${t.tokenFile}. Use them${pal ? ` as classes (${examples}); 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.)` : ''}`);
|
|
81
|
+
// a package kit beside the shadcn folder (nhost's 57 MUI files, 9.8.0):
|
|
82
|
+
// named so the agent does not put shadcn classes on its components
|
|
83
|
+
const s2 = k.shadcn?.second;
|
|
84
|
+
if (s2) L.push(`ALSO: ${s2.name}${s2.pkg ? ` (${s2.pkg})` : ''} in ${s2.files} files${s2.dir ? ` in ${s2.dir}` : ''}${s2.withoutFirst === s2.files ? ', none of them with shadcn' : ''}. Follow the kit the file already uses. On ${s2.name} components: ${secondKitHow(s2)} Never put one kit's styling on the other's components.`);
|
|
80
85
|
} else if (t.colors.length) {
|
|
81
86
|
L.push(`TOKENS: none defined. ${t.colors.length} distinct colours already in play; reuse one, never invent another.`);
|
|
82
87
|
}
|
|
@@ -135,11 +140,12 @@ export function getContext(k, { path = null } = {}) {
|
|
|
135
140
|
if (nCanon > 2) nCanon -= 1; else nDupes -= 1;
|
|
136
141
|
text = assemble(nCanon, nDupes) + shortened();
|
|
137
142
|
}
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
+
// Before cutting anything, drop what another call can give back, in this
|
|
144
|
+
// order, so the KIT or TOKENS lines, DISCIPLINE and the closing
|
|
145
|
+
// roast_validate line always stay. Built for two-kit repos in 9.7.0; from
|
|
146
|
+
// 9.8.0 every repo gets it (19 single-kit repos went over the budget and
|
|
147
|
+
// lost their closing line to the straight cut, Greg agreed 2026-10-02).
|
|
148
|
+
{
|
|
143
149
|
const drop = (re) => { for (let j = L.length - 1; j >= 0; j--) if (typeof L[j] === 'string' && re.test(L[j])) { L.splice(j, 1); return true; } return false; };
|
|
144
150
|
const steps = [() => drop(/^NOTE:/), () => (nDupes > 0 ? (nDupes -= 1, true) : false), () => drop(/^GAP:/), () => drop(/^GAP:/), () => drop(/^TYPE:/), () => (nCanon > 1 ? (nCanon -= 1, true) : false)];
|
|
145
151
|
for (const step of steps) {
|
|
@@ -148,15 +154,15 @@ export function getContext(k, { path = null } = {}) {
|
|
|
148
154
|
}
|
|
149
155
|
}
|
|
150
156
|
const TRIM = '\n(trimmed to budget; ask roast_find_component / roast_find_token for specifics)';
|
|
151
|
-
if (text.length > CONTEXT_BUDGET
|
|
152
|
-
// still over: cut the middle, never the
|
|
153
|
-
//
|
|
157
|
+
if (text.length > CONTEXT_BUDGET) {
|
|
158
|
+
// still over: cut the middle, never the lines at the top nor DISCIPLINE
|
|
159
|
+
// and the closing roast_validate line at the end
|
|
154
160
|
const tailAt = text.indexOf('\nDISCIPLINE:');
|
|
155
161
|
const tail = tailAt >= 0 ? text.slice(tailAt) : '';
|
|
156
162
|
const room = CONTEXT_BUDGET - tail.length - TRIM.length;
|
|
157
163
|
const head = tailAt >= 0 ? text.slice(0, tailAt) : text;
|
|
158
164
|
text = `${head.slice(0, Math.max(0, head.lastIndexOf('\n', room)))}${TRIM}${tail}`;
|
|
159
|
-
}
|
|
165
|
+
}
|
|
160
166
|
// No credit line here (8.1.1): a tool answers the question and nothing
|
|
161
167
|
// else. Attribution lives on the report and in the skill's citation rule.
|
|
162
168
|
return text;
|
|
@@ -22,7 +22,7 @@ import { join, basename, dirname } from 'node:path';
|
|
|
22
22
|
* fixture's expected output is byte-identical before and after.
|
|
23
23
|
*/
|
|
24
24
|
import shadcn from './shadcn.mjs';
|
|
25
|
-
import tailwind, { readTailwindTheme } from './tailwind.mjs';
|
|
25
|
+
import tailwind, { readTailwindTheme, v3Retuned } from './tailwind.mjs';
|
|
26
26
|
import { decidePalette } from '../lib/palette.mjs';
|
|
27
27
|
import { installedFile, componentName } from './installed.mjs';
|
|
28
28
|
import mui from './mui.mjs';
|
|
@@ -94,6 +94,12 @@ export function decideProfile(profile, components, files, root = null, { themeRe
|
|
|
94
94
|
let ownTheme;
|
|
95
95
|
const readOwnTheme = () => (ownTheme === undefined ? (ownTheme = readTailwindTheme({ ...profile }, ctx)?.facts ?? null) : ownTheme);
|
|
96
96
|
profile.palette = decidePalette(profile, profileOf(profile), readOwnTheme);
|
|
97
|
+
// a palette name a v3 config gave the repo's own colour is the theme too,
|
|
98
|
+
// whichever vocabulary judges the rest (9.7.1)
|
|
99
|
+
if (profile.palette && root) {
|
|
100
|
+
const tuned = v3Retuned(root, files.code);
|
|
101
|
+
if (tuned.length) profile.palette.retuned = [...new Set([...(profile.palette.retuned ?? []), ...tuned])].sort();
|
|
102
|
+
}
|
|
97
103
|
// shadcn in utility-class mode (components.json: cssVariables false) paints
|
|
98
104
|
// with Tailwind classes by design. A theme of the repo's own beside it
|
|
99
105
|
// (rybbit retunes the whole neutral scale) is what the rules file points
|
|
@@ -46,14 +46,14 @@ const nestedCatalogueImport = (src) => [...src.matchAll(SHADCN_NESTED_RE)].some(
|
|
|
46
46
|
function importCounts(root, codeFiles) {
|
|
47
47
|
if (importCache.has(codeFiles)) return importCache.get(codeFiles);
|
|
48
48
|
const counts = Object.fromEntries([...Object.keys(KITS), 'shadcn'].map((k) => [k, 0]));
|
|
49
|
-
const listed = Object.fromEntries(KIT_LIST.map((k) => [k.name, []]));
|
|
49
|
+
const listed = Object.fromEntries([...KIT_LIST.map((k) => [k.name, []]), ['shadcn/ui', []]]);
|
|
50
50
|
const direct = Object.fromEntries(Object.keys(KITS).map((k) => [k, []]));
|
|
51
51
|
for (const f of codeFiles) {
|
|
52
52
|
if (!/\.[jt]sx?$/.test(f)) continue;
|
|
53
53
|
const src = readSafe(join(root, f));
|
|
54
54
|
for (const [k, d] of Object.entries(KITS)) if (d.importRe.test(src)) { counts[k] += 1; direct[k].push(f); }
|
|
55
55
|
// a shadcn catalogue is a kit too: components/ui imports
|
|
56
|
-
if (SHADCN_IMPORT_RE.test(src) || nestedCatalogueImport(src)) counts.shadcn += 1;
|
|
56
|
+
if (SHADCN_IMPORT_RE.test(src) || nestedCatalogueImport(src)) { counts.shadcn += 1; listed['shadcn/ui'].push(f); }
|
|
57
57
|
for (const name of new Set(importSources(src).map(kitOfSource).filter(Boolean))) listed[name].push(f);
|
|
58
58
|
}
|
|
59
59
|
const out = { counts, files: { listed, direct } };
|
|
@@ -90,8 +90,10 @@ function secondKit(root, def, paint, imports, email, codeFiles) {
|
|
|
90
90
|
// a tenth of the first kit as the words count it: its layer included
|
|
91
91
|
const firstUse = paint.kitFiles;
|
|
92
92
|
const usable = (f) => !SKIP_PATH_RE.test(f);
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
// a shadcn folder beside a kit is a second kit too (zupass: Chakra and
|
|
94
|
+
// components/ui, 9.8.0); its files are the ones that import the folder
|
|
95
|
+
const found = [...KIT_LIST.filter((k) => k.name !== def.name).map((k) => k.name), 'shadcn/ui']
|
|
96
|
+
.map((name) => ({ name, list: (imports.files.listed[name] ?? []).filter(usable) }))
|
|
95
97
|
.filter((c) => c.list.length >= SECOND_MIN_FILES && (c.list.length >= SECOND_FILES || c.list.length >= SECOND_SHARE * firstUse))
|
|
96
98
|
.sort((a, b) => b.list.length - a.list.length || a.name.localeCompare(b.name));
|
|
97
99
|
if (!found.length) return null;
|
|
@@ -103,9 +105,11 @@ function secondKit(root, def, paint, imports, email, codeFiles) {
|
|
|
103
105
|
for (const f of s.list) {
|
|
104
106
|
let src;
|
|
105
107
|
try { if (statSync(join(root, f)).size > 1e6) continue; src = readFileSync(join(root, f), 'utf8'); } catch { continue; }
|
|
106
|
-
const fromS =
|
|
108
|
+
const fromS = s.name === 'shadcn/ui'
|
|
109
|
+
? [...src.matchAll(/from\s+['"]([@~./\w-]*components\/ui)\/[\w/-]+['"]/g)].map((m) => m[1])
|
|
110
|
+
: importSources(src).filter((p) => kitOfSource(p) === s.name);
|
|
107
111
|
if (!fromS.length) continue;
|
|
108
|
-
for (const p of fromS) pkgs.set(pkgRoot(p), (pkgs.get(pkgRoot(p)) ?? 0) + 1);
|
|
112
|
+
for (const p of fromS) pkgs.set(s.name === 'shadcn/ui' ? p : pkgRoot(p), (pkgs.get(s.name === 'shadcn/ui' ? p : pkgRoot(p)) ?? 0) + 1);
|
|
109
113
|
if (!firstRe.test(src)) { without.push(f); continue; }
|
|
110
114
|
mixed.push(f);
|
|
111
115
|
const hits = kitPaintInSource(src, def, { file: f, layers: paint.layers, email });
|
|
@@ -135,8 +139,45 @@ function secondKit(root, def, paint, imports, email, codeFiles) {
|
|
|
135
139
|
};
|
|
136
140
|
}
|
|
137
141
|
|
|
142
|
+
// A package kit beside a shadcn folder (nhost: 631 files import the folder,
|
|
143
|
+
// 57 import MUI; 9.8.0): named in the rules file, the MCP context and the
|
|
144
|
+
// report's receipt, never scored. The same thresholds as the kit side.
|
|
145
|
+
export function shadcnSecondKit(root, codeFiles) {
|
|
146
|
+
const imports = importCounts(root, codeFiles);
|
|
147
|
+
const usable = (f) => !SKIP_PATH_RE.test(f);
|
|
148
|
+
const first = (imports.files.listed['shadcn/ui'] ?? []).filter(usable);
|
|
149
|
+
const found = KIT_LIST
|
|
150
|
+
.map((k) => ({ name: k.name, list: (imports.files.listed[k.name] ?? []).filter(usable) }))
|
|
151
|
+
.filter((c) => c.list.length >= SECOND_MIN_FILES && (c.list.length >= SECOND_FILES || c.list.length >= SECOND_SHARE * first.length))
|
|
152
|
+
.sort((a, b) => b.list.length - a.list.length || a.name.localeCompare(b.name));
|
|
153
|
+
if (!found.length) return null;
|
|
154
|
+
const [s] = found;
|
|
155
|
+
const firstSet = new Set(first);
|
|
156
|
+
const pkgs = new Map();
|
|
157
|
+
for (const f of s.list) {
|
|
158
|
+
for (const p of importSources(readSafe(join(root, f))).filter((x) => kitOfSource(x) === s.name)) pkgs.set(pkgRoot(p), (pkgs.get(pkgRoot(p)) ?? 0) + 1);
|
|
159
|
+
}
|
|
160
|
+
const without = s.list.filter((f) => !firstSet.has(f)).length;
|
|
161
|
+
return {
|
|
162
|
+
name: s.name,
|
|
163
|
+
pkg: [...pkgs.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]?.[0] ?? null,
|
|
164
|
+
registered: !!KITS[s.name],
|
|
165
|
+
files: s.list.length,
|
|
166
|
+
withoutFirst: without,
|
|
167
|
+
mixed: s.list.length - without,
|
|
168
|
+
dir: cleanFolder(s.list, first),
|
|
169
|
+
firstDir: cleanFolder(first, s.list),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
138
173
|
// ", 20 of them without Mantine", or ", none of them with Mantine" when no
|
|
139
174
|
// file has both
|
|
175
|
+
// How to style the second kit's components, in one sentence shared by the
|
|
176
|
+
// rules file, the MCP context and the fix prompts. shadcn's rule is known;
|
|
177
|
+
// any other kit's components follow the files around them (9.8.0).
|
|
178
|
+
export const secondKitHow = (sk) => (sk.name === 'shadcn/ui'
|
|
179
|
+
? 'use its variants and the theme classes (bg-background, text-muted-foreground), never a palette colour.'
|
|
180
|
+
: 'copy how nearby files style them.');
|
|
140
181
|
export const withoutWords = (sk, first) => (!sk.withoutFirst ? '' : sk.withoutFirst === sk.files ? `, none of them with ${first}` : `, ${sk.withoutFirst} of them without ${first}`);
|
|
141
182
|
|
|
142
183
|
export function kitProfile(def) {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { existsSync, readFileSync } from 'node:fs';
|
|
29
29
|
import { join, dirname, basename } from 'node:path';
|
|
30
30
|
import { canonical } from '../lib/color.mjs';
|
|
31
|
+
import { shadcnSecondKit, withoutWords } from './kit-common.mjs';
|
|
31
32
|
import { CATALOGUE, BLOCK_COMPONENTS, REGISTRY_DIRS, KNOWN_ROWS, TWEAKCN_ROWS, LIGHT_ONLY_ROWS, FACTORY_SPACING,
|
|
32
33
|
FRONTS, LEGACY_FRONTS, BASES, BASE_COLORS, ICON_LIBRARIES, RADIUS_MAP, THEMES, SHADCN_ROWS } from './shadcn-data.mjs';
|
|
33
34
|
|
|
@@ -124,10 +125,12 @@ function themeContract(root, files) {
|
|
|
124
125
|
return seen.size;
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
/** Rows declared under a selector in a CSS text: name -> value.
|
|
128
|
+
/** Rows declared under a selector in a CSS text: name -> value. The
|
|
129
|
+
* selector may head a list (documenso: `:root, .dark-mode-disabled`) or
|
|
130
|
+
* carry a :not() (`.dark:not(.dark-mode-disabled)`), 9.7.1. */
|
|
128
131
|
function rowsUnder(css, selector) {
|
|
129
132
|
const out = new Map();
|
|
130
|
-
const re = new RegExp(`(^|[\\s,}])${selector}\\s
|
|
133
|
+
const re = new RegExp(`(^|[\\s,}])${selector}(?::not\\([^)]*\\))?\\s*(?:,[^{}]*)?\\{([^}]*)\\}`, 'g');
|
|
131
134
|
for (const m of css.matchAll(re)) {
|
|
132
135
|
for (const d of m[2].matchAll(/--([a-z0-9-]+)\s*:\s*([^;]+);/gi)) out.set(d[1], d[2].trim());
|
|
133
136
|
}
|
|
@@ -150,7 +153,7 @@ function sheetRows(css) {
|
|
|
150
153
|
if (!/--background\s*:/.test(m[2]) || sel.startsWith('@')) continue;
|
|
151
154
|
const rows = new Map();
|
|
152
155
|
for (const d of m[2].matchAll(/--([a-z0-9-]+)\s*:\s*([^;]+);/gi)) rows.set(d[1], d[2].trim());
|
|
153
|
-
if (/\.dark\
|
|
156
|
+
if (/\.dark(?![\w-])/.test(sel)) { if (!dark.has('background')) dark = rows; }
|
|
154
157
|
else if (!light.has('background')) { light = rows; scope = sel; }
|
|
155
158
|
if (light.has('background') && dark.has('background')) break;
|
|
156
159
|
}
|
|
@@ -199,6 +202,10 @@ function readSheet(root, wsRoot, cssPath, files = null) {
|
|
|
199
202
|
const rel = file.slice(root.length + 1);
|
|
200
203
|
const others = [...(files.code ?? []), ...(files.styles ?? [])].filter((f) => f !== rel).slice(0, 4000);
|
|
201
204
|
const pending = new Set(custom.filter((r) => !/^(font|shadow|tracking|spacing|radius|ease|breakpoint|typeset)/.test(r)));
|
|
205
|
+
// a read inside the sheet itself counts: --sidebar: var(--surface-2)
|
|
206
|
+
// makes --surface-2 part of the theme, whether or not a component names
|
|
207
|
+
// it (9.7.1; its own rows, light and dark, are definitions, not reads)
|
|
208
|
+
for (const r of [...pending]) if (new RegExp(`var\\(\\s*--${r}(?![\\w-])`).test(css)) pending.delete(r);
|
|
202
209
|
for (const f of others) {
|
|
203
210
|
if (!pending.size) break;
|
|
204
211
|
const src = read(join(root, f));
|
|
@@ -345,7 +352,11 @@ export default {
|
|
|
345
352
|
.filter((d) => registryNames.has(basename(d).toLowerCase()) && !profile.uiDirs.some((u) => d === u || d.startsWith(`${u}/`))))]
|
|
346
353
|
.filter((d) => files.code.filter((f) => f.startsWith(`${d}/`) && /\.[jt]sx$/.test(f)).length >= 2);
|
|
347
354
|
if (registryDirs.length) evidence.push(`${registryDirs.length} installed registr${registryDirs.length === 1 ? 'y' : 'ies'} beside it: ${registryDirs.map((d) => basename(d)).join(', ')}`);
|
|
355
|
+
// a package kit beside the folder (nhost's 57 MUI files): named, not scored
|
|
356
|
+
const second = shadcnSecondKit(root, files.code);
|
|
357
|
+
if (second) evidence.push(`${second.name} imported in ${second.files} files${withoutWords(second, 'shadcn')}: named, not scored`);
|
|
348
358
|
profile.shadcn = {
|
|
359
|
+
second,
|
|
349
360
|
installs: installs.map((i) => ({ config: i.config, uiDir: i.uiDir, catalogueNames: i.catalogueNames })),
|
|
350
361
|
// the theme contract: how many of shadcn's rows any stylesheet defines,
|
|
351
362
|
// and the file carrying most of them. The configured sheet can hold
|
|
@@ -20,7 +20,10 @@ import { cachedSource } from '../harvest/walk.mjs';
|
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
import { PALETTE } from './shadcn-data.mjs';
|
|
22
22
|
import { TAILWIND_DEFAULTS } from './tailwind-defaults.mjs';
|
|
23
|
-
import { canonical, parseColor } from '../lib/color.mjs';
|
|
23
|
+
import { canonical, parseColor, oklab } from '../lib/color.mjs';
|
|
24
|
+
import { configColours } from '../harvest/coloruse.mjs';
|
|
25
|
+
import { existsSync } from 'node:fs';
|
|
26
|
+
import { posix } from 'node:path';
|
|
24
27
|
|
|
25
28
|
// the scan's own copy when it has one (harvest/walk.mjs), the disk otherwise
|
|
26
29
|
const read = (p) => { const t = cachedSource(p); if (t !== undefined) return t; try { return readFileSync(p, 'utf8'); } catch { return ''; } };
|
|
@@ -40,6 +43,58 @@ function retuned(name, value) {
|
|
|
40
43
|
return !(cv && cd && near(cv, cd));
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
// A v3 config can retune a scale too: Novu points every grey at
|
|
47
|
+
// hsl(var(--gray-N)), workout-cool gives green its own values. The theme
|
|
48
|
+
// itself stays unread (a v3 config imports, spreads and computes; Greg,
|
|
49
|
+
// 2026-09-16), but a palette name it gives a new colour is the repo's own
|
|
50
|
+
// wherever it is read, so the live checks and the colour bar stop calling
|
|
51
|
+
// the class drift (9.7.1). Only a config the product reads counts: the root
|
|
52
|
+
// config, or one in a package holding a tenth of the code files (unkey
|
|
53
|
+
// retunes grey in an email package of 20 files, and the web app must not
|
|
54
|
+
// inherit it).
|
|
55
|
+
const TW_CONFIG_RE = /(^|\/)tailwind\.config\.[mc]?[jt]s$/;
|
|
56
|
+
const PALETTE_SHADE_NAME_RE = new RegExp(`^(?:${PALETTE})-(?:50|[1-9]00|950)$`);
|
|
57
|
+
const CONFIG_SHARE = 0.1;
|
|
58
|
+
// The defaults the engine keeps are v4's. v3's own values sit within 0.04
|
|
59
|
+
// of them in OKLab (blue-500 #3b82f6 against oklch(62.3% 0.214 259.815):
|
|
60
|
+
// 0.019; the widest, fuchsia-500, 0.036), while a scale a team retuned sits
|
|
61
|
+
// at 0.07 or more (workout-cool's green-500 at 0.076). A literal this close
|
|
62
|
+
// is Tailwind's own, whichever major it was copied from.
|
|
63
|
+
const V3_SAME = 0.05;
|
|
64
|
+
const v3Retune = (name, value) => {
|
|
65
|
+
if (!retuned(name, value)) return false;
|
|
66
|
+
const a = oklab(value.trim()), b = oklab(TAILWIND_DEFAULTS[name]);
|
|
67
|
+
return !(a && b && Math.hypot(a.L - b.L, a.a - b.a, a.b - b.b) < V3_SAME);
|
|
68
|
+
};
|
|
69
|
+
/** Palette names a v3 config the product reads gave colours of its own. */
|
|
70
|
+
export function v3Retuned(root, codeFiles) {
|
|
71
|
+
if (!root || !codeFiles?.length) return [];
|
|
72
|
+
const configs = codeFiles.filter((f) => TW_CONFIG_RE.test(f));
|
|
73
|
+
if (!configs.length) return [];
|
|
74
|
+
const pkgOf = (file) => {
|
|
75
|
+
let dir = posix.dirname(file);
|
|
76
|
+
while (dir && dir !== '.') {
|
|
77
|
+
if (existsSync(join(root, dir, 'package.json'))) return dir;
|
|
78
|
+
dir = posix.dirname(dir);
|
|
79
|
+
}
|
|
80
|
+
return '';
|
|
81
|
+
};
|
|
82
|
+
let sizes = null;
|
|
83
|
+
const sizeOf = (pkg) => {
|
|
84
|
+
if (!sizes) { sizes = new Map(); for (const f of codeFiles) { const p = pkgOf(f); sizes.set(p, (sizes.get(p) ?? 0) + 1); } }
|
|
85
|
+
return sizes.get(pkg) ?? 0;
|
|
86
|
+
};
|
|
87
|
+
const out = new Set();
|
|
88
|
+
for (const f of configs) {
|
|
89
|
+
const pkg = pkgOf(f);
|
|
90
|
+
if (pkg !== '' && sizeOf(pkg) < codeFiles.length * CONFIG_SHARE) continue;
|
|
91
|
+
for (const [n, v] of configColours(read(join(root, f)))) {
|
|
92
|
+
if (PALETTE_SHADE_NAME_RE.test(n) && v3Retune(n, v)) out.add(n);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return [...out].sort();
|
|
96
|
+
}
|
|
97
|
+
|
|
43
98
|
// what counts as a theme worth judging
|
|
44
99
|
// three, not six: a small brand palette used everywhere is still a system
|
|
45
100
|
// (hey.xyz: four brand shades; ConvertX: four colours, 2026-09-16)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Output: { text, ruleCount } — paste-ready agent-rules markdown, every rule
|
|
5
5
|
* with a receipt from the scanned repo.
|
|
6
6
|
*/
|
|
7
|
-
import { KITS, withoutWords } from '../profiles/kit-common.mjs';
|
|
7
|
+
import { KITS, withoutWords, secondKitHow } from '../profiles/kit-common.mjs';
|
|
8
8
|
import { distinctTypefaces } from '../lib/typefaces.mjs';
|
|
9
9
|
import { nearColorPairs } from './../lib/nearpairs.mjs';
|
|
10
10
|
import { neverImportedComponents } from '../lib/neverimported.mjs';
|
|
@@ -237,10 +237,20 @@ if (neverImported.length >= 3) {
|
|
|
237
237
|
if (op?.uses) lines.push(` (${op.uses} palette colour${op.uses === 1 ? '' : 's'} the theme does not own already sit in own code, ${op.samples.slice(0, 3).map((s) => `\`${s.value}\` ×${s.count}`).join(', ')}; do not add to them.)`);
|
|
238
238
|
} else {
|
|
239
239
|
rule(`This is a shadcn install${styleNote}. The theme is a set of CSS variables${sheetFile ? ` in \`${sheetFile}\`` : ''}: background, foreground, primary, muted, border and the rest, each with a light and a dark value. Change a colour there, never in a component.`);
|
|
240
|
-
|
|
240
|
+
// The same decision as the live checks (lib/palette.mjs): the contract
|
|
241
|
+
// holds when any stylesheet keeps five of shadcn's rows. Until 9.7.1
|
|
242
|
+
// this line read the configured sheet's :root rows alone, so formbricks
|
|
243
|
+
// (its names in @theme, the rows in a sibling package) was told to stay
|
|
244
|
+
// with the palette classes the live checks flag.
|
|
245
|
+
const contractHolds = P.palette?.source === 'shadcn' || (sc.sheet?.shadcnPresent ?? 0) >= 5;
|
|
246
|
+
const contractNote = contractHolds && (sc.sheet?.shadcnPresent ?? 0) < 5 && sc.contract?.file && sc.contract.file !== sheetFile ? ` The variables are defined in \`${sc.contract.file}\`.` : '';
|
|
247
|
+
if (contractHolds) rule(`Use the semantic classes the theme gives you (\`bg-background\`, \`text-muted-foreground\`, \`border-border\`), never a palette colour like \`bg-blue-500\` or \`text-gray-600\`, and never a hand-written \`dark:\` colour. The variables already carry both modes.${contractNote}`);
|
|
241
248
|
else rule(`${sheetFile ? `\`${sheetFile}\` defines` : 'The theme file defines'} none of shadcn's colour variables, so \`bg-background\` and \`text-muted-foreground\` have nothing behind them here. Until the theme variables are adopted, stay with the palette classes the surrounding file already uses; do not introduce semantic classes with no variable behind them, and do not add a second palette.`);
|
|
242
249
|
if (paint?.tin?.uses) lines.push(` (${paint.tin.uses} palette colour${paint.tin.uses === 1 ? '' : 's'} already sit in own code, ${paint.tin.samples.slice(0, 3).map((s) => `\`${s.value}\` ×${s.count}`).join(', ')}; do not add to them.)`);
|
|
243
250
|
}
|
|
251
|
+
// a package kit beside the folder (nhost's 57 MUI files, 9.8.0): the
|
|
252
|
+
// rules here are shadcn's; its components follow their own files
|
|
253
|
+
if (sc.second) rule(`This repo also uses ${sc.second.name}${sc.second.pkg ? ` (\`${sc.second.pkg}\`)` : ''}, imported in ${sc.second.files} files${sc.second.dir ? ` in \`${sc.second.dir}\`` : ''}${withoutWords(sc.second, 'shadcn')}. The rules in this section are for shadcn components. On ${sc.second.name} components, ${secondKitHow(sc.second)} Never put one kit's styling on the other's components.`);
|
|
244
254
|
rule('Before adding classes to a shadcn component, use one of its variants (`variant="outline"`, `size="sm"`). `className` on a shadcn component is for layout only: width, margin, position. Never colour, never typography.');
|
|
245
255
|
if (paint?.typo?.uses) lines.push(` (${paint.typo.uses} shadcn component${paint.typo.uses === 1 ? '' : 's'} get a text size or weight through className, like ${paint.typo.samples.slice(0, 2).map((s) => `\`${s.value}\``).join(' and ')}; add a size variant instead.)`);
|
|
246
256
|
if (paint?.doors?.uses) lines.push(` (${paint.doors.uses} shadcn component${paint.doors.uses === 1 ? '' : 's'} already recoloured through className, like ${paint.doors.samples.slice(0, 2).map((s) => `\`${s.value}\``).join(' and ')}; do not add to them.)`);
|
|
@@ -310,7 +320,7 @@ export function secondKitLead(k) {
|
|
|
310
320
|
/** the kit section's own rules are the first kit's; this says so */
|
|
311
321
|
export function secondKitRule(k) {
|
|
312
322
|
const s = k.second;
|
|
313
|
-
return `The rules in this section are for ${k.name} components. ${s.name}${s.pkg ? ` (\`${s.pkg}\`)` : ''} is imported in ${s.files} files${withoutWords(s, k.name)}. On ${s.name} components,
|
|
323
|
+
return `The rules in this section are for ${k.name} components. ${s.name}${s.pkg ? ` (\`${s.pkg}\`)` : ''} is imported in ${s.files} files${withoutWords(s, k.name)}. On ${s.name} components, ${secondKitHow(s)}`;
|
|
314
324
|
}
|
|
315
325
|
|
|
316
326
|
// ---------- shadcn in utility-class mode (words approved by Greg, 2026-10-01) ----------
|