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":";AAmNA,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;AA66BD,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,CAsBtC,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"}
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.19.1",
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
- const themeBlocks = [...THEME.matchAll(/@theme[^{]*\{([\s\S]*?)\n\}/g)].map((m) => m[1] ?? '');
29
- /** Every `--x` the `@theme` blocks register โ€” what Tailwind can make utilities from. */
30
- const REGISTERED = new Set(themeBlocks.flatMap((b) => [...b.matchAll(/--([a-z0-9-]+)\s*:/g)].map((m) => m[1] ?? '')));
31
- /** Every custom property the file defines anywhere โ€” the names a utility could be MISTAKEN for. */
32
- const DEFINED = new Set([...THEME.matchAll(/--([a-z0-9-]+)\s*:/g)].map((m) => m[1] ?? ''));
33
-
34
- const PREFIXES = ['bg', 'text', 'border', 'ring', 'outline', 'fill', 'stroke', 'from', 'via', 'to', 'divide', 'decoration', 'placeholder', 'caret', 'ring-offset'];
35
- const CLASS = new RegExp(`(?<![\\w-])(${PREFIXES.join('|')})-([a-z][a-z0-9-]*?)(?:/\\d+)?(?![\\w/-])`, 'g');
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',