cursedbelt 5.19.1 โ 5.20.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.
|
@@ -15,6 +15,8 @@ interface Rule {
|
|
|
15
15
|
check: (filePath: string, source: string) => Violation[];
|
|
16
16
|
}
|
|
17
17
|
export declare function walk(dir: string, out?: string[]): string[];
|
|
18
|
+
/** Set the stylesheets this scan resolves against; the belt's `theme.css` is always included. */
|
|
19
|
+
export declare function setThemeCss(appCss: readonly string[]): void;
|
|
18
20
|
export declare function setReimplementedNames(names: readonly string[]): void;
|
|
19
21
|
export declare function setPositionedClasses(compounds: readonly (readonly string[])[]): void;
|
|
20
22
|
export declare function positionedClassesFromCss(css: string): string[][];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"guardrailsEnforce.d.ts","sourceRoot":"","sources":["../../scripts/guardrailsEnforce.ts"],"names":[],"mappings":";
|
|
1
|
+
{"version":3,"file":"guardrailsEnforce.d.ts","sourceRoot":"","sources":["../../scripts/guardrailsEnforce.ts"],"names":[],"mappings":";AAwNA,UAAU,SAAS;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB;;;sEAGkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,IAAI;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,SAAS,EAAE,CAAC;CACzD;AAED,wBAAgB,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,EAAO,GAAG,MAAM,EAAE,CA6B9D;AAu5BD,iGAAiG;AACjG,wBAAgB,WAAW,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAE3D;AAmHD,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAEpE;AA4tBD,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,SAAS,CAAC,SAAS,MAAM,EAAE,CAAC,EAAE,GAAG,IAAI,CAEpF;AAID,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,EAAE,CAoBhE;AA8CD,eAAO,MAAM,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAuBtC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,mBAAmB,EAAE,WAAW,CAAC,MAAM,CAAuC,CAAC;AAE5F,0EAA0E;AAC1E,eAAO,MAAM,SAAS,EAAE,SAAS,MAAM,EAAwE,CAAC;AAShH,eAAO,MAAM,uBAAuB,aAAkD,CAAC;AAGvF,MAAM,WAAW,SAAS;IACzB,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACb;AAED;2FAC2F;AAC3F,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CA0C3F;AAED,YAAY,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;AAmGhC,wBAAgB,0BAA0B,CAAC,SAAS,GAAE,MAAwB,GAAG,MAAM,EAAE,CAyBxF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.20.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The React design system of the cursedbelt split โ components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
RULES,
|
|
17
17
|
setPositionedClasses,
|
|
18
18
|
setReimplementedNames,
|
|
19
|
+
setThemeCss,
|
|
19
20
|
walk,
|
|
20
21
|
} from './guardrailsEnforce';
|
|
21
22
|
|
|
@@ -588,6 +589,57 @@ describe('no-tailwind-palette', () => {
|
|
|
588
589
|
});
|
|
589
590
|
});
|
|
590
591
|
|
|
592
|
+
describe('theme-colours-resolve (task 2240)', () => {
|
|
593
|
+
const REGISTERS_PRIMARY = '@theme inline {\n --color-primary: var(--primary);\n}\n';
|
|
594
|
+
afterEach(() => setThemeCss([]));
|
|
595
|
+
|
|
596
|
+
test('๐ด a colour the belt DEFINES but nothing registers is flagged โ bg-primary drew nothing in flix', () => {
|
|
597
|
+
setThemeCss([]);
|
|
598
|
+
const found = check('theme-colours-resolve', '<span className="bg-primary/10 text-primary-foreground" />');
|
|
599
|
+
expect(found.map((v) => v.snippet.split(' โ ')[0])).toEqual(['bg-primary/10', 'text-primary-foreground']);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test("the app's own @theme registering it makes it resolve", () => {
|
|
603
|
+
setThemeCss([REGISTERS_PRIMARY]);
|
|
604
|
+
expect(check('theme-colours-resolve', '<span className="bg-primary/10 border-primary/60" />')).toHaveLength(0);
|
|
605
|
+
expect(check('theme-colours-resolve', '<span className="text-primary-foreground" />')).toHaveLength(1);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("an app's own custom property is checked the same way", () => {
|
|
609
|
+
setThemeCss([':root { --brand: #123456; }\n']);
|
|
610
|
+
expect(check('theme-colours-resolve', '<b className="text-brand" />')).toHaveLength(1);
|
|
611
|
+
setThemeCss([':root { --brand: #123456; }\n@theme {\n --color-brand: var(--brand);\n}\n']);
|
|
612
|
+
expect(check('theme-colours-resolve', '<b className="text-brand" />')).toHaveLength(0);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("Tailwind's own names, the registered tokens and a CSS file are not its business", () => {
|
|
616
|
+
setThemeCss([]);
|
|
617
|
+
expect(check('theme-colours-resolve', '<p className="text-sm border-2 bg-accent/15 text-accent-strong border-border" />')).toHaveLength(0);
|
|
618
|
+
expect(RULES['theme-colours-resolve'].check('a.css', '.x { border-radius: var(--radius); }')).toHaveLength(0);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test('takes the opt-out comment, and a comment is not a class', () => {
|
|
622
|
+
setThemeCss([]);
|
|
623
|
+
expect(check('theme-colours-resolve', '// guardrails-ignore theme-colours-resolve: an @apply target\n<i className="bg-primary" />')).toHaveLength(0);
|
|
624
|
+
expect(check('theme-colours-resolve', '// we used to write bg-primary here\n<i />')).toHaveLength(0);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test('๐ด end to end: an app registering the colour OUTSIDE its scanned root still resolves', async () => {
|
|
628
|
+
const rows = JSON.stringify(Object.fromEntries(APP_RULES.map((name) => [name, 0])));
|
|
629
|
+
const app = { 'scripts/baseline.json': rows, 'src/Badge.tsx': 'export const B = () => <span className="bg-primary" />;\n' };
|
|
630
|
+
await withFixture(app, async (dir) => {
|
|
631
|
+
const { code, report } = await runCli(dir, ['--all-app-rules', '--baseline', 'scripts/baseline.json', 'src']);
|
|
632
|
+
expect(report).toContain('bg-primary');
|
|
633
|
+
expect(code).toBe(1);
|
|
634
|
+
});
|
|
635
|
+
await withFixture({ ...app, 'styles/globals.css': REGISTERS_PRIMARY }, async (dir) => {
|
|
636
|
+
const { code, report } = await runCli(dir, ['--all-app-rules', '--baseline', 'scripts/baseline.json', 'src']);
|
|
637
|
+
expect(report).toContain('โ theme-colours-resolve: 0 violation(s)');
|
|
638
|
+
expect(code).toBe(0);
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
591
643
|
describe('no-raw-title-attr', () => {
|
|
592
644
|
test('flags title= on a DOM element', () => {
|
|
593
645
|
expect(check('no-raw-title-attr', '<button title="Delete"><Trash /></button>')).toHaveLength(1);
|
|
@@ -89,6 +89,11 @@
|
|
|
89
89
|
// `text-muted-foreground`), arbitrary token values (`bg-[var(--surface-2)]`) and
|
|
90
90
|
// `white`/`black`/`transparent`/`current` are not matched.
|
|
91
91
|
//
|
|
92
|
+
// theme-colours-resolve โ task 2240: a colour utility (`bg-primary`) must name a colour an
|
|
93
|
+
// `@theme` REGISTERS (`--color-primary`), not merely a custom property some stylesheet
|
|
94
|
+
// defines โ otherwise Tailwind emits nothing and the element draws no colour. Resolved
|
|
95
|
+
// against the belt's `theme.css` plus every `.css` in the app package. See its block.
|
|
96
|
+
//
|
|
92
97
|
// no-raw-title-attr โ DESIGN.md ยง4 ("Help Context Disclosing"): no native `title=""`
|
|
93
98
|
// on a DOM element. It does not exist on a touch device, waits ~500ms, cannot be
|
|
94
99
|
// themed, and reads inconsistently to a screen reader. Use cursedbelt's `Tooltip`,
|
|
@@ -1116,6 +1121,102 @@ const noTailwindPalette: Rule = makeValueRule(
|
|
|
1116
1121
|
TAILWIND_PALETTE_RE,
|
|
1117
1122
|
);
|
|
1118
1123
|
|
|
1124
|
+
// โโ theme-colours-resolve (task 2240) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1125
|
+
// A colour utility must name a colour Tailwind can GENERATE, not merely a CSS variable that
|
|
1126
|
+
// exists. `theme.css` defined `--primary` as an alias but registered no `--color-primary` in
|
|
1127
|
+
// its `@theme`, so `bg-primary` compiled to nothing: no error, the class is simply absent from
|
|
1128
|
+
// the built CSS. Measured in flix on 2026-09-28 โ SearchInput's badge background read
|
|
1129
|
+
// `rgba(0, 0, 0, 0)` off Chromium โ and four DataTable components carried the same classes.
|
|
1130
|
+
// `src/themeColorsResolve.spec.ts` holds the belt's own components to this since 5.19.1; this
|
|
1131
|
+
// rule is the same check for an app, whose classes resolve against its own stylesheet too.
|
|
1132
|
+
//
|
|
1133
|
+
// The rule: a colour prefix (`bg-`, `text-`, `border-`, โฆ) plus a name some stylesheet DEFINES
|
|
1134
|
+
// as a custom property (`--primary: โฆ`) must have `--color-<name>` (or `--<prefix>-<name>`)
|
|
1135
|
+
// REGISTERED in an `@theme` block. Defined and registered are both read from the belt's
|
|
1136
|
+
// `theme.css` plus every `.css` in the app package (`setThemeCss`, from `main()`), because an
|
|
1137
|
+
// app's `@theme` need not sit under the roots it scans โ binary-server's is `styles/globals.css`.
|
|
1138
|
+
// A name no stylesheet defines (`text-sm`, `border-2`, `bg-slate-800`) is Tailwind's own and is
|
|
1139
|
+
// another rule's business. `.css` files are not scanned: `border-radius:` and `outline-offset:`
|
|
1140
|
+
// are properties, not utilities, and would read as `border-` + a defined `--radius`.
|
|
1141
|
+
const THEME_COLOUR_PREFIXES = [
|
|
1142
|
+
"bg", "text", "border", "ring", "outline", "fill", "stroke", "from", "via", "to",
|
|
1143
|
+
"divide", "decoration", "placeholder", "caret", "ring-offset",
|
|
1144
|
+
];
|
|
1145
|
+
const THEME_COLOUR_CLASS_RE = new RegExp(
|
|
1146
|
+
`(?<![\\w-])(${THEME_COLOUR_PREFIXES.join("|")})-([a-z][a-z0-9-]*?)(?:/\\d+)?(?![\\w/-])`,
|
|
1147
|
+
"g",
|
|
1148
|
+
);
|
|
1149
|
+
const BELT_THEME_CSS = join(import.meta.dir, "..", "src", "theme.css");
|
|
1150
|
+
|
|
1151
|
+
interface ThemeIndex {
|
|
1152
|
+
/** Every `--x` an `@theme` block registers โ what Tailwind can make utilities from. */
|
|
1153
|
+
registered: Set<string>;
|
|
1154
|
+
/** Every custom property defined anywhere โ the names a utility could be MISTAKEN for. */
|
|
1155
|
+
defined: Set<string>;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** Index `@theme` registrations and custom-property definitions across stylesheets. */
|
|
1159
|
+
function themeIndexFromCss(sources: readonly string[]): ThemeIndex {
|
|
1160
|
+
const registered = new Set<string>();
|
|
1161
|
+
const defined = new Set<string>();
|
|
1162
|
+
for (const raw of sources) {
|
|
1163
|
+
const css = raw.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
1164
|
+
for (const block of css.matchAll(/@theme[^{]*\{([\s\S]*?)\n\}/g)) {
|
|
1165
|
+
for (const m of (block[1] ?? "").matchAll(/--([a-z0-9-]+)\s*:/g)) registered.add(m[1] ?? "");
|
|
1166
|
+
}
|
|
1167
|
+
for (const m of css.matchAll(/--([a-z0-9-]+)\s*:/g)) defined.add(m[1] ?? "");
|
|
1168
|
+
}
|
|
1169
|
+
return { registered, defined };
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/** The belt's own `theme.css` โ the index a caller outside `main()` gets by default. */
|
|
1173
|
+
function beltThemeCss(): string[] {
|
|
1174
|
+
try {
|
|
1175
|
+
return [readFileSync(BELT_THEME_CSS, "utf8")];
|
|
1176
|
+
} catch {
|
|
1177
|
+
return [];
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
let THEME_INDEX: ThemeIndex | undefined;
|
|
1182
|
+
/** Set the stylesheets this scan resolves against; the belt's `theme.css` is always included. */
|
|
1183
|
+
export function setThemeCss(appCss: readonly string[]): void {
|
|
1184
|
+
THEME_INDEX = themeIndexFromCss([...beltThemeCss(), ...appCss]);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/** The utilities in `source` that name a defined-but-unregistered colour, with their offsets. */
|
|
1188
|
+
function unresolvedThemeColours(source: string, index: ThemeIndex): { cls: string; at: number }[] {
|
|
1189
|
+
const out: { cls: string; at: number }[] = [];
|
|
1190
|
+
THEME_COLOUR_CLASS_RE.lastIndex = 0;
|
|
1191
|
+
for (const m of source.matchAll(THEME_COLOUR_CLASS_RE)) {
|
|
1192
|
+
const name = m[2] ?? "";
|
|
1193
|
+
if (!index.defined.has(name)) continue;
|
|
1194
|
+
if (index.registered.has(`color-${name}`) || index.registered.has(`${m[1]}-${name}`)) continue;
|
|
1195
|
+
out.push({ cls: m[0], at: m.index ?? 0 });
|
|
1196
|
+
}
|
|
1197
|
+
return out;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const THEME_COLOURS_IGNORE_RE = /guardrails-ignore\s+theme-colours-resolve\s*:/;
|
|
1201
|
+
|
|
1202
|
+
const themeColoursResolve: Rule = {
|
|
1203
|
+
name: "theme-colours-resolve",
|
|
1204
|
+
description:
|
|
1205
|
+
"a colour utility (bg-primary, text-primary-foreground, โฆ) names a CSS variable no @theme registers as --color-<name>, so Tailwind generates NOTHING for it and the element draws no colour. Register `--color-<name>: var(--<name>);` in the app's @theme, or use a colour the theme registers.",
|
|
1206
|
+
check(filePath, source) {
|
|
1207
|
+
if (extname(filePath) === ".css") return [];
|
|
1208
|
+
THEME_INDEX ??= themeIndexFromCss(beltThemeCss());
|
|
1209
|
+
const violations: Violation[] = [];
|
|
1210
|
+
const lines = source.split("\n");
|
|
1211
|
+
for (const { cls, at } of unresolvedThemeColours(blankComments(source), THEME_INDEX)) {
|
|
1212
|
+
const line = lineOf(source, at);
|
|
1213
|
+
if (THEME_COLOURS_IGNORE_RE.test(lines.slice(Math.max(0, line - 3), line).join("\n"))) continue;
|
|
1214
|
+
violations.push({ file: filePath, line, snippet: `${cls} โ ${lines[line - 1]?.trim() ?? ""}` });
|
|
1215
|
+
}
|
|
1216
|
+
return violations;
|
|
1217
|
+
},
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1119
1220
|
// โโ no-raw-title-attr (DESIGN.md ยง4 "Help Context Disclosing") โโโโโโโโโโโโโโ
|
|
1120
1221
|
// A native `title=""` is the cheapest possible tooltip and it is four separate
|
|
1121
1222
|
// failures at once: it does not exist on a touch device (there is no hover), it
|
|
@@ -2013,6 +2114,7 @@ export const RULES: Record<string, Rule> = {
|
|
|
2013
2114
|
[noRawZindex.name]: noRawZindex,
|
|
2014
2115
|
[noRawDataTable.name]: noRawDataTable,
|
|
2015
2116
|
[noTailwindPalette.name]: noTailwindPalette,
|
|
2117
|
+
[themeColoursResolve.name]: themeColoursResolve,
|
|
2016
2118
|
[noRawTitleAttr.name]: noRawTitleAttr,
|
|
2017
2119
|
[noLocalReimplementation.name]: noLocalReimplementation,
|
|
2018
2120
|
[microTypographyFloor.name]: microTypographyFloor,
|
|
@@ -2356,6 +2458,10 @@ function main() {
|
|
|
2356
2458
|
const sourcesByFile = new Map<string, string>();
|
|
2357
2459
|
for (const file of files) sourcesByFile.set(file, readFileSync(file, "utf8"));
|
|
2358
2460
|
|
|
2461
|
+
// `theme-colours-resolve` resolves against every stylesheet in the package, not only the
|
|
2462
|
+
// scanned roots โ an app's `@theme` can live beside `src/` (binary-server's `styles/`).
|
|
2463
|
+
setThemeCss(walk(".").filter((file) => extname(file) === ".css").map((file) => readFileSync(file, "utf8")));
|
|
2464
|
+
|
|
2359
2465
|
// `no-raw-popover` sees a popup its app's stylesheet positions โ see that rule.
|
|
2360
2466
|
setPositionedClasses(
|
|
2361
2467
|
[...sourcesByFile].filter(([file]) => extname(file) === ".css").flatMap(([, css]) => positionedClassesFromCss(css)),
|
|
@@ -21,29 +21,18 @@
|
|
|
21
21
|
import { describe, expect, test } from 'bun:test';
|
|
22
22
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
|
+
import { RULES, setThemeCss } from '../scripts/guardrailsEnforce';
|
|
24
25
|
|
|
25
26
|
const ROOT = join(import.meta.dir, 'react');
|
|
26
|
-
const THEME = readFileSync(join(import.meta.dir, 'theme.css'), 'utf8');
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
/** The utilities in `source` that name a defined-but-unregistered colour. */
|
|
38
|
-
export function unresolvedColours(source: string): string[] {
|
|
39
|
-
const out: string[] = [];
|
|
40
|
-
for (const m of source.matchAll(CLASS)) {
|
|
41
|
-
const name = m[2] ?? '';
|
|
42
|
-
if (!DEFINED.has(name)) continue;
|
|
43
|
-
const ok = REGISTERED.has(`color-${name}`) || REGISTERED.has(`${m[1]}-${name}`);
|
|
44
|
-
if (!ok) out.push(m[0]);
|
|
45
|
-
}
|
|
46
|
-
return out;
|
|
28
|
+
/**
|
|
29
|
+
* The utilities in `source` that name a defined-but-unregistered colour. One implementation:
|
|
30
|
+
* this is the guardrail rule `theme-colours-resolve`, which holds apps to the same check. With
|
|
31
|
+
* no app stylesheet it resolves against `theme.css` alone โ exactly the belt's own situation.
|
|
32
|
+
*/
|
|
33
|
+
function unresolvedColours(source: string): string[] {
|
|
34
|
+
setThemeCss([]);
|
|
35
|
+
return RULES['theme-colours-resolve'].check('x.tsx', source).map((v) => v.snippet.split(' โ ')[0] ?? '');
|
|
47
36
|
}
|
|
48
37
|
|
|
49
38
|
function files(dir: string): string[] {
|
|
@@ -56,8 +45,6 @@ function files(dir: string): string[] {
|
|
|
56
45
|
|
|
57
46
|
describe('every colour utility the belt uses resolves to a registered theme colour', () => {
|
|
58
47
|
test('the check sees the 2026-09-28 shape, and passes the canonical names', () => {
|
|
59
|
-
expect(REGISTERED.has('color-accent-solid')).toBe(true);
|
|
60
|
-
expect(DEFINED.has('primary')).toBe(true);
|
|
61
48
|
expect(unresolvedColours("'border-primary/60 bg-primary/10 text-primary-foreground'")).toEqual([
|
|
62
49
|
'border-primary/60',
|
|
63
50
|
'bg-primary/10',
|