cursedbelt 5.1.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.
Files changed (38) hide show
  1. package/dist/react/engagement/beacon.d.ts +38 -0
  2. package/dist/react/engagement/beacon.d.ts.map +1 -0
  3. package/dist/react/engagement/beacon.js +62 -0
  4. package/dist/react/engagement/beacon.js.map +1 -0
  5. package/dist/react/engagement/beaconClient.d.ts +119 -0
  6. package/dist/react/engagement/beaconClient.d.ts.map +1 -0
  7. package/dist/react/engagement/beaconClient.js +203 -0
  8. package/dist/react/engagement/beaconClient.js.map +1 -0
  9. package/dist/react/engagement/index.d.ts +3 -0
  10. package/dist/react/engagement/index.d.ts.map +1 -0
  11. package/dist/react/engagement/index.js +5 -0
  12. package/dist/react/engagement/index.js.map +1 -0
  13. package/dist/react/lib/directUpload.d.ts +130 -0
  14. package/dist/react/lib/directUpload.d.ts.map +1 -0
  15. package/dist/react/lib/directUpload.js +228 -0
  16. package/dist/react/lib/directUpload.js.map +1 -0
  17. package/dist/styles-areas/auth.css +2 -2
  18. package/dist/styles-areas/core.css +14 -2
  19. package/dist/styles-areas/spreadsheet.css +2 -2
  20. package/dist/styles-areas/workbook-viewer.css +2 -2
  21. package/dist/styles-utilities.css +12 -0
  22. package/package.json +13 -1
  23. package/scripts/checkAreaStyles.spec.ts +90 -0
  24. package/scripts/checkAreaStyles.ts +127 -6
  25. package/scripts/generateAreaStyles.ts +12 -4
  26. package/scripts/generateUtilityStyles.ts +76 -1
  27. package/scripts/styleAreas.ts +2 -0
  28. package/src/react/engagement/beacon.tsx +63 -0
  29. package/src/react/engagement/beaconClient.spec.ts +138 -0
  30. package/src/react/engagement/beaconClient.ts +228 -0
  31. package/src/react/engagement/index.ts +13 -0
  32. package/src/react/lib/directUpload.spec.ts +128 -0
  33. package/src/react/lib/directUpload.ts +334 -0
  34. package/src/styles-areas/auth.css +2 -2
  35. package/src/styles-areas/core.css +14 -2
  36. package/src/styles-areas/spreadsheet.css +2 -2
  37. package/src/styles-areas/workbook-viewer.css +2 -2
  38. package/src/styles-utilities.css +12 -0
@@ -0,0 +1,228 @@
1
+ /**
2
+ * `cursedbelt/react/upload-direct` — browser-side resumable upload, straight from the file input
3
+ * to binary-server.
4
+ *
5
+ * Lifted 2026-09-23 (cursedbelt 5.2.0, task 056-343's browser half) from `src/kit/directUpload.ts`,
6
+ * identical in collections and family. The server half — minting the session — is
7
+ * `cursedbelt-server/binary-store/upload-session`'s `createUploadSession`.
8
+ *
9
+ * 🔴 THIS FILE RUNS IN A BROWSER. It must never import `node:*`, `binaryStore.ts`, or
10
+ * anything that reaches a private key — see {@link UploadSession}. Keep it dependency-free.
11
+ *
12
+ * ## Why the bytes skip the app server
13
+ *
14
+ * Every satellite's upload route reads the whole body (`formData()` → `arrayBuffer()`) before
15
+ * handing it to binary-server, inside a unit capped at `MemoryMax=300M` on a shared t3.micro
16
+ * that also runs nginx and every other app. That is why the fleet's upload ceiling is 32 MB
17
+ * and why raising the number was never the fix: a 10 GB video does not fit in the process, in
18
+ * the nginx body limit, or in the Cloudflare Tunnel's ~100 MB request wall.
19
+ *
20
+ * So a large file does not go through the app server at all. The app server mints a session
21
+ * token naming exactly one destination key; the browser PUTs bounded parts directly to
22
+ * binary-server, which assembles them; the app server is told afterwards and records the
23
+ * metadata. No byte of video touches the EC2 box, which is the owner's standing rule for
24
+ * video (2026-08-12) and also the only shape that survives the memory limit.
25
+ *
26
+ * ## What makes it survive 10 GB
27
+ *
28
+ * - **Resume.** `GET /upload-chunk` reports which parts already landed, so an upload that
29
+ * dies at 90% resumes instead of restarting. That answer comes from the parts on disk, so
30
+ * it survives a binary-server restart too.
31
+ * - **Bounded concurrency.** A few parts in flight keeps a home upstream saturated without
32
+ * putting the whole file in memory. Only one part's bytes are ever read at a time per slot.
33
+ * - **Per-part retry.** A dropped part costs one part, not the upload.
34
+ * - **Slicing, not reading.** `Blob.slice` is lazy — the 10 GB file is never materialized.
35
+ */
36
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
37
+ /** Split `[0, total)` into `chunkBytes`-sized part indices. */
38
+ export const chunkCount = (totalBytes, chunkBytes) => Math.max(1, Math.ceil(totalBytes / chunkBytes));
39
+ const chunkUrl = (s, token, ci) => `${s.baseUrl}/upload-chunk/${s.path
40
+ .split("/")
41
+ .map(encodeURIComponent)
42
+ .join("/")}?token=${encodeURIComponent(token)}&ci=${ci}`;
43
+ const statusUrl = (s, token) => `${s.baseUrl}/upload-chunk/${s.path
44
+ .split("/")
45
+ .map(encodeURIComponent)
46
+ .join("/")}?token=${encodeURIComponent(token)}`;
47
+ /** Which parts binary-server already holds, plus whether the object is already complete.
48
+ * A fresh session answers `{ received: [], complete: false }`, so callers need no special
49
+ * case for "first attempt". */
50
+ export async function fetchSessionState(session, token = session.token, signal) {
51
+ const res = await fetch(statusUrl(session, token), { signal });
52
+ if (!res.ok)
53
+ return {
54
+ received: new Set(),
55
+ complete: false,
56
+ size: null,
57
+ checksum: null,
58
+ freeBytes: null,
59
+ };
60
+ const body = (await res.json());
61
+ return {
62
+ received: new Set(body.received ?? []),
63
+ complete: Boolean(body.complete),
64
+ size: body.size ?? null,
65
+ checksum: body.checksum ?? null,
66
+ freeBytes: body.freeBytes ?? null,
67
+ };
68
+ }
69
+ /** Thrown when binary-server has no room for the file. Distinct from a transport failure so a
70
+ * UI can say "the server is full" — which is actionable — rather than "upload failed", which
71
+ * invites the person to retry forever against a disk that will never have space. */
72
+ export class InsufficientStorageError extends Error {
73
+ needBytes;
74
+ freeBytes;
75
+ overridable;
76
+ constructor(needBytes, freeBytes,
77
+ /**
78
+ * 🔴 Vestigial since 2026-08-13 and kept only so an older deployed binary-server can
79
+ * still be understood: THIS service no longer refuses an upload for disk space at all,
80
+ * so nothing here should be producing an overridable refusal any more. If you see one,
81
+ * the origin is running a build from before that change.
82
+ */
83
+ overridable = false) {
84
+ super(freeBytes === null
85
+ ? "binary-server is out of space for this upload"
86
+ : `binary-server has ${(freeBytes / 1024 ** 3).toFixed(1)} GB free, which is not enough for a ${(needBytes / 1024 ** 3).toFixed(1)} GB upload`);
87
+ this.needBytes = needBytes;
88
+ this.freeBytes = freeBytes;
89
+ this.overridable = overridable;
90
+ this.name = "InsufficientStorageError";
91
+ }
92
+ }
93
+ /**
94
+ * Upload `file` to binary-server in parts, resuming anything already delivered.
95
+ *
96
+ * Resolves once every part has landed and bs has assembled the object. The caller then tells
97
+ * its own app server to record the metadata — see each app's `/complete` route.
98
+ */
99
+ export async function uploadDirect(file, session, opts = {}) {
100
+ const { onProgress, signal, refreshToken } = opts;
101
+ const concurrency = Math.max(1, opts.concurrency ?? 3);
102
+ const retries = Math.max(1, opts.retries ?? 5);
103
+ const { onLowDisk } = opts;
104
+ const total = session.totalChunks;
105
+ // A token can expire mid-upload on a slow line; `refreshToken` swaps in a new one and
106
+ // every subsequent request picks it up.
107
+ let token = session.token;
108
+ const prior = await fetchSessionState(session, token, signal);
109
+ if (prior.complete) {
110
+ return {
111
+ key: session.path,
112
+ size: prior.size ?? file.size,
113
+ checksum: prior.checksum,
114
+ };
115
+ }
116
+ // Bail before sending anything if the file plainly cannot land. bs keeps a reserve so a
117
+ // full disk never takes the Mac down, and it reports free space on the status call above —
118
+ // so an upload with nowhere to go costs one request instead of gigabytes of transfer.
119
+ const remainingBytes = file.size - prior.received.size * session.chunkBytes;
120
+ /*
121
+ * 🔴 NO PREFLIGHT REFUSAL. Owner instruction, 2026-08-13: *"Remove all blockers for space
122
+ * and only allow warning me. I'll manage space myself beyond that."*
123
+ *
124
+ * This used to throw when the remaining bytes exceeded the reported free space. It is gone
125
+ * rather than softened because a client-side estimate is the WORST place to make this
126
+ * call: `remainingBytes` counts every part not yet delivered, `freeBytes` was read before
127
+ * the transfer began, and neither accounts for what the run itself frees or the owner
128
+ * clears while it goes. binary-server no longer refuses either; the real limit is ENOSPC
129
+ * from the filesystem, which is about the actual write rather than a projection.
130
+ *
131
+ * `onLowDisk` is the replacement, and it only WARNS.
132
+ */
133
+ if (prior.freeBytes !== null && remainingBytes > prior.freeBytes) {
134
+ onLowDisk?.({ freeBytes: prior.freeBytes, needBytes: remainingBytes });
135
+ }
136
+ const pending = Array.from({ length: total }, (_, i) => i).filter((i) => !prior.received.has(i));
137
+ let done = total - pending.length;
138
+ let assembled = null;
139
+ const report = () => onProgress?.(total === 0 ? 1 : done / total, done * session.chunkBytes, file.size);
140
+ report();
141
+ const sendOne = async (ci) => {
142
+ const start = ci * session.chunkBytes;
143
+ // Lazy view onto the file — nothing is read until fetch streams it.
144
+ const part = file.slice(start, Math.min(start + session.chunkBytes, file.size));
145
+ let lastErr;
146
+ for (let attempt = 0; attempt < retries; attempt++) {
147
+ signal?.throwIfAborted();
148
+ try {
149
+ const res = await fetch(chunkUrl(session, token, ci), {
150
+ method: "PUT",
151
+ body: part,
152
+ // bs stores the mime from the assembling request's content-type.
153
+ // 🔴 `x-allow-low-disk` is what turns binary-server's disk RESERVE from a
154
+ // wall into a warning. Owner instruction 2026-08-13, after a large upload
155
+ // stopped dead: *"Do not let the server block me. It can warn me and I can
156
+ // say do it anyway or cancel if I want."* The reserve is sized for the worst
157
+ // case a chunked upload can transiently need, so it refused uploads that
158
+ // would have fit. bs still refuses absolutely below its hard floor, where
159
+ // the machine itself is what is at risk.
160
+ headers: file.type ? { "content-type": file.type } : undefined,
161
+ signal,
162
+ });
163
+ if (res.status === 403 && refreshToken) {
164
+ // Almost always an expired session token on a long upload. Re-mint once per
165
+ // attempt and let the loop retry with it.
166
+ token = await refreshToken();
167
+ throw new Error("token refreshed, retrying");
168
+ }
169
+ // Out of space is terminal — retrying cannot make room, and a 10 GB upload
170
+ // hammering a full disk five times over is the opposite of helpful.
171
+ if (res.status === 507) {
172
+ const body = (await res.json().catch(() => ({})));
173
+ // Terminal — retrying cannot make room, and a 10 GB upload hammering a full
174
+ // disk five times over is the opposite of helpful. 🔴 A CURRENT
175
+ // binary-server never sends this for its own reserve any more (see
176
+ // `overridable`); reaching here means the volume is genuinely out of room
177
+ // and the filesystem said so.
178
+ throw new InsufficientStorageError(file.size, body.freeBytes ?? null, body.overridable === true);
179
+ }
180
+ if (!res.ok)
181
+ throw new Error(`chunk ${ci}: HTTP ${res.status}`);
182
+ const body = (await res.json());
183
+ if (body.assembled) {
184
+ assembled = {
185
+ key: body.key ?? session.path,
186
+ size: body.size ?? file.size,
187
+ checksum: body.checksum ?? null,
188
+ };
189
+ }
190
+ done++;
191
+ report();
192
+ return;
193
+ }
194
+ catch (err) {
195
+ if (signal?.aborted)
196
+ throw err;
197
+ // Terminal: no amount of backoff creates disk space.
198
+ if (err instanceof InsufficientStorageError)
199
+ throw err;
200
+ lastErr = err;
201
+ // Backoff, capped — a home connection that dropped needs seconds, not minutes.
202
+ await sleep(Math.min(1000 * 2 ** attempt, 15_000));
203
+ }
204
+ }
205
+ throw new Error(`chunk ${ci} failed after ${retries} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
206
+ };
207
+ // Bounded worker pool over the pending indices.
208
+ const queue = [...pending];
209
+ const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
210
+ for (;;) {
211
+ const next = queue.shift();
212
+ if (next === undefined)
213
+ return;
214
+ await sendOne(next);
215
+ }
216
+ });
217
+ await Promise.all(workers);
218
+ if (assembled)
219
+ return assembled;
220
+ // Every part reported OK but no response carried the assemble result — possible if the
221
+ // final 201 was the one that got retried after its response was lost. Ask.
222
+ const after = await fetchSessionState(session, token, signal);
223
+ if (after.complete) {
224
+ return { key: session.path, size: after.size ?? file.size, checksum: after.checksum };
225
+ }
226
+ throw new Error(`upload finished but binary-server did not assemble ${session.path} (${after.received.size}/${total} parts)`);
227
+ }
228
+ //# sourceMappingURL=directUpload.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"directUpload.js","sourceRoot":"","sources":["../../../src/react/lib/directUpload.ts"],"names":[],"mappings":"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"}
@@ -18,12 +18,12 @@
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
- * 11 candidate(s).
21
+ * 10 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("caret-transparent com focus-within:border-field-ring focus-within:ring-2");
27
+ @source inline("caret-transparent focus-within:border-field-ring focus-within:ring-2");
28
28
  @source inline("focus-within:ring-field-ring/30 h-12 has-[[aria-invalid=true]]:border-field-error min-h-dvh");
29
29
  @source inline("self-start self-stretch sm:w-11");
@@ -15,7 +15,7 @@
15
15
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
16
16
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
17
17
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
18
- * 705 candidate(s).
18
+ * 706 candidate(s).
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -52,7 +52,7 @@
52
52
  @source inline("border-collapse border-current border-destructive/30 border-destructive/40 border-destructive/50");
53
53
  @source inline("border-field-border border-success/30 border-success/40 border-success/50 border-t");
54
54
  @source inline("border-transparent border-warning/30 border-warning/40 border-warning/50 border-x-4");
55
- @source inline("border-x-transparent bot bottom bottom-0 break break-all c ca cap capitalize co contain");
55
+ @source inline("border-x-transparent bot bottom bottom-0 break break-all c ca cap capitalize co com contain");
56
56
  @source inline("container content contents cursor cursor-col-resize cursor-default cursor-ew-resize");
57
57
  @source inline("cursor-pointer cursor-row-resize d dark dark:bg-card-overlay dark:bg-card-raised");
58
58
  @source inline("dark:bg-gray-800 data data-[disabled]:hover:bg-border data-[disabled]:opacity-50");
@@ -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.1.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.",
@@ -203,6 +203,12 @@
203
203
  "source": "./src/react/components/DropdownMenu.tsx",
204
204
  "import": "./dist/react/components/DropdownMenu.js"
205
205
  },
206
+ "./react/engagement-beacon": {
207
+ "types": "./dist/react/engagement/index.d.ts",
208
+ "bun": "./src/react/engagement/index.ts",
209
+ "source": "./src/react/engagement/index.ts",
210
+ "import": "./dist/react/engagement/index.js"
211
+ },
206
212
  "./react/file-tree": {
207
213
  "types": "./dist/react/file-tree/index.d.ts",
208
214
  "bun": "./src/react/file-tree/index.ts",
@@ -395,6 +401,12 @@
395
401
  "source": "./src/react/components/Switch.tsx",
396
402
  "import": "./dist/react/components/Switch.js"
397
403
  },
404
+ "./react/upload-direct": {
405
+ "types": "./dist/react/lib/directUpload.d.ts",
406
+ "bun": "./src/react/lib/directUpload.ts",
407
+ "source": "./src/react/lib/directUpload.ts",
408
+ "import": "./dist/react/lib/directUpload.js"
409
+ },
398
410
  "./react/workbook-viewer": {
399
411
  "types": "./dist/react/workbook-viewer/index.d.ts",
400
412
  "bun": "./src/react/workbook-viewer/index.ts",
@@ -8,9 +8,14 @@ import {
8
8
  classesInCss,
9
9
  hasVariantPrefix,
10
10
  inlineCandidatesIn,
11
+ onlyAsPropertyName,
11
12
  onlyInMergeTables,
12
13
  readBeltSheets,
14
+ readThemeVariables,
15
+ themeVariablesIn,
13
16
  tokensInJs,
17
+ undefinedThemeVariables,
18
+ variablesDeclared,
14
19
  } from './checkAreaStyles';
15
20
  import { compileStylesheet } from './generateUtilityStyles';
16
21
 
@@ -133,6 +138,28 @@ describe('🔴 tailwind-merge tables are not usage (task 2118)', () => {
133
138
  });
134
139
  });
135
140
 
141
+ describe('🔴 a property NAME is not class usage (task 2131, station)', () => {
142
+ // Verbatim from station's build: d3's scale check, inside recharts.
143
+ const STATION = 'function RE(e){if(e!=null)return"invert"in e&&typeof e.invert=="function"?e.invert:null}';
144
+
145
+ test('an `in` test and an object key are excused', () => {
146
+ expect(onlyAsPropertyName('invert', STATION)).toBe(true);
147
+ expect(onlyAsPropertyName('invert', 'const o={"invert":1}')).toBe(true);
148
+ });
149
+
150
+ test('refused when the token is ALSO a class, or a lone string value (`cn("italic")` IS usage)', () => {
151
+ expect(onlyAsPropertyName('invert', `${STATION};x({className:"invert grayscale"})`)).toBe(false);
152
+ expect(onlyAsPropertyName('italic', 'x=cn("italic",y)')).toBe(false);
153
+ expect(onlyAsPropertyName('h-8', 'const v={size:{sm:"h-8"}}')).toBe(false);
154
+ expect(onlyAsPropertyName('invert', '')).toBe(false);
155
+ });
156
+
157
+ test('the station shape reports no `invert` miss', () => {
158
+ const { missing } = checkAreaStyles({ js: [STATION], css: [''] }, readBeltSheets());
159
+ expect(missing.map((m) => m.className)).not.toContain('invert');
160
+ });
161
+ });
162
+
136
163
  describe('an area is `@source inline` candidates since 5.1.0 (task 2124)', () => {
137
164
  test('inlineCandidatesIn reads both quote kinds and skips markers', () => {
138
165
  const css = `/* banner */\n@source inline("flex sm:hidden peer");\n@source inline('before:content-[""]');\n`;
@@ -203,3 +230,66 @@ describe('🔴 the ORDER check — a base utility after a variant that sets the
203
230
  }
204
231
  });
205
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
+ });