cursedbelt 5.2.0 → 5.3.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.
@@ -144,3 +144,15 @@
144
144
  @source inline("w-[var(--control-h-lg)] w-[var(--control-h-md)] w-[var(--control-h-sm)] w-auto w-full w-px white");
145
145
  @source inline("whitespace-nowrap whitespace-pre-wrap wrap wrap-break-word z z-10 z-20 z-30 z-40 z-50");
146
146
  @source inline("z-[var(--z-popover)]");
147
+
148
+ /*
149
+ * Theme variables cursedbelt's components read BY NAME (`var(--color-card)` in a style prop).
150
+ * Tailwind emits a theme variable only when the app's own pass sees it used, and a `var()` here is
151
+ * that use — `@source inline` cannot mark one. 9 variable(s), derived by
152
+ * scripts/generateUtilityStyles.ts → themeVariablesReadByName(). Nothing reads this property.
153
+ */
154
+ @layer theme {
155
+ :root, :host {
156
+ --cb-theme-read-by-name: var(--color-accent) var(--color-border) var(--color-card) var(--color-muted) var(--color-popover) var(--duration-base) var(--ease-out) var(--text-caption) var(--text-sm);
157
+ }
158
+ }
@@ -18,10 +18,10 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 4 candidate(s).
21
+ * 2 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
25
25
  * import closures rather than a hand-written list, is scripts/styleAreas.ts.
26
26
  */
27
- @source inline("--font-sans --text-sm col collapse");
27
+ @source inline("col collapse");
@@ -18,10 +18,10 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 7 candidate(s).
21
+ * 5 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
25
25
  * import closures rather than a hand-written list, is scripts/styleAreas.ts.
26
26
  */
27
- @source inline("--font-sans --text-sm col collapse min-w-16 w w-48");
27
+ @source inline("col collapse min-w-16 w w-48");
@@ -4969,3 +4969,15 @@
4969
4969
  }
4970
4970
  }
4971
4971
  }
4972
+
4973
+ /*
4974
+ * Theme variables cursedbelt's components read BY NAME (`var(--color-card)` in a style prop).
4975
+ * Tailwind emits a theme variable only when the app's own pass sees it used, and a `var()` here is
4976
+ * that use — `@source inline` cannot mark one. 9 variable(s), derived by
4977
+ * scripts/generateUtilityStyles.ts → themeVariablesReadByName(). Nothing reads this property.
4978
+ */
4979
+ @layer theme {
4980
+ :root, :host {
4981
+ --cb-theme-read-by-name: var(--color-accent) var(--color-border) var(--color-card) var(--color-muted) var(--color-popover) var(--duration-base) var(--ease-out) var(--text-caption) var(--text-sm);
4982
+ }
4983
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.2.0",
3
+ "version": "5.3.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.",
@@ -11,7 +11,11 @@ import {
11
11
  onlyAsPropertyName,
12
12
  onlyInMergeTables,
13
13
  readBeltSheets,
14
+ readThemeVariables,
15
+ themeVariablesIn,
14
16
  tokensInJs,
17
+ undefinedThemeVariables,
18
+ variablesDeclared,
15
19
  } from './checkAreaStyles';
16
20
  import { compileStylesheet } from './generateUtilityStyles';
17
21
 
@@ -226,3 +230,66 @@ describe('🔴 the ORDER check — a base utility after a variant that sets the
226
230
  }
227
231
  });
228
232
  });
233
+
234
+ describe('🔴 the THEME VARIABLE check — a `var()` of a theme variable no built stylesheet declares (5.3.0)', () => {
235
+ // station's DataTable frozen column and DropIndicator, verbatim in shape from its minified bundle.
236
+ const STATION_JS =
237
+ 'style:Je?{"--cb-frozen-bg":ke%2===1?"color-mix(in srgb, var(--color-muted) 50%, var(--color-card))":"var(--color-card)"}:void 0,' +
238
+ 'function ni({orientation:e,side:t,color:r}){const n=r??"var(--color-accent)"}';
239
+ const APP = '@import "tailwindcss" source(none);\n@import "./styles-static.css";\n';
240
+ const THEME_READ_BLOCK = /\n\/\*\n \* Theme variables cursedbelt's components read BY NAME[\s\S]*$/;
241
+
242
+ test("station's pre-fix shape — core WITHOUT the theme-read block — is RED on exactly the three it lost", async () => {
243
+ const core = readBeltSheets().areas.core ?? '';
244
+ expect(core).toMatch(THEME_READ_BLOCK); // the block exists, so stripping it below is the 5.2.0 core
245
+ const preFix = (await compileStylesheet(`${APP}${core.replace(THEME_READ_BLOCK, '\n')}\n@import "./theme.css";\n`)).build([]);
246
+ expect(undefinedThemeVariables({ js: [STATION_JS], css: [preFix] }, readThemeVariables())).toEqual([
247
+ '--color-accent',
248
+ '--color-card',
249
+ '--color-muted',
250
+ ]);
251
+ });
252
+
253
+ test('the fix: the shipped core makes the app\'s ONE pass declare every theme variable a component reads by name', async () => {
254
+ const fixed = (await compileStylesheet(`${APP}@import "./styles-areas/core.css";\n@import "./theme.css";\n`)).build([]);
255
+ expect(undefinedThemeVariables({ js: [STATION_JS], css: [fixed] }, readThemeVariables())).toEqual([]);
256
+ // …and what core names is what the check scopes to — every one a theme variable.
257
+ const block = THEME_READ_BLOCK.exec(readBeltSheets().areas.core ?? '')?.[0] ?? '';
258
+ const named = [...block.matchAll(/var\((--[\w-]+)\)/g)].map((m) => m[1]);
259
+ const theme = readThemeVariables();
260
+ expect(named.filter((n) => !theme.has(n as string))).toEqual([]);
261
+ for (const v of ['--color-card', '--color-muted', '--color-accent', '--text-caption']) expect(named).toContain(v);
262
+ });
263
+
264
+ test('scope: a runtime-set or plain custom property is not a theme variable, and a fallback does not excuse one', () => {
265
+ const theme = new Set(['--color-card']);
266
+ const js = ['"var(--offset-right)" "var(--radix-popper-available-height)" "var(--color-card, white)"'];
267
+ expect(undefinedThemeVariables({ js, css: [''] }, theme)).toEqual(['--color-card']);
268
+ expect(undefinedThemeVariables({ js, css: [':root{--color-card:var(--card)}'] }, theme)).toEqual([]);
269
+ });
270
+
271
+ test('themeVariablesIn reads every @theme form and skips Tailwind\'s `initial` (unset) keys', () => {
272
+ const css =
273
+ '@theme inline { --color-card: var(--card); }\n@theme default { --text-sm: .875rem; ' +
274
+ '--default-font-feature-settings: --theme(--font-sans--font-feature-settings, initial); --x: initial; }\n:root { --card: #fff; }';
275
+ expect([...themeVariablesIn(css)].sort()).toEqual(['--color-card', '--text-sm']);
276
+ expect(readThemeVariables().has('--color-card')).toBe(true); // theme.css
277
+ expect(readThemeVariables().has('--ease-out')).toBe(true); // tailwindcss/theme.css, resolved
278
+ expect([...variablesDeclared('@property --tw-x{syntax:"*"}.a{--b:1;color:var(--c)}')].sort()).toEqual(['--b', '--tw-x']);
279
+ });
280
+
281
+ test('the CLI exits 1 on a build that reads an undeclared theme variable, naming it', () => {
282
+ const dir = mkdtempSync(join(tmpdir(), 'belt-theme-var-check-'));
283
+ try {
284
+ mkdirSync(join(dir, 'assets'));
285
+ writeFileSync(join(dir, 'assets', 'index.js'), `const c="truncate";${STATION_JS}`);
286
+ writeFileSync(join(dir, 'assets', 'index.css'), '@layer theme{:root{--color-muted:var(--muted);--color-accent:var(--accent)}}@layer utilities{.truncate{overflow:hidden}}');
287
+ const r = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { stdout: 'pipe', stderr: 'pipe' });
288
+ expect(r.exitCode).toBe(1);
289
+ expect(r.stderr.toString()).toContain('--color-card');
290
+ expect(r.stderr.toString()).not.toContain('--color-muted');
291
+ } finally {
292
+ rmSync(dir, { recursive: true, force: true });
293
+ }
294
+ });
295
+ });
@@ -29,12 +29,20 @@
29
29
  * {@link cascadeFaults}: a base utility after a variant that sets the same property is two sorted
30
30
  * Tailwind passes concatenated, and the base wins at every width. Both halves exit 1.
31
31
  *
32
+ * ── …and whether every THEME variable the build reads is defined (5.3.0) ────────────────
33
+ * {@link undefinedThemeVariables}: a `var(--color-card)` in the built JS or CSS whose `--color-card`
34
+ * no built stylesheet declares. Tailwind prunes theme variables nothing in its pass names, and a
35
+ * component that reads one by name from a style prop is invisible to the class check above — the
36
+ * hole station's frozen DataTable column fell through (F4, 2026-09-23). Exit 1 as well.
37
+ *
32
38
  * Usage, from an app, after its build:
33
39
  * bun node_modules/cursedbelt/scripts/checkAreaStyles.ts dist
34
- * Exit 0 = complete, 1 = a class is unstyled (listed with its area), 2 = could not measure
40
+ * Exit 0 = complete, 1 = a class is unstyled (listed with its area), a base utility follows a
41
+ * variant, or a theme variable read is declared nowhere; 2 = could not measure
35
42
  * (no JS or no CSS under the directory — a check that measured nothing has not passed).
36
43
  */
37
44
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
45
+ import { createRequire } from 'node:module';
38
46
  import { basename, dirname, join, resolve } from 'node:path';
39
47
  import { fileURLToPath } from 'node:url';
40
48
 
@@ -271,6 +279,81 @@ export function cascadeFaults(cssTexts: readonly string[]): CascadeFault[] {
271
279
  return faults;
272
280
  }
273
281
 
282
+ // ── the THEME VARIABLE check (5.3.0) ───────────────────────────────────────────────────
283
+
284
+ /**
285
+ * Every custom property an `@theme` block declares (any `@theme inline`/`static`/… form). These — and
286
+ * only these — are what Tailwind PRUNES: it emits a theme variable into `:root` only when its pass
287
+ * sees it used. Every other custom property is written down by a stylesheet unconditionally or set
288
+ * at runtime (`--radix-*`, sonner's `--offset`, a component's `--cb-frozen-bg`), and an area split
289
+ * cannot lose it — which is why the check is scoped here and needs no allowlist.
290
+ */
291
+ export function themeVariablesIn(css: string): Set<string> {
292
+ const out = new Set<string>();
293
+ const code = css.replace(/\/\*[\s\S]*?\*\//g, '');
294
+ for (const m of code.matchAll(/@theme\b[^{]*\{/g)) {
295
+ let depth = 1;
296
+ let i = (m.index ?? 0) + m[0].length;
297
+ const start = i;
298
+ for (; i < code.length && depth > 0; i++) {
299
+ if (code[i] === '{') depth++;
300
+ else if (code[i] === '}') depth--;
301
+ }
302
+ for (const d of code.slice(start, i).matchAll(/(?:^|[;{\s])(--[\w-]+)\s*:\s*([^;}]*)/g)) {
303
+ // `initial` is Tailwind's "unset" — `--default-font-feature-settings: --theme(--font-sans--…, initial)`
304
+ // is declared only when the app sets the key it names, and preflight reads it with a fallback.
305
+ // Measured on station's union build: the four `--default-*-settings` were the only noise.
306
+ if (/^(?:initial|--theme\([^)]*,\s*initial\))$/.test((d[2] as string).trim())) continue;
307
+ out.add(d[1] as string);
308
+ }
309
+ }
310
+ return out;
311
+ }
312
+
313
+ /** Every custom property a stylesheet DECLARES: `--x:` in any block, or `@property --x`. */
314
+ export function variablesDeclared(css: string): Set<string> {
315
+ const out = new Set<string>();
316
+ const code = css.replace(/\/\*[\s\S]*?\*\//g, '');
317
+ for (const m of code.matchAll(/(?:^|[;{\s])(--[\w-]+)\s*:/g)) out.add(m[1] as string);
318
+ for (const m of code.matchAll(/@property\s+(--[\w-]+)/g)) out.add(m[1] as string);
319
+ return out;
320
+ }
321
+
322
+ /** Every `var(--x…)` a text reads — a JS bundle's style strings or a stylesheet's values. */
323
+ export function variablesReferenced(text: string): Set<string> {
324
+ return new Set([...text.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1] as string));
325
+ }
326
+
327
+ /**
328
+ * 🔴 The THEME variables the built JS or CSS reads with `var()` that no built stylesheet declares.
329
+ * Each one is a value that silently resolves to nothing — `var(--color-card)` as a background is
330
+ * transparent, not an error. A fallback (`var(--x, red)`) does not excuse one: it renders, but not
331
+ * as the theme says, which is the same divergence from the union.
332
+ */
333
+ export function undefinedThemeVariables(input: { js: string[]; css: string[] }, theme: ReadonlySet<string>): string[] {
334
+ const declared = new Set<string>();
335
+ for (const css of input.css) for (const name of variablesDeclared(css)) declared.add(name);
336
+ const read = new Set<string>();
337
+ for (const text of [...input.js, ...input.css]) for (const name of variablesReferenced(text)) read.add(name);
338
+ return [...read].filter((name) => theme.has(name) && !declared.has(name)).sort();
339
+ }
340
+
341
+ /**
342
+ * The theme a cursedbelt consumer compiles against: this package's `theme.css` and
343
+ * `styles-static.css`, and Tailwind's own `theme.css` (resolved from this package, which in an app
344
+ * is the app's copy). 🔴 Throws rather than shrinking the set when Tailwind cannot be found — a
345
+ * check over half the theme would pass on exactly the variables it cannot see.
346
+ */
347
+ export function readThemeVariables(src: string | undefined = SRC): Set<string> {
348
+ if (!src) throw new Error("cursedbelt's src/ is not beside this script — the package is incomplete");
349
+ const tailwindTheme = createRequire(join(src, '..', 'package.json')).resolve('tailwindcss/theme.css');
350
+ const out = new Set<string>();
351
+ for (const file of [join(src, 'theme.css'), join(src, 'styles-static.css'), tailwindTheme]) {
352
+ for (const name of themeVariablesIn(readFileSync(file, 'utf8'))) out.add(name);
353
+ }
354
+ return out;
355
+ }
356
+
274
357
  function filesUnder(dir: string, ext: string, out: string[] = []): string[] {
275
358
  for (const entry of readdirSync(dir)) {
276
359
  if (entry === 'node_modules') continue;
@@ -290,13 +373,15 @@ export interface AreaCheckResult {
290
373
  referenced: number;
291
374
  /** {@link cascadeFaults} over the built CSS — base utilities that override a variant. */
292
375
  outOfOrder: CascadeFault[];
376
+ /** {@link undefinedThemeVariables} — theme variables read by `var()` and declared by no built CSS. */
377
+ undefinedVariables: string[];
293
378
  }
294
379
 
295
380
  /** The measurement, over explicit texts — what the spec drives. */
296
381
  export function checkAreaStyles(
297
382
  input: { js: string[]; css: string[] },
298
383
  belt: { union: string; areas: Record<string, string> },
299
- ): Omit<AreaCheckResult, 'jsFiles' | 'cssFiles' | 'outOfOrder'> {
384
+ ): Omit<AreaCheckResult, 'jsFiles' | 'cssFiles' | 'outOfOrder' | 'undefinedVariables'> {
300
385
  const union = classesInCss(belt.union);
301
386
  const areaOf = new Map<string, string>();
302
387
  for (const [area, css] of Object.entries(belt.areas)) {
@@ -332,7 +417,14 @@ export function checkDist(distDir: string): AreaCheckResult {
332
417
  readBeltSheets(),
333
418
  );
334
419
  const cssTexts = cssFiles.map((f) => readFileSync(f, 'utf8'));
335
- return { ...result, outOfOrder: cascadeFaults(cssTexts), jsFiles: jsFiles.length, cssFiles: cssFiles.length };
420
+ const jsTexts = jsFiles.map((f) => readFileSync(f, 'utf8'));
421
+ return {
422
+ ...result,
423
+ outOfOrder: cascadeFaults(cssTexts),
424
+ undefinedVariables: undefinedThemeVariables({ js: jsTexts, css: cssTexts }, readThemeVariables()),
425
+ jsFiles: jsFiles.length,
426
+ cssFiles: cssFiles.length,
427
+ };
336
428
  }
337
429
 
338
430
  if (import.meta.main) {
@@ -353,11 +445,21 @@ if (import.meta.main) {
353
445
  console.error(' Two sorted Tailwind passes were concatenated — cursedbelt ≤ 5.0.x precompiled areas after the app\'s own.');
354
446
  console.error(' cursedbelt ≥ 5.1.0 areas are candidates for the app\'s ONE pass: upgrade, and rebuild.');
355
447
  }
448
+ if (result.undefinedVariables.length > 0) {
449
+ console.error(
450
+ `✗ ${result.undefinedVariables.length} theme variable(s) the build reads with var() are declared by NO built stylesheet — ` +
451
+ 'each resolves to nothing (a transparent background, an unset size):',
452
+ );
453
+ console.error(` ${result.undefinedVariables.join(' ')}`);
454
+ console.error(' Tailwind emits a theme variable only when its pass sees it used. cursedbelt ≥ 5.3.0 names every one its');
455
+ console.error(' components read in styles-areas/core.css — import core, on ≥ 5.3.0. A variable of the app\'s own: name it');
456
+ console.error(' in a file the app\'s Tailwind pass scans.');
457
+ }
356
458
  if (result.missing.length === 0) {
357
- if (result.outOfOrder.length > 0) process.exit(1);
459
+ if (result.outOfOrder.length > 0 || result.undefinedVariables.length > 0) process.exit(1);
358
460
  console.log(
359
461
  `✓ belt area styles complete: all ${result.referenced} cursedbelt classes the build references are styled, ` +
360
- `and no base utility follows a variant (${result.jsFiles} js, ${result.cssFiles} css).`,
462
+ `no base utility follows a variant, and every theme variable read is declared (${result.jsFiles} js, ${result.cssFiles} css).`,
361
463
  );
362
464
  process.exit(0);
363
465
  }
@@ -84,7 +84,7 @@
84
84
  */
85
85
  import { REPO_ROOT, STATIC_STYLESHEET, readStylesheets } from './generateStaticStyles';
86
86
  import { classesInCss } from './checkAreaStyles';
87
- import { UTILITY_INPUT, compileStylesheet } from './generateUtilityStyles';
87
+ import { UTILITY_INPUT, compileStylesheet, themeReadBlock, themeVariablesReadByName } from './generateUtilityStyles';
88
88
  import {
89
89
  AREA_DIR,
90
90
  AREAS,
@@ -195,7 +195,7 @@ export const inlineCandidates = async (candidates: readonly string[], area: stri
195
195
  // Tailwind escapes a selector the CSS.escape way; a candidate whose escaped form is in the
196
196
  // output is kept even when unescaping would not round-trip it (a `…` from a doc comment did not).
197
197
  // A `--token` candidate is a THEME VARIABLE the modules read (\`var(--text-sm)\` in a style
198
- // prop): Tailwind emits it into \`:root\` only because the scan saw it, so it stays too.
198
+ // prop). It is kept for the proof below, then dropped from what is written — see the return.
199
199
  const kept = writable.filter(
200
200
  (c) => emitted.has(c) || full.includes(`.${cssEscape(c)}`) || (/^--[\w-]+$/.test(c) && full.includes(`${c}:`)),
201
201
  );
@@ -207,7 +207,11 @@ export const inlineCandidates = async (candidates: readonly string[], area: stri
207
207
  'widen the filter rather than lose it.',
208
208
  );
209
209
  }
210
- return kept;
210
+ // …but a `--token` is not WRITTEN: `@source inline` cannot mark a theme variable (tailwindcss
211
+ // 4.3.3 adds inline candidates without the `--` branch scanned ones take — measured), so one
212
+ // here would claim a variable the app never gets. `core` carries every one of them instead,
213
+ // as a `var()` the app's pass does see (`themeReadBlock`, 5.3.0).
214
+ return kept.filter((c) => !/^--[\w-]+$/.test(c));
211
215
  };
212
216
 
213
217
  /** `CSS.escape` for a class name — what Tailwind writes into a selector. */
@@ -291,6 +295,10 @@ export const buildAreaStyles = async (): Promise<BuiltArea[]> => {
291
295
  raw.set(area.name, await candidatesIn(closures.get(area.name) as Set<string>));
292
296
  }
293
297
 
298
+ // 🔴 The theme variables components read BY NAME go in core, whole: an area is candidates, and
299
+ // no candidate can make the app's pass emit one (`themeVariablesReadByName` has the measurement).
300
+ const themeRead = themeReadBlock(await themeVariablesReadByName());
301
+
294
302
  const built: BuiltArea[] = [];
295
303
  for (const area of AREAS) {
296
304
  const closure = closures.get(area.name) as Set<string>;
@@ -311,7 +319,7 @@ export const buildAreaStyles = async (): Promise<BuiltArea[]> => {
311
319
  }
312
320
  built.push({
313
321
  name: area.name,
314
- css: `${banner(area, inline.length, area.name !== CORE)}${inlineSource(inline)}`,
322
+ css: `${banner(area, inline.length, area.name !== CORE)}${inlineSource(inline)}${area.name === CORE ? themeRead : ''}`,
315
323
  candidates: inline,
316
324
  modules: [...closure].map((f) => f.replace(`${REPO_ROOT}/`, '')).sort(),
317
325
  });
@@ -58,6 +58,7 @@
58
58
  import { compile } from '@tailwindcss/node';
59
59
  import { Scanner } from '@tailwindcss/oxide';
60
60
  import { REPO_ROOT, STATIC_STYLESHEET, readStylesheets } from './generateStaticStyles';
61
+ import { allModules, candidatesIn, nonModuleFiles } from './styleAreas';
61
62
 
62
63
  export { REPO_ROOT };
63
64
 
@@ -124,6 +125,78 @@ export const compileStylesheet = (
124
125
  input: string,
125
126
  ): ReturnType<typeof compile> => compile(input, { base: STYLES_BASE, onDependency: noDependency });
126
127
 
128
+ // ── Theme variables the components read BY NAME (5.3.0) ─────────────────────────────────
129
+
130
+ /**
131
+ * The custom property whose value names every theme variable {@link themeVariablesReadByName}
132
+ * returns. Nothing reads it; it exists so the app's Tailwind pass sees a `var()` of each.
133
+ */
134
+ export const THEME_READ_PROPERTY = '--cb-theme-read-by-name';
135
+
136
+ /** Every theme a consumer imports, and nothing scanned — what decides "Tailwind would prune this". */
137
+ const THEME_PROBE_INPUT = '@import "tailwindcss" source(none);\n@import "./styles-static.css";\n@import "./theme.css";\n';
138
+
139
+ /** A collapse tripwire, not a ratchet: 9 on the day it was set (tailwindcss 4.3.3). */
140
+ export const MIN_THEME_READ_BY_NAME = 4;
141
+
142
+ const declaresVariable = (css: string, name: string): boolean =>
143
+ new RegExp(`(^|[\\s;{])${name.replace(/[-]/g, '\\-')}\\s*:`).test(css);
144
+
145
+ /**
146
+ * 🔴 The THEME variables cursedbelt's components read by NAME, which Tailwind emits only when
147
+ * something in the app's own pass names them.
148
+ *
149
+ * `DataTable` sets `--cb-frozen-bg` inline to `var(--color-card)`, `DropIndicator` paints with
150
+ * `var(--color-accent)`, `focusViewer.css` sizes a caption with `var(--text-caption)`. Tailwind v4
151
+ * emits a theme variable into `:root` only when it is USED — by a utility, by a `var()` in CSS it
152
+ * compiles, or by a `--name` token its scanner finds in a source file. The union route scans this
153
+ * package's tree, so it sees the names; an area is class candidates, so an app on the areas had
154
+ * NO `--color-card` and station's frozen DataTable column went transparent (F4, 2026-09-23,
155
+ * `reports/2026-09-23/2026-09-23-f4-station-desk-flix-final-adoption.md`).
156
+ *
157
+ * `@source inline("--color-card")` does NOT fix it — measured on tailwindcss 4.3.3: inline
158
+ * candidates skip the `--` branch the scanner's candidates take, so they mark nothing. A `var()`
159
+ * in CSS the app's pass compiles does, so {@link themeReadBlock} writes one.
160
+ *
161
+ * DERIVED, never listed: every `--token` the scanner finds in a module or hand-written `.css` under
162
+ * `src/react` that a theme-only compile emits when named and omits when not. A new
163
+ * `var(--color-ring)` in a component joins the list at the next `bun run build`;
164
+ * `scripts/checkAreaStyles.ts` is what fails an app build when one is still missing anyway.
165
+ */
166
+ export const themeVariablesReadByName = async (): Promise<string[]> => {
167
+ const files = [...allModules(), ...nonModuleFiles().filter((f) => f.endsWith('.css'))];
168
+ const named = [...(await candidatesIn(files))].filter((c) => /^--[\w-]+$/.test(c)).sort();
169
+ const bare = (await compileStylesheet(THEME_PROBE_INPUT)).build([]);
170
+ const marked = (await compileStylesheet(THEME_PROBE_INPUT)).build(named);
171
+ const pruned = named.filter((v) => declaresVariable(marked, v) && !declaresVariable(bare, v));
172
+ if (pruned.length < MIN_THEME_READ_BY_NAME) {
173
+ throw new Error(
174
+ `only ${pruned.length} theme variable(s) read by name were found (floor ${MIN_THEME_READ_BY_NAME}) — ` +
175
+ `a collapsed scan of ${files.length} file(s), not a design system that stopped reading them.`,
176
+ );
177
+ }
178
+ return pruned;
179
+ };
180
+
181
+ /**
182
+ * The declaration that makes an app's Tailwind pass emit `vars`: one custom property on `:root`
183
+ * whose value `var()`s each of them. The VALUES stay the theme's, so an app that re-themes a
184
+ * variable keeps its own; this names them and defines nothing.
185
+ */
186
+ export const themeReadBlock = (vars: readonly string[]): string => `
187
+ /*
188
+ * Theme variables cursedbelt's components read BY NAME (\`var(--color-card)\` in a style prop).
189
+ * Tailwind emits a theme variable only when the app's own pass sees it used, and a \`var()\` here is
190
+ * that use — \`@source inline\` cannot mark one. ${vars.length} variable(s), derived by
191
+ * scripts/generateUtilityStyles.ts → themeVariablesReadByName(). Nothing reads this property.
192
+ */
193
+ @layer theme {
194
+ :root, :host {
195
+ ${THEME_READ_PROPERTY}: ${vars.map((v) => `var(${v})`).join(' ')};
196
+ }
197
+ }
198
+ `;
199
+
127
200
  /** The banner the artifact carries, so a reader never mistakes it for a source file. */
128
201
  export const BANNER = `/*
129
202
  * cursedbelt component utilities — GENERATED BUILD ARTIFACT. Never hand-edit.
@@ -177,7 +250,9 @@ export const buildUtilityStyles = async (): Promise<{ css: string; candidates: s
177
250
  `${STYLES_BASE}/react exists and is not excluded by a .gitignore.`,
178
251
  );
179
252
  }
180
- return { css: `${BANNER}${compiled.build(candidates)}`, candidates };
253
+ // The theme variables components read by name ride along: the pair's consumer scans nothing
254
+ // of this package either, so it has the areas' hole exactly (see themeVariablesReadByName).
255
+ return { css: `${BANNER}${compiled.build(candidates)}${themeReadBlock(await themeVariablesReadByName())}`, candidates };
181
256
  };
182
257
 
183
258
  /** The artifact's expected contents and what is on disk right now. */
@@ -144,3 +144,15 @@
144
144
  @source inline("w-[var(--control-h-lg)] w-[var(--control-h-md)] w-[var(--control-h-sm)] w-auto w-full w-px white");
145
145
  @source inline("whitespace-nowrap whitespace-pre-wrap wrap wrap-break-word z z-10 z-20 z-30 z-40 z-50");
146
146
  @source inline("z-[var(--z-popover)]");
147
+
148
+ /*
149
+ * Theme variables cursedbelt's components read BY NAME (`var(--color-card)` in a style prop).
150
+ * Tailwind emits a theme variable only when the app's own pass sees it used, and a `var()` here is
151
+ * that use — `@source inline` cannot mark one. 9 variable(s), derived by
152
+ * scripts/generateUtilityStyles.ts → themeVariablesReadByName(). Nothing reads this property.
153
+ */
154
+ @layer theme {
155
+ :root, :host {
156
+ --cb-theme-read-by-name: var(--color-accent) var(--color-border) var(--color-card) var(--color-muted) var(--color-popover) var(--duration-base) var(--ease-out) var(--text-caption) var(--text-sm);
157
+ }
158
+ }
@@ -18,10 +18,10 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 4 candidate(s).
21
+ * 2 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
25
25
  * import closures rather than a hand-written list, is scripts/styleAreas.ts.
26
26
  */
27
- @source inline("--font-sans --text-sm col collapse");
27
+ @source inline("col collapse");
@@ -18,10 +18,10 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 7 candidate(s).
21
+ * 5 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
25
25
  * import closures rather than a hand-written list, is scripts/styleAreas.ts.
26
26
  */
27
- @source inline("--font-sans --text-sm col collapse min-w-16 w w-48");
27
+ @source inline("col collapse min-w-16 w w-48");
@@ -4969,3 +4969,15 @@
4969
4969
  }
4970
4970
  }
4971
4971
  }
4972
+
4973
+ /*
4974
+ * Theme variables cursedbelt's components read BY NAME (`var(--color-card)` in a style prop).
4975
+ * Tailwind emits a theme variable only when the app's own pass sees it used, and a `var()` here is
4976
+ * that use — `@source inline` cannot mark one. 9 variable(s), derived by
4977
+ * scripts/generateUtilityStyles.ts → themeVariablesReadByName(). Nothing reads this property.
4978
+ */
4979
+ @layer theme {
4980
+ :root, :host {
4981
+ --cb-theme-read-by-name: var(--color-accent) var(--color-border) var(--color-card) var(--color-muted) var(--color-popover) var(--duration-base) var(--ease-out) var(--text-caption) var(--text-sm);
4982
+ }
4983
+ }