cursedbelt 2.3.0 → 2.5.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/src/styles.css CHANGED
@@ -6,32 +6,54 @@
6
6
  * This `@source` makes Tailwind v4 scan cursedbelt's source files so the utility
7
7
  * classes used by its components are generated in the app's stylesheet.
8
8
  *
9
- * ── Two entry points, and which one an app wants ─────────────────────────────
9
+ * ── Two ways in, and which one an app wants ──────────────────────────────────
10
10
  * `@source "."` is the expensive half: it points Tailwind's JIT at cursedbelt's
11
11
  * ENTIRE source tree, so the app emits the union of the whole design system
12
- * (113 KB / 846 selectors, measured in the previous generation) whether it
13
- * renders three components or eighty. Everything else in this file is CSS the
14
- * components genuinely need and no app can do without.
12
+ * whether it renders three components or eighty, and its own build walks 827
13
+ * files it does not own. Everything else in this file is CSS the components
14
+ * genuinely need and no app can do without.
15
15
  *
16
- * A CSS `@import` takes the whole file, so the split is a second subpath:
16
+ * A CSS `@import` takes the whole file, so the split is two more subpaths:
17
17
  *
18
- * cursedbelt/styles.css this file — the real CSS + the `@source` scan
19
- * cursedbelt/styles-static.css the same file MINUS the `@source` line
18
+ * cursedbelt/styles.css this file — the real CSS + the `@source` scan
19
+ * cursedbelt/styles-static.css the same file MINUS the `@source` line
20
+ * cursedbelt/styles-utilities.css the utilities that `@source` line would have
21
+ * produced, precompiled at cursedbelt's build
20
22
  *
21
- * The static one is for an app that pays for its own scanning — it gets the
22
- * tooltip pointer-events fix, the Progress keyframes, the lightbox bridge, the
23
- * ambient/glass layer and `tw-animate-css`, and emits none of cursedbelt's
24
- * utility classes, so whatever component utilities it needs must come from
25
- * sources IT registers. Do not hand-list cursedbelt's component files to get
26
- * them back: a relative `@source` reaching into this package's source tree is a
27
- * `file:` dependency wearing a stylesheet's clothes, and it is the thing the
28
- * previous generation's 29-line-per-app arrangement is remembered for.
23
+ * The second two are a PAIR and neither is usable alone — `styles-static.css` on
24
+ * its own gets the tooltip pointer-events fix, the Progress keyframes, the
25
+ * lightbox bridge, the ambient/glass layer and `tw-animate-css`, and emits none
26
+ * of cursedbelt's utility classes, i.e. an unstyled app:
29
27
  *
30
- * 🔴 This header is in BOTH files, because one is generated from the other and a
31
- * test holds them byte-identical apart from the `@source` line. Always edit
32
- * `src/styles.css`; never `src/styles-static.css`, which
28
+ * @import "tailwindcss";
29
+ * @import "cursedbelt/styles-static.css";
30
+ * @import "cursedbelt/styles-utilities.css";
31
+ * @import "cursedbelt/theme.css";
32
+ *
33
+ * 🔴 Do NOT hand-list cursedbelt's component files to get the utilities back.
34
+ * Tailwind v4 resolves every `@source` to a DIRECTORY and scans it recursively —
35
+ * a file path and a glob are both silently widened to their parent — and
36
+ * `VirtualList`/`VirtualCardGrid` live at the root of `src/react/`, so the finest
37
+ * grain an app can write is the whole design system. A relative `@source` reaching
38
+ * into this package's source tree is also a `file:` dependency wearing a
39
+ * stylesheet's clothes, and it buys nothing: it is the thing the previous
40
+ * generation's 29-line-per-app arrangement is remembered for, and those 29 lines
41
+ * were never file-level scoping either.
42
+ *
43
+ * The pair is not SMALLER than this file — measured, both routes emit the same
44
+ * rules within 54 bytes. What it buys is a build that scans nothing of another
45
+ * package's source tree, and a `styles-static.css` that finally has a correct
46
+ * use. The measurements, and what would actually shrink a consumer, are in the
47
+ * header of `scripts/generateUtilityStyles.ts`.
48
+ *
49
+ * 🔴 This header is in BOTH of the first two files, because one is generated from
50
+ * the other and a test holds them byte-identical apart from the `@source` line.
51
+ * Always edit `src/styles.css`; never `src/styles-static.css`, which
33
52
  * `scripts/generateStaticStyles.ts` rewrites on every `bun run build` and
34
- * `src/stylesStaticMatches.spec.ts` fails on when it is stale.
53
+ * `src/stylesStaticMatches.spec.ts` fails on when it is stale. Editing this file
54
+ * invalidates `src/styles-utilities.css` too — it is compiled against the static
55
+ * variant, `scripts/generateUtilityStyles.ts` rewrites it in the same build step,
56
+ * and `src/stylesUtilitiesMatches.spec.ts` is what fails when it is stale.
35
57
  */
36
58
 
37
59
  /* Animation utilities (animate-in/out, fade/zoom/slide-in-from-*) consumed by the
@@ -0,0 +1,314 @@
1
+ /**
2
+ * `src/styles-utilities.css` is a current, COMPLETE precompile of `src/react/**`
3
+ * (2026-09-14).
4
+ *
5
+ * ## What this exists to prevent
6
+ *
7
+ * The artifact replaces a `@source` scan the consuming app used to run. The scan
8
+ * was self-healing — it read whatever was in `src/react` at the app's build time —
9
+ * and the artifact is not: it is a file, generated once here, and every app that
10
+ * imports it gets exactly what was checked in.
11
+ *
12
+ * Its failure mode is SILENT and it is the worst one available. A stale artifact
13
+ * is missing the utilities a new component uses, so that component renders
14
+ * unstyled in every app; a collapsed scan writes an empty utility layer, so the
15
+ * WHOLE app renders unstyled. Neither is visible to `tsc` (it is CSS), to Biome
16
+ * (nothing is wrong with the file), or to a byte count read in one direction (a
17
+ * smaller stylesheet is the thing the split was asked for).
18
+ *
19
+ * So the assertions below do not compare size. They compile a consuming app BOTH
20
+ * ways — `@import "cursedbelt/styles.css"` (today, scanning) against
21
+ * `styles-static.css` + `styles-utilities.css` with `source(none)` (after
22
+ * adoption, scanning nothing) — and compare the rules that come out: every
23
+ * selector, every declaration, every custom property the declarations reference.
24
+ * `scripts/generateUtilityStyles.ts` owns the WHY and the measurements; this file
25
+ * is the WHETHER.
26
+ *
27
+ * Two differences between the two routes are real, deliberate and normalised for
28
+ * below rather than papered over — each is asserted to STILL BE the only one of
29
+ * its kind, so the normalisation cannot quietly widen into an amnesty:
30
+ *
31
+ * · `var(--x)` on the scanning route vs `var(--x, <literal>)` on the
32
+ * precompiled one, for tokens that reach the compiler through `@reference`.
33
+ * Same variable, same computed value, plus a fallback.
34
+ * · `@theme inline` alias declarations (`--color-card: var(--card)`) that the
35
+ * scanning route emits into `:root` and nothing ever reads — `inline` means
36
+ * the utility itself carries `var(--card)`, so the alias is bookkeeping. Only
37
+ * custom properties something actually `var()`s are compared.
38
+ *
39
+ * `Bun.file` for every read here, not node:fs — the convention the sibling specs
40
+ * (`src/publishShape.spec.ts`, `src/declaredDepsAreImported.spec.ts`) state and
41
+ * the reason they give: a spec that reads through a mockable builtin can pass
42
+ * against files it never opened. The Tailwind compiler this file drives does use
43
+ * node:fs internally, which is unavoidable and is the point — it is resolving the
44
+ * same `@import`s a consumer's bundler will.
45
+ */
46
+ import { describe, expect, it } from 'bun:test';
47
+ import pkg from '../package.json';
48
+ import {
49
+ CONSUMER_PRECOMPILED,
50
+ CONSUMER_SCANNING,
51
+ MIN_CANDIDATES,
52
+ REPO_ROOT,
53
+ UTILITY_STYLESHEET,
54
+ compileStylesheet,
55
+ readUtilityStylesheet,
56
+ } from '../scripts/generateUtilityStyles';
57
+
58
+ const { expected, onDisk, candidates } = await readUtilityStylesheet();
59
+
60
+ /** The command every staleness failure below is fixed by, as a line the owner can paste. */
61
+ const REGENERATE = `cd ${REPO_ROOT} && bun run styles:utilities`;
62
+
63
+ /**
64
+ * Flatten a stylesheet into `at-rule ‖ … ‖ selector` → the set of declarations in
65
+ * that block, merging blocks that repeat (both routes emit `@keyframes spin`
66
+ * twice; a consumer's browser does not care, and neither should a comparison).
67
+ *
68
+ * A hand-rolled walk rather than a CSS parser because the only input is Tailwind's
69
+ * own output: well-formed, and the one construct that could confuse brace counting
70
+ * — a `{` inside a string or a comment — is skipped explicitly.
71
+ */
72
+ const flatten = (css: string): Map<string, Set<string>> => {
73
+ const out = new Map<string, Set<string>>();
74
+ const stack: string[] = [];
75
+ const open: string[][] = [[]];
76
+ let buf = '';
77
+ const push = (raw: string): void => {
78
+ const decl = raw.trim();
79
+ if (decl) open[open.length - 1]?.push(decl);
80
+ };
81
+ for (let i = 0; i < css.length; i++) {
82
+ const ch = css[i];
83
+ if (ch === '/' && css[i + 1] === '*') {
84
+ i = css.indexOf('*/', i) + 1;
85
+ continue;
86
+ }
87
+ if (ch === '"' || ch === "'") {
88
+ const end = css.indexOf(ch, i + 1);
89
+ buf += css.slice(i, end + 1);
90
+ i = end;
91
+ continue;
92
+ }
93
+ if (ch === '{') {
94
+ stack.push(buf.trim().replace(/\s+/g, ' '));
95
+ buf = '';
96
+ open.push([]);
97
+ continue;
98
+ }
99
+ if (ch === '}') {
100
+ push(buf);
101
+ buf = '';
102
+ const key = stack.join(' ‖ ');
103
+ const set = out.get(key) ?? new Set<string>();
104
+ for (const decl of open.pop() ?? []) set.add(decl);
105
+ out.set(key, set);
106
+ stack.pop();
107
+ continue;
108
+ }
109
+ // At depth 0 a `;` ends a statement at-rule (`@layer a, b;`, `@import …`),
110
+ // which carries no declarations and must not stick to the next selector.
111
+ if (ch === ';') {
112
+ if (stack.length) push(buf);
113
+ buf = '';
114
+ continue;
115
+ }
116
+ buf += ch;
117
+ }
118
+ return out;
119
+ };
120
+
121
+ /** `var(--x, <anything>)` → `var(--x)`, so a fallback reads as the same declaration. */
122
+ const dropFallbacks = (decl: string): string => decl.replace(/var\((\s*--[\w-]+)[^)]*\)/g, 'var($1)');
123
+
124
+ const CUSTOM_PROPERTY = /^(--[\w-]+)\s*:/;
125
+
126
+ /** Every custom property a stylesheet actually reads through `var()`. */
127
+ const referenced = (css: string): Set<string> => {
128
+ const out = new Set<string>();
129
+ for (const m of css.matchAll(/var\(\s*(--[\w-]+)/g)) out.add(m[1] as string);
130
+ return out;
131
+ };
132
+
133
+ /** Every custom property a stylesheet declares, including `@property` registrations. */
134
+ const declared = (css: string): Set<string> => {
135
+ const out = new Set<string>();
136
+ for (const m of css.matchAll(/(?:^|[;{\s])(--[\w-]+)\s*:/g)) out.add(m[1] as string);
137
+ for (const m of css.matchAll(/@property\s+(--[\w-]+)/g)) out.add(m[1] as string);
138
+ return out;
139
+ };
140
+
141
+ // The two routes, compiled from the SAME candidate list (the classes cursedbelt's
142
+ // components use), so neither side is credited with utilities the other was never
143
+ // asked for. The precompiled route is built with no candidates at all: everything
144
+ // it emits came out of the artifact on disk.
145
+ const scanning = (await compileStylesheet(CONSUMER_SCANNING)).build(candidates);
146
+ const precompiled = (await compileStylesheet(CONSUMER_PRECOMPILED)).build([]);
147
+ const before = flatten(scanning);
148
+ const after = flatten(precompiled);
149
+ /** Hoisted: both loops below ask this per declaration, over 160 KB of CSS. */
150
+ const readByScanning = referenced(scanning);
151
+
152
+ describe('styles-utilities.css is a generated, gated precompile of src/react', () => {
153
+ it('is checked in and byte-identical to what the generator produces', () => {
154
+ expect(onDisk, `${UTILITY_STYLESHEET} is missing. It is generated:\n ${REGENERATE}`).not.toBeNull();
155
+ expect(
156
+ onDisk,
157
+ `${UTILITY_STYLESHEET} is stale — src/react (or src/styles.css, which it is compiled\n` +
158
+ 'against) changed and the artifact was not regenerated, so every app importing\n' +
159
+ `\`cursedbelt/styles-utilities.css\` is missing the difference. Run:\n ${REGENERATE}`,
160
+ ).toBe(expected);
161
+ });
162
+
163
+ it('was compiled from a scan that did not collapse', () => {
164
+ // The generator throws below its own floor; this is the assertion that says
165
+ // what the floor is FOR, and it fails here rather than at publish time.
166
+ expect(candidates.length).toBeGreaterThanOrEqual(MIN_CANDIDATES);
167
+ });
168
+
169
+ it('has compiled two real consumer stylesheets to compare', () => {
170
+ // Guards every assertion below: two empty strings agree about everything.
171
+ expect(before.size).toBeGreaterThan(500);
172
+ expect(after.size).toBeGreaterThan(500);
173
+ });
174
+
175
+ it('emits every rule the `@source` scan emits, with the same declarations', () => {
176
+ // The core proof, and the reason this spec does not read a byte count: an
177
+ // app that adopts the pair must RENDER the same. A missing key is a selector
178
+ // that vanished; a missing declaration is a property that stopped applying.
179
+ const lostRules: string[] = [];
180
+ const lostDeclarations: string[] = [];
181
+ for (const [key, decls] of before) {
182
+ const mine = after.get(key);
183
+ if (!mine) {
184
+ lostRules.push(key);
185
+ continue;
186
+ }
187
+ const theirs = new Set([...mine].map(dropFallbacks));
188
+ for (const decl of decls) {
189
+ // `@theme inline` aliases nothing reads — see the header.
190
+ const prop = CUSTOM_PROPERTY.exec(decl)?.[1];
191
+ if (prop && !readByScanning.has(prop)) continue;
192
+ if (!theirs.has(dropFallbacks(decl))) lostDeclarations.push(`${key} → ${decl}`);
193
+ }
194
+ }
195
+ expect(
196
+ lostRules,
197
+ '`styles-static.css` + `styles-utilities.css` emits FEWER rules than\n' +
198
+ '`styles.css` does. Every selector listed here renders unstyled in any app that\n' +
199
+ `adopted the pair. Usually staleness:\n ${REGENERATE}`,
200
+ ).toEqual([]);
201
+ expect(
202
+ lostDeclarations,
203
+ 'the precompiled pair emits these selectors but not these declarations, so the\n' +
204
+ `affected components render PARTLY styled. Usually staleness:\n ${REGENERATE}`,
205
+ ).toEqual([]);
206
+ });
207
+
208
+ it('reads no custom property the files beside it do not declare', async () => {
209
+ // The trap the split creates and the scan never could: Tailwind emits only
210
+ // the theme variables its own build finds a use for, so a utility shipped in
211
+ // a prebuilt file can reference a `--text-sm` the consuming app never had a
212
+ // reason to emit — and `font-size: var(--text-sm)` with no `--text-sm` is an
213
+ // invisible nothing.
214
+ //
215
+ // 🔴 Asked of the FILES, not of a compile. Tailwind v4 back-fills: it tracks
216
+ // the `var()`s in whatever CSS it is handed, so the artifact's whole theme
217
+ // layer can be deleted and a compiled consumer still declares everything
218
+ // (verified by deleting it). That back-fill is real and it is why the rule
219
+ // comparison above passes — but it is Tailwind's promise, not this file's,
220
+ // and a `<link>` tag gets none of it. This asks what the artifact and the
221
+ // stylesheets a consumer imports beside it actually WRITE DOWN.
222
+ const artifact = onDisk ?? '';
223
+ // styles-static.css carries exactly one bare specifier, and its `@utility`
224
+ // definitions and `@property` registrations are where the `--tw-enter-*` /
225
+ // `--tw-animation-*` the artifact reads are declared. Pinned so the coupling
226
+ // cannot rot into a silent pass.
227
+ const animateDir = `${REPO_ROOT}/node_modules/tw-animate-css`;
228
+ expect(
229
+ (pkg as { dependencies: Record<string, string> }).dependencies['tw-animate-css'],
230
+ 'tw-animate-css is what src/styles.css @imports',
231
+ ).toBeDefined();
232
+ // Its own `exports` map, not a guessed filename — a CSS-only package resolves
233
+ // through the `style` condition, which Bun.resolveSync does not follow.
234
+ const animateManifest = (await Bun.file(`${animateDir}/package.json`).json()) as {
235
+ exports: Record<string, { style?: string }>;
236
+ };
237
+ const animateEntry = animateManifest.exports['.']?.style;
238
+ expect(animateEntry, 'tw-animate-css no longer exports a `style` entry').toBeDefined();
239
+ const animateCss = await Bun.file(`${animateDir}/${animateEntry}`).text();
240
+ expect(animateCss.length, 'tw-animate-css moved its stylesheet').toBeGreaterThan(1_000);
241
+ const beside = await Promise.all(
242
+ ['src/theme.css', 'src/styles-static.css'].map((f) => Bun.file(`${REPO_ROOT}/${f}`).text()),
243
+ );
244
+ const provided = new Set([artifact, animateCss, ...beside].flatMap((css) => [...declared(css)]));
245
+ // Differential, not an allowlist: whatever the scanning route leaves to the
246
+ // runtime (`--radix-*`/`--reka-*` from the positioning libraries, `--cb-*`
247
+ // set inline by components) is fine here too, and nothing NEW is.
248
+ const declaredByScanning = declared(scanning);
249
+ const known = [...readByScanning].filter((v) => !declaredByScanning.has(v));
250
+ expect(
251
+ [...referenced(artifact)].filter((v) => !provided.has(v) && !known.includes(v)).sort(),
252
+ `${UTILITY_STYLESHEET} reads these custom properties and neither it, src/theme.css,\n` +
253
+ 'src/styles-static.css nor tw-animate-css declares them. Under the `@source` scan the\n' +
254
+ "consuming app's own build emitted them; a prebuilt file cannot count on that.\n" +
255
+ `Run:\n ${REGENERATE}`,
256
+ ).toEqual([]);
257
+ });
258
+
259
+ it('normalised exactly the two differences its header documents, and no more', () => {
260
+ // The two allowances above are the difference between a proof and a rubber
261
+ // stamp, so they are measured rather than trusted: if either grows, this
262
+ // fails and the header has to be rewritten before the pair ships again.
263
+ const fallbackOnly: string[] = [];
264
+ const aliasOnly: string[] = [];
265
+ const read = referenced(scanning);
266
+ for (const [key, decls] of before) {
267
+ const mine = after.get(key);
268
+ if (!mine) continue;
269
+ for (const decl of decls) {
270
+ if (mine.has(decl)) continue;
271
+ const prop = CUSTOM_PROPERTY.exec(decl)?.[1];
272
+ if (prop && !read.has(prop)) aliasOnly.push(`${key} → ${decl}`);
273
+ else fallbackOnly.push(`${key} → ${decl}`);
274
+ }
275
+ }
276
+ // One: `.animate-progress-indeterminate`, whose `@theme` token reaches the
277
+ // artifact's compile through `@reference "./styles-static.css"`.
278
+ expect(fallbackOnly.map((d) => d.split(' → ')[0])).toEqual([
279
+ '@layer utilities ‖ .animate-progress-indeterminate',
280
+ ]);
281
+ // Four: the `@theme inline` aliases in src/theme.css that no utility reads.
282
+ // A number, not a list, because which four is an implementation detail of
283
+ // Tailwind's theme tree-shaking; a JUMP here means the allowance is covering
284
+ // something it was not written for.
285
+ expect(aliasOnly.length).toBeLessThanOrEqual(8);
286
+ });
287
+
288
+ it('is exported as a subpath that resolves into a directory `files` ships', () => {
289
+ const exports = (pkg as { exports: Record<string, unknown> }).exports;
290
+ expect(exports['./styles-utilities.css']).toBe(`./${UTILITY_STYLESHEET}`);
291
+ // Out of `src/`, beside the two stylesheets it completes — a trio of
292
+ // app-facing stylesheets served from two different directories is a
293
+ // difference nobody can see in CSS.
294
+ expect(exports['./styles-static.css']).toBe('./src/styles-static.css');
295
+ expect((pkg as { files: string[] }).files).toContain('src');
296
+ });
297
+
298
+ it('resolves as `cursedbelt/styles-utilities.css` through the real resolver', () => {
299
+ // The assertions above read the manifest; this one RESOLVES the specifier the
300
+ // way a consumer's bundler does. An export entry pointing at a path that does
301
+ // not exist satisfies every string comparison in this file and fails here.
302
+ expect(Bun.resolveSync(`${pkg.name}/styles-utilities.css`, REPO_ROOT)).toBe(
303
+ `${REPO_ROOT}/${UTILITY_STYLESHEET}`,
304
+ );
305
+ });
306
+
307
+ it('is regenerated by the build, so a stale artifact cannot be published', () => {
308
+ // `prepublishOnly` → `verify` → `build`, so the tarball's copy is always the
309
+ // one this repo's current src/react produces.
310
+ expect((pkg as { scripts: Record<string, string> }).scripts.build).toContain(
311
+ 'scripts/generateUtilityStyles.ts',
312
+ );
313
+ });
314
+ });