cursedbelt 5.2.0 → 5.3.1
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/dist/styles-areas/core.css +12 -0
- package/dist/styles-areas/spreadsheet.css +2 -2
- package/dist/styles-areas/workbook-viewer.css +2 -2
- package/dist/styles-utilities.css +12 -0
- package/package.json +1 -1
- package/scripts/checkAreaStyles.spec.ts +190 -3
- package/scripts/checkAreaStyles.ts +243 -10
- package/scripts/generateAreaStyles.ts +12 -4
- package/scripts/generateUtilityStyles.ts +76 -1
- package/src/react/media/hlsSource.spec.ts +5 -2
- package/src/styles-areas/core.css +12 -0
- package/src/styles-areas/spreadsheet.css +2 -2
- package/src/styles-areas/workbook-viewer.css +2 -2
- package/src/styles-utilities.css +12 -0
|
@@ -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
|
-
*
|
|
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("
|
|
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
|
-
*
|
|
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("
|
|
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,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
5
|
import {
|
|
6
6
|
cascadeFaults,
|
|
7
7
|
checkAreaStyles,
|
|
@@ -11,10 +11,21 @@ import {
|
|
|
11
11
|
onlyAsPropertyName,
|
|
12
12
|
onlyInMergeTables,
|
|
13
13
|
readBeltSheets,
|
|
14
|
+
readThemeVariables,
|
|
15
|
+
scanLeaks,
|
|
16
|
+
themeVariablesIn,
|
|
14
17
|
tokensInJs,
|
|
18
|
+
undefinedThemeVariables,
|
|
19
|
+
variablesDeclared,
|
|
15
20
|
} from './checkAreaStyles';
|
|
16
21
|
import { compileStylesheet } from './generateUtilityStyles';
|
|
17
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The CLI also runs the scan-leak check over its cwd. These fixtures live in a temp dir — which this
|
|
25
|
+
* repo's test preload puts under `.agent.noindex/` — so tell git the temp dir is in NO work tree.
|
|
26
|
+
*/
|
|
27
|
+
const noRepoEnv = (dir: string) => ({ ...process.env, GIT_CEILING_DIRECTORIES: dirname(dir) });
|
|
28
|
+
|
|
18
29
|
const BELT = {
|
|
19
30
|
union: `@layer utilities { .flex { display: flex } .rounded-full { border-radius: 9999px } .hover\\:bg-muted { &:hover { @media (hover: hover) { background: var(--muted) } } } .recharts-tip { opacity: .5; } }`,
|
|
20
31
|
areas: {
|
|
@@ -82,7 +93,7 @@ describe('checkAreaStyles', () => {
|
|
|
82
93
|
const dir = mkdtempSync(join(tmpdir(), 'belt-area-check-'));
|
|
83
94
|
try {
|
|
84
95
|
const run = () =>
|
|
85
|
-
Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { stdout: 'pipe', stderr: 'pipe' });
|
|
96
|
+
Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { cwd: dir, env: noRepoEnv(dir), stdout: 'pipe', stderr: 'pipe' });
|
|
86
97
|
expect(run().exitCode).toBe(2);
|
|
87
98
|
const { union } = readBeltSheets();
|
|
88
99
|
// `truncate` is a core utility in every build of the union.
|
|
@@ -218,7 +229,7 @@ describe('🔴 the ORDER check — a base utility after a variant that sets the
|
|
|
218
229
|
mkdirSync(join(dir, 'assets'));
|
|
219
230
|
writeFileSync(join(dir, 'assets', 'index.js'), 'const c="truncate";');
|
|
220
231
|
writeFileSync(join(dir, 'assets', 'index.css'), '@layer utilities{@media(min-width:40rem){.sm\\:flex{display:flex}}.truncate{overflow:hidden}.hidden{display:none}}');
|
|
221
|
-
const r = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { stdout: 'pipe', stderr: 'pipe' });
|
|
232
|
+
const r = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { cwd: dir, env: noRepoEnv(dir), stdout: 'pipe', stderr: 'pipe' });
|
|
222
233
|
expect(r.exitCode).toBe(1);
|
|
223
234
|
expect(r.stderr.toString()).toContain('.hidden (later) overrides .sm:flex');
|
|
224
235
|
} finally {
|
|
@@ -226,3 +237,179 @@ describe('🔴 the ORDER check — a base utility after a variant that sets the
|
|
|
226
237
|
}
|
|
227
238
|
});
|
|
228
239
|
});
|
|
240
|
+
|
|
241
|
+
describe('🔴 the THEME VARIABLE check — a `var()` of a theme variable no built stylesheet declares (5.3.0)', () => {
|
|
242
|
+
// station's DataTable frozen column and DropIndicator, verbatim in shape from its minified bundle.
|
|
243
|
+
const STATION_JS =
|
|
244
|
+
'style:Je?{"--cb-frozen-bg":ke%2===1?"color-mix(in srgb, var(--color-muted) 50%, var(--color-card))":"var(--color-card)"}:void 0,' +
|
|
245
|
+
'function ni({orientation:e,side:t,color:r}){const n=r??"var(--color-accent)"}';
|
|
246
|
+
const APP = '@import "tailwindcss" source(none);\n@import "./styles-static.css";\n';
|
|
247
|
+
const THEME_READ_BLOCK = /\n\/\*\n \* Theme variables cursedbelt's components read BY NAME[\s\S]*$/;
|
|
248
|
+
|
|
249
|
+
test("station's pre-fix shape — core WITHOUT the theme-read block — is RED on exactly the three it lost", async () => {
|
|
250
|
+
const core = readBeltSheets().areas.core ?? '';
|
|
251
|
+
expect(core).toMatch(THEME_READ_BLOCK); // the block exists, so stripping it below is the 5.2.0 core
|
|
252
|
+
const preFix = (await compileStylesheet(`${APP}${core.replace(THEME_READ_BLOCK, '\n')}\n@import "./theme.css";\n`)).build([]);
|
|
253
|
+
expect(undefinedThemeVariables({ js: [STATION_JS], css: [preFix] }, readThemeVariables())).toEqual([
|
|
254
|
+
'--color-accent',
|
|
255
|
+
'--color-card',
|
|
256
|
+
'--color-muted',
|
|
257
|
+
]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('the fix: the shipped core makes the app\'s ONE pass declare every theme variable a component reads by name', async () => {
|
|
261
|
+
const fixed = (await compileStylesheet(`${APP}@import "./styles-areas/core.css";\n@import "./theme.css";\n`)).build([]);
|
|
262
|
+
expect(undefinedThemeVariables({ js: [STATION_JS], css: [fixed] }, readThemeVariables())).toEqual([]);
|
|
263
|
+
// …and what core names is what the check scopes to — every one a theme variable.
|
|
264
|
+
const block = THEME_READ_BLOCK.exec(readBeltSheets().areas.core ?? '')?.[0] ?? '';
|
|
265
|
+
const named = [...block.matchAll(/var\((--[\w-]+)\)/g)].map((m) => m[1]);
|
|
266
|
+
const theme = readThemeVariables();
|
|
267
|
+
expect(named.filter((n) => !theme.has(n as string))).toEqual([]);
|
|
268
|
+
for (const v of ['--color-card', '--color-muted', '--color-accent', '--text-caption']) expect(named).toContain(v);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('scope: a runtime-set or plain custom property is not a theme variable, and a fallback does not excuse one', () => {
|
|
272
|
+
const theme = new Set(['--color-card']);
|
|
273
|
+
const js = ['"var(--offset-right)" "var(--radix-popper-available-height)" "var(--color-card, white)"'];
|
|
274
|
+
expect(undefinedThemeVariables({ js, css: [''] }, theme)).toEqual(['--color-card']);
|
|
275
|
+
expect(undefinedThemeVariables({ js, css: [':root{--color-card:var(--card)}'] }, theme)).toEqual([]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('themeVariablesIn reads every @theme form and skips Tailwind\'s `initial` (unset) keys', () => {
|
|
279
|
+
const css =
|
|
280
|
+
'@theme inline { --color-card: var(--card); }\n@theme default { --text-sm: .875rem; ' +
|
|
281
|
+
'--default-font-feature-settings: --theme(--font-sans--font-feature-settings, initial); --x: initial; }\n:root { --card: #fff; }';
|
|
282
|
+
expect([...themeVariablesIn(css)].sort()).toEqual(['--color-card', '--text-sm']);
|
|
283
|
+
expect(readThemeVariables().has('--color-card')).toBe(true); // theme.css
|
|
284
|
+
expect(readThemeVariables().has('--ease-out')).toBe(true); // tailwindcss/theme.css, resolved
|
|
285
|
+
expect([...variablesDeclared('@property --tw-x{syntax:"*"}.a{--b:1;color:var(--c)}')].sort()).toEqual(['--b', '--tw-x']);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('the CLI exits 1 on a build that reads an undeclared theme variable, naming it', () => {
|
|
289
|
+
const dir = mkdtempSync(join(tmpdir(), 'belt-theme-var-check-'));
|
|
290
|
+
try {
|
|
291
|
+
mkdirSync(join(dir, 'assets'));
|
|
292
|
+
writeFileSync(join(dir, 'assets', 'index.js'), `const c="truncate";${STATION_JS}`);
|
|
293
|
+
writeFileSync(join(dir, 'assets', 'index.css'), '@layer theme{:root{--color-muted:var(--muted);--color-accent:var(--accent)}}@layer utilities{.truncate{overflow:hidden}}');
|
|
294
|
+
const r = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), dir], { cwd: dir, env: noRepoEnv(dir), stdout: 'pipe', stderr: 'pipe' });
|
|
295
|
+
expect(r.exitCode).toBe(1);
|
|
296
|
+
expect(r.stderr.toString()).toContain('--color-card');
|
|
297
|
+
expect(r.stderr.toString()).not.toContain('--color-muted');
|
|
298
|
+
} finally {
|
|
299
|
+
rmSync(dir, { recursive: true, force: true });
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('🔴 the SCAN-LEAK check — a scanned file ignored only by the global gitignore (5.3.1, family 0a298a5)', () => {
|
|
305
|
+
/** A git repo whose "machine-global" excludes file is a temp file — the real ~/.gitignore_global is never read. */
|
|
306
|
+
function fixture() {
|
|
307
|
+
const base = mkdtempSync(join(tmpdir(), 'belt-scan-leak-'));
|
|
308
|
+
const repo = join(base, 'app');
|
|
309
|
+
mkdirSync(repo);
|
|
310
|
+
const globalIgnore = join(base, 'gitignore_global');
|
|
311
|
+
writeFileSync(globalIgnore, '.DS_Store\n.agent.noindex/\n*.agent.*\nnode_modules\n');
|
|
312
|
+
const gitconfig = join(base, 'gitconfig');
|
|
313
|
+
writeFileSync(gitconfig, `[core]\n\texcludesFile = ${globalIgnore}\n`);
|
|
314
|
+
const env = { ...process.env, GIT_CONFIG_GLOBAL: gitconfig, GIT_CONFIG_NOSYSTEM: '1' };
|
|
315
|
+
expect(Bun.spawnSync(['git', 'init', '-q', repo], { env }).exitCode).toBe(0);
|
|
316
|
+
writeFileSync(join(repo, '.gitignore'), 'dist/\n');
|
|
317
|
+
mkdirSync(join(repo, 'src'));
|
|
318
|
+
writeFileSync(join(repo, 'src', 'App.tsx'), 'export const A = () => <div className="flex" />;');
|
|
319
|
+
// The scratch that made family's "core only" build look complete.
|
|
320
|
+
mkdirSync(join(repo, '.agent.noindex'));
|
|
321
|
+
writeFileSync(join(repo, '.agent.noindex', 'compare-styles.agent.ts'), 'const c = "truncate rounded-full grid-cols-3";');
|
|
322
|
+
writeFileSync(join(repo, 'src', 'probe.agent.tsx'), '<p className="sr-only" />');
|
|
323
|
+
// Globally ignored but nothing an extractor reads: binary, and inside node_modules.
|
|
324
|
+
writeFileSync(join(repo, '.DS_Store'), Buffer.from([0, 0, 0, 1, 66, 117, 100, 49, 0]));
|
|
325
|
+
mkdirSync(join(repo, 'node_modules', 'x'), { recursive: true });
|
|
326
|
+
writeFileSync(join(repo, 'node_modules', 'x', 'index.js'), 'const c = "flex";');
|
|
327
|
+
// Ignored by the repo itself — Tailwind skips it too.
|
|
328
|
+
mkdirSync(join(repo, 'dist'));
|
|
329
|
+
writeFileSync(join(repo, 'dist', 'index.js'), 'const c="truncate";');
|
|
330
|
+
return { base, repo, env, globalIgnore };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
test('RED: each globally-ignored TEXT path is named with the file and pattern that matched', () => {
|
|
334
|
+
const { base, repo, env, globalIgnore } = fixture();
|
|
335
|
+
try {
|
|
336
|
+
const leaks = scanLeaks(repo, env);
|
|
337
|
+
expect(leaks?.map((l) => l.path)).toEqual(['.agent.noindex/', 'src/probe.agent.tsx']);
|
|
338
|
+
// `*.agent.*` (line 3) matches `.agent.noindex/` too, and the LAST match in a file decides.
|
|
339
|
+
expect(leaks?.[0]?.source).toBe(`${globalIgnore}:3`);
|
|
340
|
+
expect(leaks?.[0]?.textFiles).toBe(1);
|
|
341
|
+
expect(leaks?.[1]?.pattern).toBe('*.agent.*');
|
|
342
|
+
// From a SUBDIRECTORY root, only what is under it.
|
|
343
|
+
expect(scanLeaks(join(repo, 'src'), env)?.map((l) => l.path)).toEqual(['probe.agent.tsx']);
|
|
344
|
+
} finally {
|
|
345
|
+
rmSync(base, { recursive: true, force: true });
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("GREEN once the repo's OWN .gitignore names them — the global file still matches, but no longer decides", () => {
|
|
350
|
+
const { base, repo, env } = fixture();
|
|
351
|
+
try {
|
|
352
|
+
writeFileSync(join(repo, '.gitignore'), 'dist/\n.agent.noindex/\n*.agent.*\n');
|
|
353
|
+
expect(scanLeaks(repo, env)).toEqual([]);
|
|
354
|
+
} finally {
|
|
355
|
+
rmSync(base, { recursive: true, force: true });
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('.git/info/exclude is not the repo\'s .gitignore either — RED', () => {
|
|
360
|
+
const { base, repo, env } = fixture();
|
|
361
|
+
try {
|
|
362
|
+
writeFileSync(join(repo, '.gitignore'), 'dist/\n.agent.noindex/\n*.agent.*\n');
|
|
363
|
+
writeFileSync(join(repo, 'notes.md'), 'bg-primary');
|
|
364
|
+
writeFileSync(join(repo, '.git', 'info', 'exclude'), 'notes.md\n');
|
|
365
|
+
expect(scanLeaks(repo, env)?.map((l) => [l.path, l.source])).toEqual([['notes.md', '.git/info/exclude:1']]);
|
|
366
|
+
} finally {
|
|
367
|
+
rmSync(base, { recursive: true, force: true });
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('an app root that is itself git-ignored cannot be measured — it throws (the CLI exits 2)', () => {
|
|
372
|
+
const { base, repo, env } = fixture();
|
|
373
|
+
try {
|
|
374
|
+
expect(() => scanLeaks(join(repo, '.agent.noindex'), env)).toThrow(/itself git-ignored/);
|
|
375
|
+
} finally {
|
|
376
|
+
rmSync(base, { recursive: true, force: true });
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('outside any git work tree there is nothing to disagree about — null, not []', () => {
|
|
381
|
+
const dir = mkdtempSync(join(tmpdir(), 'belt-scan-leak-nogit-'));
|
|
382
|
+
try {
|
|
383
|
+
expect(scanLeaks(dir, noRepoEnv(dir))).toBeNull();
|
|
384
|
+
} finally {
|
|
385
|
+
rmSync(dir, { recursive: true, force: true });
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('the CLI exits 1 on a leak even when every class is styled, and 0 once the repo ignores it', () => {
|
|
390
|
+
const { base, repo, env } = fixture();
|
|
391
|
+
try {
|
|
392
|
+
writeFileSync(join(repo, 'dist', 'index.css'), '.truncate{overflow:hidden}');
|
|
393
|
+
const run = () =>
|
|
394
|
+
Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), 'dist'], { cwd: repo, env, stdout: 'pipe', stderr: 'pipe' });
|
|
395
|
+
const red = run();
|
|
396
|
+
expect(red.exitCode).toBe(1);
|
|
397
|
+
expect(red.stderr.toString()).toContain('.agent.noindex/');
|
|
398
|
+
expect(red.stderr.toString()).toContain('src/probe.agent.tsx');
|
|
399
|
+
// --root reads the same repo from anywhere.
|
|
400
|
+
const elsewhere = Bun.spawnSync(['bun', join(import.meta.dir, 'checkAreaStyles.ts'), join(repo, 'dist'), '--root', repo], {
|
|
401
|
+
cwd: base,
|
|
402
|
+
env,
|
|
403
|
+
stdout: 'pipe',
|
|
404
|
+
stderr: 'pipe',
|
|
405
|
+
});
|
|
406
|
+
expect(elsewhere.exitCode).toBe(1);
|
|
407
|
+
writeFileSync(join(repo, '.gitignore'), 'dist/\n.agent.noindex/\n*.agent.*\n');
|
|
408
|
+
const green = run();
|
|
409
|
+
expect(green.stderr.toString()).toBe('');
|
|
410
|
+
expect(green.exitCode).toBe(0);
|
|
411
|
+
} finally {
|
|
412
|
+
rmSync(base, { recursive: true, force: true });
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
});
|
|
@@ -29,13 +29,33 @@
|
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
+
*
|
|
38
|
+
* ── …and whether the build could have SEEN files the repo does not own (5.3.1) ───────────
|
|
39
|
+
* {@link scanLeaks}: Tailwind v4's automatic source detection honours the repo's OWN `.gitignore`
|
|
40
|
+
* files but NOT the machine-global `core.excludesFile`. So agent scratch — `.agent.noindex/`,
|
|
41
|
+
* `*.agent.*`, ignored only globally — is scanned, adds its classes to the build, and every check
|
|
42
|
+
* above passes on a stylesheet a clean checkout cannot reproduce. Measured in apps/family
|
|
43
|
+
* (0a298a5, 2026-09-23): its first "core only" adoption was green, and a clean build was 65
|
|
44
|
+
* cursedbelt classes short. Any text file under the app root that git ignores through something
|
|
45
|
+
* other than a repo `.gitignore` (`git check-ignore -v` names the file that matched) is exit 1;
|
|
46
|
+
* the fix is the line in the repo's own `.gitignore`, which Tailwind does read.
|
|
47
|
+
*
|
|
48
|
+
* Usage, from an app ROOT (the directory Tailwind scans), after its build:
|
|
49
|
+
* bun node_modules/cursedbelt/scripts/checkAreaStyles.ts dist [--root <app root, default cwd>]
|
|
50
|
+
* Exit 0 = complete, 1 = a class is unstyled (listed with its area), a base utility follows a
|
|
51
|
+
* variant, a theme variable read is declared nowhere, or a scanned file is ignored only
|
|
52
|
+
* outside the repo's own `.gitignore`; 2 = could not measure
|
|
35
53
|
* (no JS or no CSS under the directory — a check that measured nothing has not passed).
|
|
36
54
|
*/
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
55
|
+
import { spawnSync } from 'node:child_process';
|
|
56
|
+
import { closeSync, existsSync, lstatSync, openSync, readdirSync, readFileSync, readSync, statSync } from 'node:fs';
|
|
57
|
+
import { createRequire } from 'node:module';
|
|
58
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
39
59
|
import { fileURLToPath } from 'node:url';
|
|
40
60
|
|
|
41
61
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -271,6 +291,180 @@ export function cascadeFaults(cssTexts: readonly string[]): CascadeFault[] {
|
|
|
271
291
|
return faults;
|
|
272
292
|
}
|
|
273
293
|
|
|
294
|
+
// ── the THEME VARIABLE check (5.3.0) ───────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Every custom property an `@theme` block declares (any `@theme inline`/`static`/… form). These — and
|
|
298
|
+
* only these — are what Tailwind PRUNES: it emits a theme variable into `:root` only when its pass
|
|
299
|
+
* sees it used. Every other custom property is written down by a stylesheet unconditionally or set
|
|
300
|
+
* at runtime (`--radix-*`, sonner's `--offset`, a component's `--cb-frozen-bg`), and an area split
|
|
301
|
+
* cannot lose it — which is why the check is scoped here and needs no allowlist.
|
|
302
|
+
*/
|
|
303
|
+
export function themeVariablesIn(css: string): Set<string> {
|
|
304
|
+
const out = new Set<string>();
|
|
305
|
+
const code = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
306
|
+
for (const m of code.matchAll(/@theme\b[^{]*\{/g)) {
|
|
307
|
+
let depth = 1;
|
|
308
|
+
let i = (m.index ?? 0) + m[0].length;
|
|
309
|
+
const start = i;
|
|
310
|
+
for (; i < code.length && depth > 0; i++) {
|
|
311
|
+
if (code[i] === '{') depth++;
|
|
312
|
+
else if (code[i] === '}') depth--;
|
|
313
|
+
}
|
|
314
|
+
for (const d of code.slice(start, i).matchAll(/(?:^|[;{\s])(--[\w-]+)\s*:\s*([^;}]*)/g)) {
|
|
315
|
+
// `initial` is Tailwind's "unset" — `--default-font-feature-settings: --theme(--font-sans--…, initial)`
|
|
316
|
+
// is declared only when the app sets the key it names, and preflight reads it with a fallback.
|
|
317
|
+
// Measured on station's union build: the four `--default-*-settings` were the only noise.
|
|
318
|
+
if (/^(?:initial|--theme\([^)]*,\s*initial\))$/.test((d[2] as string).trim())) continue;
|
|
319
|
+
out.add(d[1] as string);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Every custom property a stylesheet DECLARES: `--x:` in any block, or `@property --x`. */
|
|
326
|
+
export function variablesDeclared(css: string): Set<string> {
|
|
327
|
+
const out = new Set<string>();
|
|
328
|
+
const code = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
329
|
+
for (const m of code.matchAll(/(?:^|[;{\s])(--[\w-]+)\s*:/g)) out.add(m[1] as string);
|
|
330
|
+
for (const m of code.matchAll(/@property\s+(--[\w-]+)/g)) out.add(m[1] as string);
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Every `var(--x…)` a text reads — a JS bundle's style strings or a stylesheet's values. */
|
|
335
|
+
export function variablesReferenced(text: string): Set<string> {
|
|
336
|
+
return new Set([...text.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1] as string));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 🔴 The THEME variables the built JS or CSS reads with `var()` that no built stylesheet declares.
|
|
341
|
+
* Each one is a value that silently resolves to nothing — `var(--color-card)` as a background is
|
|
342
|
+
* transparent, not an error. A fallback (`var(--x, red)`) does not excuse one: it renders, but not
|
|
343
|
+
* as the theme says, which is the same divergence from the union.
|
|
344
|
+
*/
|
|
345
|
+
export function undefinedThemeVariables(input: { js: string[]; css: string[] }, theme: ReadonlySet<string>): string[] {
|
|
346
|
+
const declared = new Set<string>();
|
|
347
|
+
for (const css of input.css) for (const name of variablesDeclared(css)) declared.add(name);
|
|
348
|
+
const read = new Set<string>();
|
|
349
|
+
for (const text of [...input.js, ...input.css]) for (const name of variablesReferenced(text)) read.add(name);
|
|
350
|
+
return [...read].filter((name) => theme.has(name) && !declared.has(name)).sort();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The theme a cursedbelt consumer compiles against: this package's `theme.css` and
|
|
355
|
+
* `styles-static.css`, and Tailwind's own `theme.css` (resolved from this package, which in an app
|
|
356
|
+
* is the app's copy). 🔴 Throws rather than shrinking the set when Tailwind cannot be found — a
|
|
357
|
+
* check over half the theme would pass on exactly the variables it cannot see.
|
|
358
|
+
*/
|
|
359
|
+
export function readThemeVariables(src: string | undefined = SRC): Set<string> {
|
|
360
|
+
if (!src) throw new Error("cursedbelt's src/ is not beside this script — the package is incomplete");
|
|
361
|
+
const tailwindTheme = createRequire(join(src, '..', 'package.json')).resolve('tailwindcss/theme.css');
|
|
362
|
+
const out = new Set<string>();
|
|
363
|
+
for (const file of [join(src, 'theme.css'), join(src, 'styles-static.css'), tailwindTheme]) {
|
|
364
|
+
for (const name of themeVariablesIn(readFileSync(file, 'utf8'))) out.add(name);
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── the SCAN-LEAK check (5.3.1) ───────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
export interface ScanLeak {
|
|
372
|
+
/** The ignored path, relative to the app root (`.agent.noindex/`, `notes.agent.md`). */
|
|
373
|
+
path: string;
|
|
374
|
+
/** The ignore file that matched — never a repo `.gitignore` — and its line and pattern. */
|
|
375
|
+
source: string;
|
|
376
|
+
pattern: string;
|
|
377
|
+
/** How many TEXT files it holds (what Tailwind's extractor reads). */
|
|
378
|
+
textFiles: number;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Tailwind never scans these; a leak inside them cannot reach the build. */
|
|
382
|
+
const NEVER_SCANNED = new Set(['node_modules', '.git']);
|
|
383
|
+
|
|
384
|
+
/** Text = no NUL byte in the first 8 KB — the test git and Tailwind's extractor both effectively apply. */
|
|
385
|
+
function isTextFile(file: string): boolean {
|
|
386
|
+
const fd = openSync(file, 'r');
|
|
387
|
+
try {
|
|
388
|
+
const buf = Buffer.alloc(8192);
|
|
389
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
390
|
+
return !buf.subarray(0, n).includes(0);
|
|
391
|
+
} finally {
|
|
392
|
+
closeSync(fd);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Text files at or under `path`, not following symlinks, skipping node_modules/.git; stops counting at `cap`. */
|
|
397
|
+
function countTextFiles(path: string, cap = 1000): number {
|
|
398
|
+
let st: ReturnType<typeof lstatSync>;
|
|
399
|
+
try {
|
|
400
|
+
st = lstatSync(path);
|
|
401
|
+
} catch {
|
|
402
|
+
return 0;
|
|
403
|
+
}
|
|
404
|
+
if (st.isSymbolicLink()) return 0;
|
|
405
|
+
if (st.isFile()) return st.size > 0 && isTextFile(path) ? 1 : 0;
|
|
406
|
+
if (!st.isDirectory()) return 0;
|
|
407
|
+
let n = 0;
|
|
408
|
+
for (const entry of readdirSync(path)) {
|
|
409
|
+
if (NEVER_SCANNED.has(entry)) continue;
|
|
410
|
+
n += countTextFiles(join(path, entry), cap - n);
|
|
411
|
+
if (n >= cap) break;
|
|
412
|
+
}
|
|
413
|
+
return n;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function git(root: string, args: string[], env: NodeJS.ProcessEnv | undefined, input?: string) {
|
|
417
|
+
return spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', input, env: env ?? process.env, maxBuffer: 64 * 1024 * 1024 });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* 🔴 Every path under `root` that exists, would be scanned (a text file, or a directory holding
|
|
422
|
+
* one, outside node_modules/.git), and is ignored by git ONLY through something that is not one of
|
|
423
|
+
* the repo's own `.gitignore` files — `core.excludesFile` (the machine-global one) or
|
|
424
|
+
* `.git/info/exclude`. Tailwind v4 reads neither, so each is a source of classes a clean checkout
|
|
425
|
+
* does not have. `git check-ignore -v` reports the DECIDING pattern, and a repo `.gitignore` outranks
|
|
426
|
+
* both, so a path the repo ignores itself is never reported however the global file also matches.
|
|
427
|
+
*
|
|
428
|
+
* Returns `null` when `root` is not inside a git work tree: then no gitignore of any kind applies, to
|
|
429
|
+
* git or to Tailwind, and there is nothing for the two to disagree about. `env` is for tests (a
|
|
430
|
+
* hermetic `GIT_CONFIG_GLOBAL`).
|
|
431
|
+
*/
|
|
432
|
+
export function scanLeaks(root: string, env?: NodeJS.ProcessEnv): ScanLeak[] | null {
|
|
433
|
+
const top = git(root, ['rev-parse', '--show-toplevel'], env);
|
|
434
|
+
if (top.status !== 0) return null;
|
|
435
|
+
const topLevel = top.stdout.trim();
|
|
436
|
+
const rootRel = relative(topLevel, resolve(root));
|
|
437
|
+
if (rootRel && git(root, ['check-ignore', '-q', '--', `${resolve(root)}/`], env).status === 0) {
|
|
438
|
+
// git cannot list "the ignored files under an ignored directory", and which of them Tailwind reads
|
|
439
|
+
// is then a question about ITS walker. An app root is never inside an ignored directory; refuse.
|
|
440
|
+
throw new Error(`${root} is itself git-ignored in ${topLevel} — run from the app's root`);
|
|
441
|
+
}
|
|
442
|
+
const listed = git(root, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z', '--', '.'], env);
|
|
443
|
+
if (listed.status !== 0) throw new Error(`git ls-files failed in ${root}: ${listed.stderr.trim()}`);
|
|
444
|
+
const paths = listed.stdout
|
|
445
|
+
.split('\0')
|
|
446
|
+
.filter((p) => p.length > 0)
|
|
447
|
+
.filter((p) => !p.split('/').some((seg) => NEVER_SCANNED.has(seg)));
|
|
448
|
+
if (paths.length === 0) return [];
|
|
449
|
+
const matched = git(root, ['check-ignore', '-v', '-z', '--stdin'], env, `${paths.join('\0')}\0`);
|
|
450
|
+
// Exit 1 = none of them ignored (cannot happen for ls-files -i output, but is not an error).
|
|
451
|
+
if (matched.status !== 0 && matched.status !== 1) throw new Error(`git check-ignore failed in ${root}: ${matched.stderr.trim()}`);
|
|
452
|
+
const fields = matched.stdout.split('\0');
|
|
453
|
+
const leaks: ScanLeak[] = [];
|
|
454
|
+
for (let i = 0; i + 3 < fields.length; i += 4) {
|
|
455
|
+
const [source, line, pattern, path] = fields.slice(i, i + 4) as [string, string, string, string];
|
|
456
|
+
if (!source) continue;
|
|
457
|
+
const abs = isAbsolute(source) ? source : resolve(root, source);
|
|
458
|
+
const inRepo = !relative(topLevel, abs).startsWith('..') && !isAbsolute(relative(topLevel, abs));
|
|
459
|
+
const inGitDir = relative(topLevel, abs).split(sep)[0] === '.git';
|
|
460
|
+
if (basename(abs) === '.gitignore' && inRepo && !inGitDir) continue; // the repo's own — Tailwind reads it
|
|
461
|
+
const textFiles = countTextFiles(join(root, path));
|
|
462
|
+
if (textFiles === 0) continue; // .DS_Store, a screenshot: nothing an extractor could read
|
|
463
|
+
leaks.push({ path, source: `${source}:${line}`, pattern, textFiles });
|
|
464
|
+
}
|
|
465
|
+
return leaks.sort((a, b) => a.path.localeCompare(b.path));
|
|
466
|
+
}
|
|
467
|
+
|
|
274
468
|
function filesUnder(dir: string, ext: string, out: string[] = []): string[] {
|
|
275
469
|
for (const entry of readdirSync(dir)) {
|
|
276
470
|
if (entry === 'node_modules') continue;
|
|
@@ -290,13 +484,15 @@ export interface AreaCheckResult {
|
|
|
290
484
|
referenced: number;
|
|
291
485
|
/** {@link cascadeFaults} over the built CSS — base utilities that override a variant. */
|
|
292
486
|
outOfOrder: CascadeFault[];
|
|
487
|
+
/** {@link undefinedThemeVariables} — theme variables read by `var()` and declared by no built CSS. */
|
|
488
|
+
undefinedVariables: string[];
|
|
293
489
|
}
|
|
294
490
|
|
|
295
491
|
/** The measurement, over explicit texts — what the spec drives. */
|
|
296
492
|
export function checkAreaStyles(
|
|
297
493
|
input: { js: string[]; css: string[] },
|
|
298
494
|
belt: { union: string; areas: Record<string, string> },
|
|
299
|
-
): Omit<AreaCheckResult, 'jsFiles' | 'cssFiles' | 'outOfOrder'> {
|
|
495
|
+
): Omit<AreaCheckResult, 'jsFiles' | 'cssFiles' | 'outOfOrder' | 'undefinedVariables'> {
|
|
300
496
|
const union = classesInCss(belt.union);
|
|
301
497
|
const areaOf = new Map<string, string>();
|
|
302
498
|
for (const [area, css] of Object.entries(belt.areas)) {
|
|
@@ -332,11 +528,22 @@ export function checkDist(distDir: string): AreaCheckResult {
|
|
|
332
528
|
readBeltSheets(),
|
|
333
529
|
);
|
|
334
530
|
const cssTexts = cssFiles.map((f) => readFileSync(f, 'utf8'));
|
|
335
|
-
|
|
531
|
+
const jsTexts = jsFiles.map((f) => readFileSync(f, 'utf8'));
|
|
532
|
+
return {
|
|
533
|
+
...result,
|
|
534
|
+
outOfOrder: cascadeFaults(cssTexts),
|
|
535
|
+
undefinedVariables: undefinedThemeVariables({ js: jsTexts, css: cssTexts }, readThemeVariables()),
|
|
536
|
+
jsFiles: jsFiles.length,
|
|
537
|
+
cssFiles: cssFiles.length,
|
|
538
|
+
};
|
|
336
539
|
}
|
|
337
540
|
|
|
338
541
|
if (import.meta.main) {
|
|
339
|
-
const
|
|
542
|
+
const args = process.argv.slice(2);
|
|
543
|
+
const rootAt = args.indexOf('--root');
|
|
544
|
+
const root = resolve(rootAt >= 0 ? (args[rootAt + 1] ?? '.') : '.');
|
|
545
|
+
if (rootAt >= 0) args.splice(rootAt, 2);
|
|
546
|
+
const dir = resolve(args[0] ?? 'dist');
|
|
340
547
|
if (!existsSync(dir)) {
|
|
341
548
|
console.error(`✗ ${dir} does not exist — build the app first. A check that measured nothing has not passed.`);
|
|
342
549
|
process.exit(2);
|
|
@@ -346,6 +553,22 @@ if (import.meta.main) {
|
|
|
346
553
|
console.error(`✗ ${dir} holds ${result.jsFiles} .js and ${result.cssFiles} .css file(s) — nothing to compare.`);
|
|
347
554
|
process.exit(2);
|
|
348
555
|
}
|
|
556
|
+
let leaks: ScanLeak[];
|
|
557
|
+
try {
|
|
558
|
+
leaks = scanLeaks(root) ?? [];
|
|
559
|
+
} catch (e) {
|
|
560
|
+
console.error(`✗ could not tell which files Tailwind scanned: ${(e as Error).message}`);
|
|
561
|
+
process.exit(2);
|
|
562
|
+
}
|
|
563
|
+
if (leaks.length > 0) {
|
|
564
|
+
console.error(
|
|
565
|
+
`✗ ${leaks.length} path(s) under ${root} are ignored by git only OUTSIDE the repo's own .gitignore — Tailwind v4 scans them anyway,`,
|
|
566
|
+
);
|
|
567
|
+
console.error(' so their classes are in this build and a clean checkout\'s build would be short of them (apps/family 0a298a5: 65 classes):');
|
|
568
|
+
for (const l of leaks.slice(0, 12)) console.error(` ${l.path} (${l.textFiles} text file(s); ${l.source} \`${l.pattern}\`)`);
|
|
569
|
+
if (leaks.length > 12) console.error(` … +${leaks.length - 12} more`);
|
|
570
|
+
console.error(" Add each pattern to the repo's own .gitignore (e.g. `.agent.noindex/` and `*.agent.*`), rebuild, and check again.");
|
|
571
|
+
}
|
|
349
572
|
if (result.outOfOrder.length > 0) {
|
|
350
573
|
console.error(`✗ ${result.outOfOrder.length} base utilit(y|ies) come AFTER a variant that sets the same property, so the base wins at every width:`);
|
|
351
574
|
for (const f of result.outOfOrder.slice(0, 12)) console.error(` .${f.base} (later) overrides .${f.variant} — ${f.properties.join(', ')}`);
|
|
@@ -353,11 +576,21 @@ if (import.meta.main) {
|
|
|
353
576
|
console.error(' Two sorted Tailwind passes were concatenated — cursedbelt ≤ 5.0.x precompiled areas after the app\'s own.');
|
|
354
577
|
console.error(' cursedbelt ≥ 5.1.0 areas are candidates for the app\'s ONE pass: upgrade, and rebuild.');
|
|
355
578
|
}
|
|
579
|
+
if (result.undefinedVariables.length > 0) {
|
|
580
|
+
console.error(
|
|
581
|
+
`✗ ${result.undefinedVariables.length} theme variable(s) the build reads with var() are declared by NO built stylesheet — ` +
|
|
582
|
+
'each resolves to nothing (a transparent background, an unset size):',
|
|
583
|
+
);
|
|
584
|
+
console.error(` ${result.undefinedVariables.join(' ')}`);
|
|
585
|
+
console.error(' Tailwind emits a theme variable only when its pass sees it used. cursedbelt ≥ 5.3.0 names every one its');
|
|
586
|
+
console.error(' components read in styles-areas/core.css — import core, on ≥ 5.3.0. A variable of the app\'s own: name it');
|
|
587
|
+
console.error(' in a file the app\'s Tailwind pass scans.');
|
|
588
|
+
}
|
|
356
589
|
if (result.missing.length === 0) {
|
|
357
|
-
if (result.outOfOrder.length > 0) process.exit(1);
|
|
590
|
+
if (result.outOfOrder.length > 0 || result.undefinedVariables.length > 0 || leaks.length > 0) process.exit(1);
|
|
358
591
|
console.log(
|
|
359
592
|
`✓ belt area styles complete: all ${result.referenced} cursedbelt classes the build references are styled, ` +
|
|
360
|
-
`
|
|
593
|
+
`no base utility follows a variant, every theme variable read is declared, and nothing scanned is ignored only globally (${result.jsFiles} js, ${result.cssFiles} css).`,
|
|
361
594
|
);
|
|
362
595
|
process.exit(0);
|
|
363
596
|
}
|
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|
|
@@ -58,8 +58,11 @@ describe('attachHlsSource — every road ends at something playable', () => {
|
|
|
58
58
|
// master it cannot parse is a permanent spinner, which is worse than the slow copy.
|
|
59
59
|
const el = videoEl(false);
|
|
60
60
|
const detach = attachHlsSource(el, { hlsUrl: LADDER, streamUrl: REMUX });
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// Poll, not one tick: the fallback lands after the dynamic `import('hls.js')` settles, and on a
|
|
62
|
+
// cold module cache (publish's `gate --force`, 2026-09-23) that took longer than one macrotask.
|
|
63
|
+
for (const deadline = Date.now() + 5000; el.src === '' && Date.now() < deadline; ) {
|
|
64
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
65
|
+
}
|
|
63
66
|
expect(el.src).toBe(REMUX);
|
|
64
67
|
detach();
|
|
65
68
|
});
|
|
@@ -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
|
-
*
|
|
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("
|
|
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
|
-
*
|
|
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("
|
|
27
|
+
@source inline("col collapse min-w-16 w w-48");
|
package/src/styles-utilities.css
CHANGED
|
@@ -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
|
+
}
|