roast-my-design-system 9.3.3 → 9.4.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/lib/guard-api.mjs +17 -2
- package/skills/roast-my-design-system/scripts/lib/palette.mjs +178 -0
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/mcp/engine.mjs +8 -108
- package/skills/roast-my-design-system/scripts/mcp/knowledge.mjs +3 -0
- package/skills/roast-my-design-system/scripts/mcp/tools.mjs +8 -1
- package/skills/roast-my-design-system/scripts/profiles/index.mjs +10 -1
- package/skills/roast-my-design-system/scripts/profiles/shadcn.mjs +6 -0
- package/skills/roast-my-design-system/scripts/profiles/tailwind.mjs +28 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.4.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": [
|
|
@@ -17,6 +17,7 @@ export { designGaps } from './gaps.mjs';
|
|
|
17
17
|
import { decideProfile, profileOf, installedDirs } from '../profiles/index.mjs';
|
|
18
18
|
import { KITS } from '../profiles/kit-common.mjs';
|
|
19
19
|
import { kitPaintFindings } from './kitpaint.mjs';
|
|
20
|
+
import { paletteFindings } from './palette.mjs';
|
|
20
21
|
import { PALETTE_CLASS_RE, DEMO_PATH_RE, blankComments } from '../harvest/paint.mjs';
|
|
21
22
|
import { harvestTokens, extractStyling, normalizeHex, isGrey } from '../harvest/tokens.mjs';
|
|
22
23
|
import { harvestComponents, definedComponents, tsconfigAliases } from '../harvest/components.mjs';
|
|
@@ -67,6 +68,14 @@ export { WIDGET_CSS_RE, WIDGET_CONFIG_RE, LIBRARY_CLASS_RE, isLibraryClass } fro
|
|
|
67
68
|
// examples and demos: a guard that skips them flags palette classes in the
|
|
68
69
|
// same files the report counts.
|
|
69
70
|
export { PALETTE_CLASS_RE, DEMO_PATH_RE, blankComments };
|
|
71
|
+
// The palette rule itself (9.4.0): paletteFindings(fileText, system.profile
|
|
72
|
+
// .palette, { file }) returns the worded findings the live checks give for
|
|
73
|
+
// the same file, with the index of each in the text; empty when the rule is
|
|
74
|
+
// off, on a stylesheet, in a demo folder or inside a kit door. Which
|
|
75
|
+
// vocabulary applies was decided once by the profile (lib/palette.mjs), so
|
|
76
|
+
// a guard that calls this agrees with the report's live checks by
|
|
77
|
+
// construction, on a Tailwind theme and on a shadcn kit alike.
|
|
78
|
+
export { paletteFindings };
|
|
70
79
|
// A product built on a kit (MUI, Mantine, Chakra UI, Ant Design), 8.4.6: a
|
|
71
80
|
// colour or a pixel size written onto a kit component where the theme has a
|
|
72
81
|
// value. kitPaintFindings(fileText, system.profile.kit, { file }) returns the
|
|
@@ -225,8 +234,14 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
|
|
|
225
234
|
// a product built on a kit: name, theme files and values, spacing step,
|
|
226
235
|
// the team's own layers over it, and the definition kitPaintFindings reads
|
|
227
236
|
kit,
|
|
228
|
-
//
|
|
229
|
-
//
|
|
237
|
+
// the vocabulary the palette rule judges against, or null when it is
|
|
238
|
+
// off: a Tailwind theme's own names, shadcn's names when the sheet's
|
|
239
|
+
// contract holds in CSS-variable mode, or the repo's own theme under a
|
|
240
|
+
// shadcn kit with no rows (lib/palette.mjs, 9.4.0). Hand it to
|
|
241
|
+
// paletteFindings.
|
|
242
|
+
palette: P.palette,
|
|
243
|
+
// superseded by `palette` in 9.4.0; kept one release for readers of the
|
|
244
|
+
// old shape. The old gate read the configured sheet's :root rows alone.
|
|
230
245
|
paletteReady: P.isShadcn && (P.shadcn?.sheet?.shadcnPresent ?? 0) >= 5 && P.designSystem?.cssVariables !== false,
|
|
231
246
|
sheetFile: P.shadcn?.sheet?.found ? P.shadcn.sheet.file : null,
|
|
232
247
|
// packages whose Tailwind config scopes utilities under an id: widgets
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Palette classes where the theme names its colours: one rule, read by the
|
|
3
|
+
* report's live checks (validate, review, --check, the edit hook, the
|
|
4
|
+
* end-of-turn review) and by the guard through the doorway.
|
|
5
|
+
*
|
|
6
|
+
* Until 9.4.0 the rule lived twice. The live checks switched it on for a
|
|
7
|
+
* Tailwind theme and for any shadcn repo; the guard switched it on only for
|
|
8
|
+
* a shadcn repo whose configured sheet held five of shadcn's rows under
|
|
9
|
+
* :root. Probed on the fleet (2026-10-01): the guard was silent on 11 of 48
|
|
10
|
+
* shadcn repos where the live checks spoke (formbricks writes its theme
|
|
11
|
+
* straight into @theme, documenso names no sheet path, Nango and Ghost keep
|
|
12
|
+
* a v4 theme of their own and no shadcn rows), and the live checks flagged
|
|
13
|
+
* 1,394 palette classes in rybbit's last 300 changes where the report says
|
|
14
|
+
* the palette is the theme (utility-class mode). A rule kept in two places
|
|
15
|
+
* drifts; this is the one place.
|
|
16
|
+
*
|
|
17
|
+
* decidePalette answers once, from the profile, which vocabulary a palette
|
|
18
|
+
* class is judged against (profiles/index.mjs stores it as profile.palette):
|
|
19
|
+
* 1. a Tailwind theme in use: its own names, with the theme's families
|
|
20
|
+
* (a palette grey is only drift where the theme has a grey) and its
|
|
21
|
+
* retuned names (a palette name given the repo's own colour is the
|
|
22
|
+
* theme, not drift)
|
|
23
|
+
* 2. a shadcn install in CSS-variable mode whose theme contract holds,
|
|
24
|
+
* five or more of shadcn's ten rows in any of its stylesheets: shadcn's
|
|
25
|
+
* names
|
|
26
|
+
* 3. a shadcn install with no contract but a v4 theme of its own: that
|
|
27
|
+
* theme's names, as in 1
|
|
28
|
+
* 4. utility-class mode (the palette is the theme, as the report says), or
|
|
29
|
+
* nothing to point at: off
|
|
30
|
+
* paletteFindings words the hits, the same words on every door: the class,
|
|
31
|
+
* the theme file, the theme colour nearest by value that suits the utility,
|
|
32
|
+
* and what to add when none fits.
|
|
33
|
+
*/
|
|
34
|
+
import { PALETTE_CLASS_RE, GREY_HUE_RE, DARK_WB_RE, DEMO_PATH_RE, blankComments } from '../harvest/paint.mjs';
|
|
35
|
+
import { TAILWIND_DEFAULTS } from '../profiles/tailwind-defaults.mjs';
|
|
36
|
+
import { oklab } from './color.mjs';
|
|
37
|
+
|
|
38
|
+
// shadcn's names, one per role the utilities want; the sheet always has
|
|
39
|
+
// greys and colours, so no family check
|
|
40
|
+
const SHADCN_NAMES = ['primary', 'foreground', 'muted-foreground', 'background', 'border'];
|
|
41
|
+
// rows of shadcn's contract that make the sheet the vocabulary
|
|
42
|
+
export const CONTRACT_MIN = 5;
|
|
43
|
+
const CSS_FILE_RE = /\.(css|scss|sass|less|styl)$/i;
|
|
44
|
+
|
|
45
|
+
const fromTheme = (t, doors) => ({
|
|
46
|
+
source: 'tailwind', file: t.file, names: t.names, values: t.values ?? {}, retuned: t.retuned ?? [],
|
|
47
|
+
families: t.families ?? null, adopted: t.adopted !== false, doors,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The vocabulary a palette class is judged against, or null when the rule
|
|
52
|
+
* is off. `readTheme()` reads the repo's own v4 theme on demand (case 3),
|
|
53
|
+
* the same reader the Tailwind profile uses.
|
|
54
|
+
* @returns {{ source, file, names, values, retuned, families, adopted?, doors } | null}
|
|
55
|
+
*/
|
|
56
|
+
export function decidePalette(profile, P, readTheme = null) {
|
|
57
|
+
if (P.isTailwind && profile.tailwind) return fromTheme(profile.tailwind, []);
|
|
58
|
+
if (!P.isShadcn || !profile.shadcn) return null;
|
|
59
|
+
const sc = profile.shadcn;
|
|
60
|
+
// utility-class mode: the palette is the theme, and the report scores it so
|
|
61
|
+
if (P.designSystem?.cssVariables === false) return null;
|
|
62
|
+
// the catalogue and kit blocks are the kit's own doors: editing them is the
|
|
63
|
+
// intended use, and the report's tile leaves them out
|
|
64
|
+
const doors = [...(P.uiDirs ?? []), ...(sc.blockFiles ?? [])];
|
|
65
|
+
const rows = Math.max(sc.sheet?.shadcnPresent ?? 0, sc.contract?.rows ?? 0);
|
|
66
|
+
if (rows >= CONTRACT_MIN) {
|
|
67
|
+
return {
|
|
68
|
+
source: 'shadcn', file: sc.sheet?.found ? sc.sheet.file : sc.contract?.file ?? null,
|
|
69
|
+
names: SHADCN_NAMES, values: {}, retuned: [], families: null, doors,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const own = typeof readTheme === 'function' ? readTheme() : null;
|
|
73
|
+
return own ? fromTheme(own, doors) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The palette classes in a piece of text that the vocabulary answers, each
|
|
78
|
+
* with the fix in the engine's words. Empty when the rule is off, on a
|
|
79
|
+
* stylesheet, in a demo folder or inside a kit door. `index` is the class's
|
|
80
|
+
* position in `text`; comments are blanked, not cut, so it holds.
|
|
81
|
+
* @returns [{ rule: 'palette-class', severity: 'violation', index, value, themeFile, example, message, fix }]
|
|
82
|
+
*/
|
|
83
|
+
export function paletteFindings(text, palette, { file = null, css = null } = {}) {
|
|
84
|
+
if (!palette || !text) return [];
|
|
85
|
+
if (css ?? (file ? CSS_FILE_RE.test(file) : false)) return [];
|
|
86
|
+
if (file && (DEMO_PATH_RE.test(file) || (palette.doors ?? []).some((d) => file === d || file.startsWith(`${d}/`)))) return [];
|
|
87
|
+
const retuned = new Set(palette.retuned ?? []);
|
|
88
|
+
const fam = palette.families ?? null;
|
|
89
|
+
const hasFamily = (cls) => !fam || (GREY_HUE_RE.test(cls) ? fam.grey : fam.colour);
|
|
90
|
+
const themeFile = palette.file ?? 'the theme sheet';
|
|
91
|
+
// the example name follows the utility: a text- class wants an ink or
|
|
92
|
+
// foreground name, a bg- class a surface or background name
|
|
93
|
+
const names = palette.names ?? [];
|
|
94
|
+
const pick = (re) => names.find((n) => re.test(n)) ?? names[0] ?? 'brand';
|
|
95
|
+
const values = palette.values ?? {};
|
|
96
|
+
// a class named in a comment paints nothing; blanked, not cut, so the
|
|
97
|
+
// indexes still point at their line
|
|
98
|
+
const code = blankComments(text);
|
|
99
|
+
const hits = [...code.matchAll(PALETTE_CLASS_RE)];
|
|
100
|
+
// the evening override painted by hand (dark:bg-black) is the same sin on a
|
|
101
|
+
// shadcn sheet, which always has a dark row of its own
|
|
102
|
+
if (palette.source === 'shadcn') hits.push(...code.matchAll(DARK_WB_RE));
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const m of hits.sort((a, b) => a.index - b.index)) {
|
|
105
|
+
const cls = m[0];
|
|
106
|
+
const util = cls.replace(/^((?:[\w-]+:)*[a-z]+)-.*$/, '$1');
|
|
107
|
+
const shade = cls.replace(/^(?:[\w-]+:)*[a-z]+-/, '').replace(/\/\d+$/, '');
|
|
108
|
+
if (retuned.has(shade)) continue;
|
|
109
|
+
if (!hasFamily(cls)) continue;
|
|
110
|
+
const role = utilRole(util);
|
|
111
|
+
// the theme colour nearest by value among the names that suit the
|
|
112
|
+
// utility; the pick by name alone only when none is close
|
|
113
|
+
const example = nearestThemeName(shade, role, values)
|
|
114
|
+
?? (role === 'text' ? pick(/ink|text|fg|foreground/) : role === 'surface' ? pick(/surface|bg|background|canvas/) : pick(/border|edge|line|ring/));
|
|
115
|
+
const word = hueWord(shade, names);
|
|
116
|
+
out.push({
|
|
117
|
+
rule: 'palette-class', severity: 'violation', index: m.index, value: cls, themeFile, example: `${util}-${example}`,
|
|
118
|
+
message: `Palette class ${cls} where the theme names its colours (${themeFile}).`,
|
|
119
|
+
fix: `Use a theme token as the class (${util}-${example}). ${names.includes(word)
|
|
120
|
+
? `If no token fits, add ${/^[aeiou]/.test(word) ? 'an' : 'a'} ${word} shade to the theme once and use it by name.`
|
|
121
|
+
: `If no token fits, add one to the theme once, for example ${word}, and use ${util}-${word}.`}`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------- the theme colour a palette class stands in for ----------
|
|
128
|
+
// Which theme names suit a utility, read from the name: a text class wants an
|
|
129
|
+
// ink, a bg class a surface, a border class an edge. A name that says none of
|
|
130
|
+
// these (brand, warning, positive) is an accent and suits every utility.
|
|
131
|
+
const TEXT_NAME_RE = /ink|text|fg|foreground/;
|
|
132
|
+
const SURFACE_NAME_RE = /surface|(^|-)bg(-|$)|background|canvas|soft|tint|wash/;
|
|
133
|
+
const EDGE_NAME_RE = /border|edge|line|ring|divider|outline/;
|
|
134
|
+
function utilRole(util) {
|
|
135
|
+
if (/(^|:)(?:text|placeholder|caret|decoration|fill|stroke)$/.test(util)) return 'text';
|
|
136
|
+
if (/(^|:)bg$/.test(util)) return 'surface';
|
|
137
|
+
if (/(^|:)(?:border|ring|outline|divide)$/.test(util)) return 'edge';
|
|
138
|
+
return 'any';
|
|
139
|
+
}
|
|
140
|
+
function suits(name, role) {
|
|
141
|
+
const text = TEXT_NAME_RE.test(name), surface = SURFACE_NAME_RE.test(name), edge = EDGE_NAME_RE.test(name);
|
|
142
|
+
if (role === 'any' || (!text && !surface && !edge)) return true;
|
|
143
|
+
return role === 'text' ? text && !surface && !edge : role === 'surface' ? surface : edge;
|
|
144
|
+
}
|
|
145
|
+
// Distance in OKLab with the two colour axes doubled, so a pale amber lands on
|
|
146
|
+
// the pale warning surface rather than on the near-white canvas beside it.
|
|
147
|
+
const hueDistance = (x, y) => Math.hypot(x.L - y.L, 2 * (x.a - y.a), 2 * (x.b - y.b));
|
|
148
|
+
// Past this, the nearest theme colour is a different colour, not a stand-in
|
|
149
|
+
// (bg-blue-600 against a theme of greys), and the pick by name answers.
|
|
150
|
+
const CLOSE = 0.2;
|
|
151
|
+
/** The suitable theme name nearest to a palette shade's Tailwind value, or null. */
|
|
152
|
+
function nearestThemeName(shade, role, values) {
|
|
153
|
+
const from = oklab(TAILWIND_DEFAULTS[shade] ?? '');
|
|
154
|
+
if (!from) return null;
|
|
155
|
+
let best = null;
|
|
156
|
+
for (const [name, value] of Object.entries(values)) {
|
|
157
|
+
if (!suits(name, role)) continue;
|
|
158
|
+
const to = oklab(value);
|
|
159
|
+
if (!to) continue;
|
|
160
|
+
const d = hueDistance(from, to);
|
|
161
|
+
if (!best || d < best.d) best = { name, d };
|
|
162
|
+
}
|
|
163
|
+
return best && best.d <= CLOSE ? best.name : null;
|
|
164
|
+
}
|
|
165
|
+
// The word for a new token follows the hue, in the theme's own vocabulary when
|
|
166
|
+
// it has one (Ledgerly says positive and negative, not success and danger).
|
|
167
|
+
const HUE_WORDS = [
|
|
168
|
+
[/^(?:red|rose)-/, ['negative', 'danger', 'error', 'destructive']],
|
|
169
|
+
[/^(?:orange|amber|yellow)-/, ['warning', 'caution']],
|
|
170
|
+
[/^(?:green|emerald|lime)-/, ['positive', 'success']],
|
|
171
|
+
[/^(?:blue|sky|cyan)-/, ['info']],
|
|
172
|
+
];
|
|
173
|
+
function hueWord(shade, names) {
|
|
174
|
+
const words = HUE_WORDS.find(([re]) => re.test(shade))?.[1];
|
|
175
|
+
if (!words) return GREY_HUE_RE.test(`-${shade}`) ? 'muted' : 'accent';
|
|
176
|
+
return words.find((w) => names.some((n) => n === w || n.startsWith(`${w}-`)))
|
|
177
|
+
?? { negative: 'danger', warning: 'warning', positive: 'success', info: 'info' }[words[0]];
|
|
178
|
+
}
|
|
@@ -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.4.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,9 +18,7 @@ import { exemptReason, isDrawing, SVG_MARKUP_RE } from '../lib/exempt.mjs';
|
|
|
18
18
|
import { extraDeclarations, fontDeclarations } from '../lib/declarations.mjs';
|
|
19
19
|
import { typefaceOf, GENERIC_FONTS } from '../lib/typefaces.mjs';
|
|
20
20
|
import { kitPaintFindings } from '../lib/kitpaint.mjs';
|
|
21
|
-
import {
|
|
22
|
-
import { TAILWIND_DEFAULTS } from '../profiles/tailwind-defaults.mjs';
|
|
23
|
-
import { oklab } from '../lib/color.mjs';
|
|
21
|
+
import { paletteFindings } from '../lib/palette.mjs';
|
|
24
22
|
import { tokenTwinFindings } from '../lib/tokentwins.mjs';
|
|
25
23
|
import { avoidedImportFindings } from '../lib/avoidedimports.mjs';
|
|
26
24
|
import { isChartFile, chartFindings } from '../lib/charts.mjs';
|
|
@@ -47,7 +45,7 @@ export const KIT_CHECK = 'colours and pixel sizes written onto kit components wh
|
|
|
47
45
|
export const PALETTE_CHECK = 'palette classes where the theme names a colour';
|
|
48
46
|
export const BUTTON_CHECK = 'hand-made buttons where the repo has a Button';
|
|
49
47
|
export function checksFor(k) {
|
|
50
|
-
const list = k?.kit ? [...CHECKS, KIT_CHECK] :
|
|
48
|
+
const list = k?.kit ? [...CHECKS, KIT_CHECK] : k?.palette ? [...CHECKS, PALETTE_CHECK] : [...CHECKS];
|
|
51
49
|
if (k?.buttons?.length) list.push(BUTTON_CHECK);
|
|
52
50
|
return list;
|
|
53
51
|
}
|
|
@@ -180,57 +178,12 @@ export function validateContent(content, k) {
|
|
|
180
178
|
}
|
|
181
179
|
|
|
182
180
|
// ---------- palette classes where the theme names its colours ----------
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
// exactly the files the tile leaves out.
|
|
190
|
-
const inShadcnDoors = (f) => !!f && k.shadcn.doors.some((d) => f === d || f.startsWith(`${d}/`));
|
|
191
|
-
// A demo folder is left out on a Tailwind theme too: the report's tile never
|
|
192
|
-
// counted one, and the live check flagged it (2026-09-30).
|
|
193
|
-
const demo = !!file && DEMO_PATH_RE.test(file);
|
|
194
|
-
const paletteRule = k.tailwind ? (demo ? null : 'tailwind')
|
|
195
|
-
: k.shadcn && !(file && (inShadcnDoors(file) || demo)) ? 'shadcn'
|
|
196
|
-
: null;
|
|
197
|
-
if (paletteRule && !css) {
|
|
198
|
-
const tw = k.tailwind ?? {};
|
|
199
|
-
const retuned = new Set(tw.retuned ?? []);
|
|
200
|
-
const fam = tw.families ?? null;
|
|
201
|
-
const hasFamily = (cls) => !fam || (GREY_HUE_RE.test(cls) ? fam.grey : fam.colour);
|
|
202
|
-
const themeFile = paletteRule === 'tailwind' ? tw.file : (k.shadcn.sheet ?? 'the theme sheet');
|
|
203
|
-
// the example name follows the utility: a text- class wants an ink or
|
|
204
|
-
// foreground name, a bg- class a surface or background name
|
|
205
|
-
const names = paletteRule === 'tailwind' ? (tw.names ?? []) : ['primary', 'foreground', 'muted-foreground', 'background', 'border'];
|
|
206
|
-
const pick = (re) => names.find((n) => re.test(n)) ?? names[0] ?? 'brand';
|
|
207
|
-
const values = paletteRule === 'tailwind' ? tw.values ?? {} : {};
|
|
208
|
-
// a class named in a comment paints nothing; blanked, not cut, so the
|
|
209
|
-
// line numbers below still hold
|
|
210
|
-
const code = blankComments(text);
|
|
211
|
-
const hits = [...code.matchAll(PALETTE_CLASS_RE)];
|
|
212
|
-
// the evening override painted by hand (dark:bg-black) is the same sin
|
|
213
|
-
// on a shadcn sheet, which always has a dark row of its own
|
|
214
|
-
if (paletteRule === 'shadcn') hits.push(...code.matchAll(DARK_WB_RE));
|
|
215
|
-
for (const m of hits.sort((a, b) => a.index - b.index)) {
|
|
216
|
-
const cls = m[0];
|
|
217
|
-
const util = cls.replace(/^((?:[\w-]+:)*[a-z]+)-.*$/, '$1');
|
|
218
|
-
const shade = cls.replace(/^(?:[\w-]+:)*[a-z]+-/, '').replace(/\/\d+$/, '');
|
|
219
|
-
const role = utilRole(util);
|
|
220
|
-
// the theme colour nearest by value among the names that suit the
|
|
221
|
-
// utility; the old pick by name alone only when none is close
|
|
222
|
-
const example = nearestThemeName(shade, role, values)
|
|
223
|
-
?? (role === 'text' ? pick(/ink|text|fg|foreground/) : role === 'surface' ? pick(/surface|bg|background|canvas/) : pick(/border|edge|line|ring/));
|
|
224
|
-
if (retuned.has(shade)) continue;
|
|
225
|
-
if (!hasFamily(cls)) continue;
|
|
226
|
-
const word = hueWord(shade, names);
|
|
227
|
-
add('palette-class', 'violation', m.index,
|
|
228
|
-
`Palette class ${cls} where the theme names its colours (${themeFile}).`,
|
|
229
|
-
`Use a theme token as the class (${util}-${example}). ${names.includes(word)
|
|
230
|
-
? `If no token fits, add ${/^[aeiou]/.test(word) ? 'an' : 'a'} ${word} shade to the theme once and use it by name.`
|
|
231
|
-
: `If no token fits, add one to the theme once, for example ${word}, and use ${util}-${word}.`}`);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
181
|
+
// One rule for every door (lib/palette.mjs): which vocabulary applies, a
|
|
182
|
+
// Tailwind theme, shadcn's sheet or the repo's own theme under a shadcn
|
|
183
|
+
// kit, was decided once by the profile. The catalogue, kit blocks and demo
|
|
184
|
+
// folders are left out there, the same files the report's tile leaves out,
|
|
185
|
+
// and utility-class mode switches the rule off, as the report scores it.
|
|
186
|
+
for (const f of paletteFindings(text, k.palette, { file, css })) add(f.rule, f.severity, f.index, f.message, f.fix);
|
|
234
187
|
|
|
235
188
|
// ---------- colours ----------
|
|
236
189
|
const seenHere = new Set();
|
|
@@ -396,59 +349,6 @@ export function validateContent(content, k) {
|
|
|
396
349
|
return { findings, checked: checksFor(k) };
|
|
397
350
|
}
|
|
398
351
|
|
|
399
|
-
// ---------- the theme colour a palette class stands in for ----------
|
|
400
|
-
// Which theme names suit a utility, read from the name: a text class wants an
|
|
401
|
-
// ink, a bg class a surface, a border class an edge. A name that says none of
|
|
402
|
-
// these (brand, warning, positive) is an accent and suits every utility.
|
|
403
|
-
const TEXT_NAME_RE = /ink|text|fg|foreground/;
|
|
404
|
-
const SURFACE_NAME_RE = /surface|(^|-)bg(-|$)|background|canvas|soft|tint|wash/;
|
|
405
|
-
const EDGE_NAME_RE = /border|edge|line|ring|divider|outline/;
|
|
406
|
-
function utilRole(util) {
|
|
407
|
-
if (/(^|:)(?:text|placeholder|caret|decoration|fill|stroke)$/.test(util)) return 'text';
|
|
408
|
-
if (/(^|:)bg$/.test(util)) return 'surface';
|
|
409
|
-
if (/(^|:)(?:border|ring|outline|divide)$/.test(util)) return 'edge';
|
|
410
|
-
return 'any';
|
|
411
|
-
}
|
|
412
|
-
function suits(name, role) {
|
|
413
|
-
const text = TEXT_NAME_RE.test(name), surface = SURFACE_NAME_RE.test(name), edge = EDGE_NAME_RE.test(name);
|
|
414
|
-
if (role === 'any' || (!text && !surface && !edge)) return true;
|
|
415
|
-
return role === 'text' ? text && !surface && !edge : role === 'surface' ? surface : edge;
|
|
416
|
-
}
|
|
417
|
-
// Distance in OKLab with the two colour axes doubled, so a pale amber lands on
|
|
418
|
-
// the pale warning surface rather than on the near-white canvas beside it.
|
|
419
|
-
const hueDistance = (x, y) => Math.hypot(x.L - y.L, 2 * (x.a - y.a), 2 * (x.b - y.b));
|
|
420
|
-
// Past this, the nearest theme colour is a different colour, not a stand-in
|
|
421
|
-
// (bg-blue-600 against a theme of greys), and the old pick by name answers.
|
|
422
|
-
const CLOSE = 0.2;
|
|
423
|
-
/** The suitable theme name nearest to a palette shade's Tailwind value, or null. */
|
|
424
|
-
function nearestThemeName(shade, role, values) {
|
|
425
|
-
const from = oklab(TAILWIND_DEFAULTS[shade] ?? '');
|
|
426
|
-
if (!from) return null;
|
|
427
|
-
let best = null;
|
|
428
|
-
for (const [name, value] of Object.entries(values)) {
|
|
429
|
-
if (!suits(name, role)) continue;
|
|
430
|
-
const to = oklab(value);
|
|
431
|
-
if (!to) continue;
|
|
432
|
-
const d = hueDistance(from, to);
|
|
433
|
-
if (!best || d < best.d) best = { name, d };
|
|
434
|
-
}
|
|
435
|
-
return best && best.d <= CLOSE ? best.name : null;
|
|
436
|
-
}
|
|
437
|
-
// The word for a new token follows the hue, in the theme's own vocabulary when
|
|
438
|
-
// it has one (Ledgerly says positive and negative, not success and danger).
|
|
439
|
-
const HUE_WORDS = [
|
|
440
|
-
[/^(?:red|rose)-/, ['negative', 'danger', 'error', 'destructive']],
|
|
441
|
-
[/^(?:orange|amber|yellow)-/, ['warning', 'caution']],
|
|
442
|
-
[/^(?:green|emerald|lime)-/, ['positive', 'success']],
|
|
443
|
-
[/^(?:blue|sky|cyan)-/, ['info']],
|
|
444
|
-
];
|
|
445
|
-
function hueWord(shade, names) {
|
|
446
|
-
const words = HUE_WORDS.find(([re]) => re.test(shade))?.[1];
|
|
447
|
-
if (!words) return GREY_HUE_RE.test(`-${shade}`) ? 'muted' : 'accent';
|
|
448
|
-
return words.find((w) => names.some((n) => n === w || n.startsWith(`${w}-`)))
|
|
449
|
-
?? { negative: 'danger', warning: 'warning', positive: 'success', info: 'info' }[words[0]];
|
|
450
|
-
}
|
|
451
|
-
|
|
452
352
|
function looksLikeCss(text) {
|
|
453
353
|
// no JSX tags, has a `{ ...: ` declaration shape → treat as stylesheet.
|
|
454
354
|
// Sniffed on the head of the text with bounded patterns: the old version
|
|
@@ -210,6 +210,9 @@ export function loadKnowledge(root) {
|
|
|
210
210
|
// colours in: null when the repo is neither (see profiles/)
|
|
211
211
|
kit,
|
|
212
212
|
tailwind: P.isTailwind && profile.tailwind ? profile.tailwind : null,
|
|
213
|
+
// the vocabulary the palette rule judges against, decided once by the
|
|
214
|
+
// profile (lib/palette.mjs); null switches the rule off
|
|
215
|
+
palette: P.palette,
|
|
213
216
|
// A shadcn kitchen: the sheet names the colours, so a palette class in
|
|
214
217
|
// own code is paint from a tin (shadcn's own rule: semantic colours,
|
|
215
218
|
// never bg-blue-500). The report's tile counts it over own code plus
|
|
@@ -52,7 +52,14 @@ export function getContext(k, { path = null } = {}) {
|
|
|
52
52
|
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.`);
|
|
53
53
|
} else if (t.tokenFile) {
|
|
54
54
|
const strays = t.colors.length - k.tokenColors.length;
|
|
55
|
-
|
|
55
|
+
// on a shadcn kit the example classes follow the vocabulary the palette
|
|
56
|
+
// rule judges against (lib/palette.mjs): shadcn's names, or the repo's own
|
|
57
|
+
// theme when it keeps one and no shadcn rows; in utility-class mode the
|
|
58
|
+
// palette is the theme and the clause is dropped
|
|
59
|
+
const pal = k.shadcn ? k.palette : null;
|
|
60
|
+
const pick = (re, i) => (pal?.names ?? []).find((n) => re.test(n)) ?? pal?.names?.[i] ?? 'brand';
|
|
61
|
+
const examples = pal?.source === 'tailwind' ? `bg-${pick(/surface|(^|-)bg(-|$)|background|canvas/, 0)}, text-${pick(/ink|text|fg|foreground/, 1)}` : 'bg-primary, text-muted-foreground';
|
|
62
|
+
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.)` : ''}`);
|
|
56
63
|
} else if (t.colors.length) {
|
|
57
64
|
L.push(`TOKENS: none defined. ${t.colors.length} distinct colours already in play; reuse one, never invent another.`);
|
|
58
65
|
}
|
|
@@ -22,7 +22,8 @@ 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 from './tailwind.mjs';
|
|
25
|
+
import tailwind, { readTailwindTheme } from './tailwind.mjs';
|
|
26
|
+
import { decidePalette } from '../lib/palette.mjs';
|
|
26
27
|
import mui from './mui.mjs';
|
|
27
28
|
import mantine from './mantine.mjs';
|
|
28
29
|
import chakra from './chakra.mjs';
|
|
@@ -82,6 +83,12 @@ export function decideProfile(profile, components, files, root = null) {
|
|
|
82
83
|
profile.kind = picked.kind;
|
|
83
84
|
profile.kindConfidence = decision.confidence;
|
|
84
85
|
profile.kindEvidence = decision.evidence;
|
|
86
|
+
// Which vocabulary a palette class is judged against, decided once here
|
|
87
|
+
// and read by the live checks and the guard (lib/palette.mjs): a Tailwind
|
|
88
|
+
// theme, shadcn's sheet when its contract holds, the repo's own theme
|
|
89
|
+
// under a shadcn kit with no rows, or nothing. The report's tile keeps
|
|
90
|
+
// its own gates (harvest/index.mjs) and no score reads this.
|
|
91
|
+
profile.palette = decidePalette(profile, profileOf(profile), () => readTailwindTheme({ ...profile }, ctx)?.facts ?? null);
|
|
85
92
|
// A shadcn repo that PUBLISHES a registry is a registry: the fourth kind.
|
|
86
93
|
// Every count still reads the shadcn facts (release (a): zero score change);
|
|
87
94
|
// the kind, the receipt and the header line say what it is.
|
|
@@ -154,6 +161,8 @@ export function profileOf(h) {
|
|
|
154
161
|
// a Tailwind repo with its own theme and no kit
|
|
155
162
|
isTailwind: kind === 'tailwind',
|
|
156
163
|
tailwind: p.tailwind ?? null,
|
|
164
|
+
// the vocabulary the palette rule judges against, or null when it is off
|
|
165
|
+
palette: p.palette ?? null,
|
|
157
166
|
// a product built on a component kit installed from npm (mui)
|
|
158
167
|
isKit: !!p.kit,
|
|
159
168
|
kit: p.kit ?? null,
|
|
@@ -328,6 +328,12 @@ export default {
|
|
|
328
328
|
if (registryDirs.length) evidence.push(`${registryDirs.length} installed registr${registryDirs.length === 1 ? 'y' : 'ies'} beside it: ${registryDirs.map((d) => basename(d)).join(', ')}`);
|
|
329
329
|
profile.shadcn = {
|
|
330
330
|
installs: installs.map((i) => ({ config: i.config, uiDir: i.uiDir, catalogueNames: i.catalogueNames })),
|
|
331
|
+
// the theme contract: how many of shadcn's rows any stylesheet defines,
|
|
332
|
+
// and the file carrying most of them. The configured sheet can hold
|
|
333
|
+
// none under :root (formbricks writes its theme straight into @theme,
|
|
334
|
+
// a sibling package keeps the rows) while the contract still holds;
|
|
335
|
+
// the palette rule reads this, not the sheet alone (lib/palette.mjs)
|
|
336
|
+
contract: { rows: contract, file: themeContract.file ?? null },
|
|
331
337
|
kit,
|
|
332
338
|
sheet,
|
|
333
339
|
blockFiles,
|
|
@@ -118,15 +118,16 @@ function countUses(root, codeFiles, styleFiles, own) {
|
|
|
118
118
|
return { uses, files };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The repo's own v4 theme, read without deciding anything: the facts the
|
|
123
|
+
* profile stores as `tailwind` and the evidence for the decision, or null
|
|
124
|
+
* when there is no theme worth judging. Exported so the palette rule can
|
|
125
|
+
* read the theme of a repo another kind claimed (a shadcn install with no
|
|
126
|
+
* shadcn rows but a theme of its own, lib/palette.mjs). Reads `profile`,
|
|
127
|
+
* never writes it.
|
|
128
|
+
* @returns {{ facts, decision } | null}
|
|
129
|
+
*/
|
|
130
|
+
export function readTailwindTheme(profile, ctx) {
|
|
130
131
|
if (!ctx?.root || !ctx?.files) return null;
|
|
131
132
|
const { root, files } = ctx;
|
|
132
133
|
const twRaw = profile.stylingDeps?.includes('Tailwind CSS') || /tailwind/i.test((profile.stylingDeps ?? []).join(' '))
|
|
@@ -158,8 +159,7 @@ export default {
|
|
|
158
159
|
twRaw ? `Tailwind ${twRaw}` : 'Tailwind v4 theme block',
|
|
159
160
|
];
|
|
160
161
|
const values = resolveValues(root, files.styles, theme.own.map((x) => [x, theme.names.get(x)]));
|
|
161
|
-
|
|
162
|
-
profile.tailwind = {
|
|
162
|
+
const facts = {
|
|
163
163
|
file: theme.file,
|
|
164
164
|
names: theme.own,
|
|
165
165
|
restated: theme.names.size - theme.own.length,
|
|
@@ -175,6 +175,22 @@ export default {
|
|
|
175
175
|
// a theme nobody uses is not the system yet: shown with receipts, not scored
|
|
176
176
|
adopted: usedEnough(uses, theme.own.length),
|
|
177
177
|
};
|
|
178
|
-
return { confidence: usedEnough(uses, theme.own.length) ? 'high' : 'medium', evidence };
|
|
178
|
+
return { facts, decision: { confidence: usedEnough(uses, theme.own.length) ? 'high' : 'medium', evidence } };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export default {
|
|
182
|
+
kind: 'tailwind',
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param profile the profiler's facts (mutated: designSystem, tailwind)
|
|
186
|
+
* @param counts reusable/pages/codeFiles
|
|
187
|
+
* @param ctx { root, files }
|
|
188
|
+
*/
|
|
189
|
+
recognise(profile, counts, ctx) {
|
|
190
|
+
const read = readTailwindTheme(profile, ctx);
|
|
191
|
+
if (!read) return null;
|
|
192
|
+
profile.designSystem = { kind: 'tailwind', name: 'a Tailwind theme', confidence: 'high', cssVariables: true };
|
|
193
|
+
profile.tailwind = read.facts;
|
|
194
|
+
return read.decision;
|
|
179
195
|
},
|
|
180
196
|
};
|