cursedbelt 5.0.1 → 5.2.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 (137) 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/react/media/FocusViewer.d.ts.map +1 -1
  18. package/dist/react/media/FocusViewer.js.map +1 -1
  19. package/dist/react/media/MediaThumb.d.ts.map +1 -1
  20. package/dist/react/media/MediaThumb.js.map +1 -1
  21. package/dist/react/media/VideoTurner.d.ts.map +1 -1
  22. package/dist/react/media/VideoTurner.js.map +1 -1
  23. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  24. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  25. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  26. package/dist/styles-areas/activity-bar.css +8 -11
  27. package/dist/styles-areas/analytics.css +30 -653
  28. package/dist/styles-areas/auth.css +10 -135
  29. package/dist/styles-areas/calendar.css +22 -397
  30. package/dist/styles-areas/chart-table.css +8 -20
  31. package/dist/styles-areas/charts.css +9 -70
  32. package/dist/styles-areas/chat.css +17 -345
  33. package/dist/styles-areas/clipboard.css +7 -7
  34. package/dist/styles-areas/code-editor.css +8 -111
  35. package/dist/styles-areas/companion-link.css +8 -51
  36. package/dist/styles-areas/core.css +131 -2488
  37. package/dist/styles-areas/dashboard-grid.css +10 -65
  38. package/dist/styles-areas/data-table.css +30 -638
  39. package/dist/styles-areas/deep-link.css +7 -7
  40. package/dist/styles-areas/diff-viewer.css +8 -11
  41. package/dist/styles-areas/disk-usage.css +13 -243
  42. package/dist/styles-areas/emoji.css +9 -135
  43. package/dist/styles-areas/fields.css +32 -563
  44. package/dist/styles-areas/file-tree.css +8 -14
  45. package/dist/styles-areas/filter-rail.css +14 -216
  46. package/dist/styles-areas/folder-tree.css +12 -200
  47. package/dist/styles-areas/keep-awake.css +7 -7
  48. package/dist/styles-areas/layout-engine.css +12 -220
  49. package/dist/styles-areas/markdown.css +9 -68
  50. package/dist/styles-areas/master-detail.css +14 -220
  51. package/dist/styles-areas/master-lock.css +9 -113
  52. package/dist/styles-areas/media-gallery.css +15 -332
  53. package/dist/styles-areas/media.css +20 -623
  54. package/dist/styles-areas/nav.css +24 -469
  55. package/dist/styles-areas/notifications.css +12 -226
  56. package/dist/styles-areas/overlays.css +14 -114
  57. package/dist/styles-areas/palette.css +15 -121
  58. package/dist/styles-areas/pdf-viewer.css +8 -28
  59. package/dist/styles-areas/problem-detail.css +9 -78
  60. package/dist/styles-areas/rich-text.css +9 -148
  61. package/dist/styles-areas/sharing.css +13 -134
  62. package/dist/styles-areas/spreadsheet.css +8 -18
  63. package/dist/styles-areas/stats.css +19 -262
  64. package/dist/styles-areas/test-report.css +13 -114
  65. package/dist/styles-areas/virtual.css +8 -11
  66. package/dist/styles-areas/wizard.css +21 -362
  67. package/dist/styles-areas/workbench.css +27 -524
  68. package/dist/styles-areas/workbook-viewer.css +8 -25
  69. package/dist/styles-static.css +1 -0
  70. package/dist/styles.css +1 -0
  71. package/package.json +20 -2
  72. package/scripts/checkAreaStyles.spec.ts +158 -1
  73. package/scripts/checkAreaStyles.ts +226 -6
  74. package/scripts/cssRules.ts +8 -1
  75. package/scripts/gate.ts +1 -1
  76. package/scripts/generateAreaStyles.ts +130 -10
  77. package/scripts/paths.ts +1 -1
  78. package/scripts/styleAreas.ts +2 -0
  79. package/src/react/components/ActionBar.spec.tsx +1 -0
  80. package/src/react/engagement/beacon.tsx +63 -0
  81. package/src/react/engagement/beaconClient.spec.ts +138 -0
  82. package/src/react/engagement/beaconClient.ts +228 -0
  83. package/src/react/engagement/index.ts +13 -0
  84. package/src/react/file-tree/FileTree.tsx +3 -3
  85. package/src/react/lib/directUpload.spec.ts +128 -0
  86. package/src/react/lib/directUpload.ts +334 -0
  87. package/src/react/media/FocusViewer.tsx +1 -0
  88. package/src/react/media/MediaThumb.tsx +0 -1
  89. package/src/react/media/VideoTurner.tsx +0 -1
  90. package/src/react/media-gallery/GalleryTable.tsx +0 -1
  91. package/src/react/media-gallery/MediaGallery.tsx +0 -1
  92. package/src/styles-areas/activity-bar.css +8 -11
  93. package/src/styles-areas/analytics.css +30 -653
  94. package/src/styles-areas/auth.css +10 -135
  95. package/src/styles-areas/calendar.css +22 -397
  96. package/src/styles-areas/chart-table.css +8 -20
  97. package/src/styles-areas/charts.css +9 -70
  98. package/src/styles-areas/chat.css +17 -345
  99. package/src/styles-areas/clipboard.css +7 -7
  100. package/src/styles-areas/code-editor.css +8 -111
  101. package/src/styles-areas/companion-link.css +8 -51
  102. package/src/styles-areas/core.css +131 -2488
  103. package/src/styles-areas/dashboard-grid.css +10 -65
  104. package/src/styles-areas/data-table.css +30 -638
  105. package/src/styles-areas/deep-link.css +7 -7
  106. package/src/styles-areas/diff-viewer.css +8 -11
  107. package/src/styles-areas/disk-usage.css +13 -243
  108. package/src/styles-areas/emoji.css +9 -135
  109. package/src/styles-areas/fields.css +32 -563
  110. package/src/styles-areas/file-tree.css +8 -14
  111. package/src/styles-areas/filter-rail.css +14 -216
  112. package/src/styles-areas/folder-tree.css +12 -200
  113. package/src/styles-areas/keep-awake.css +7 -7
  114. package/src/styles-areas/layout-engine.css +12 -220
  115. package/src/styles-areas/markdown.css +9 -68
  116. package/src/styles-areas/master-detail.css +14 -220
  117. package/src/styles-areas/master-lock.css +9 -113
  118. package/src/styles-areas/media-gallery.css +15 -332
  119. package/src/styles-areas/media.css +20 -623
  120. package/src/styles-areas/nav.css +24 -469
  121. package/src/styles-areas/notifications.css +12 -226
  122. package/src/styles-areas/overlays.css +14 -114
  123. package/src/styles-areas/palette.css +15 -121
  124. package/src/styles-areas/pdf-viewer.css +8 -28
  125. package/src/styles-areas/problem-detail.css +9 -78
  126. package/src/styles-areas/rich-text.css +9 -148
  127. package/src/styles-areas/sharing.css +13 -134
  128. package/src/styles-areas/spreadsheet.css +8 -18
  129. package/src/styles-areas/stats.css +19 -262
  130. package/src/styles-areas/test-report.css +13 -114
  131. package/src/styles-areas/virtual.css +8 -11
  132. package/src/styles-areas/wizard.css +21 -362
  133. package/src/styles-areas/workbench.css +27 -524
  134. package/src/styles-areas/workbook-viewer.css +8 -25
  135. package/src/styles-static.css +1 -0
  136. package/src/styles.css +1 -0
  137. package/src/stylesAreas.spec.ts +12 -7
@@ -2121,7 +2121,7 @@ export function FileTree({
2121
2121
  : null;
2122
2122
 
2123
2123
  const rowBody = (
2124
- // biome-ignore lint/a11y/useFocusableInteractive: it IS focusable — `tabIndex`
2124
+ // It IS focusable — `tabIndex`
2125
2125
  // is set below by the roving-focus rule (one row at 0, the rest at -1).
2126
2126
  <div
2127
2127
  role='treeitem'
@@ -2205,7 +2205,7 @@ export function FileTree({
2205
2205
  {...externalHandlers(node)}
2206
2206
  >
2207
2207
  {row.expandable ? (
2208
- // biome-ignore lint/a11y/noStaticElementInteractions: a disclosure twisty
2208
+ // A disclosure twisty
2209
2209
  // inside a `treeitem`, which already carries `aria-expanded` — a nested
2210
2210
  // <button> would be a second focus stop announcing the same state twice.
2211
2211
  <span
@@ -2444,7 +2444,7 @@ export function FileTree({
2444
2444
  </div>
2445
2445
  ) : null}
2446
2446
 
2447
- {/* biome-ignore lint/a11y/noStaticElementInteractions: the blank area under the tree is
2447
+ {/* The blank area under the tree is
2448
2448
  a DROP TARGET, not a control — "get this out of here" is the one gesture with no
2449
2449
  row to aim at. Every move it can express is also on a row's own menu. */}
2450
2450
  <div
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `cursedbelt/react/upload-direct` — the browser half of a resumable upload to binary-server.
3
+ *
4
+ * Neither app that carried this module (collections, family) had a test of it, so these pin the
5
+ * behaviours a 10 GB upload depends on, against a scripted `fetch`: resume skips delivered parts,
6
+ * parts are PUT with their bytes, a 403 re-mints the token, a 507 is terminal and never retried, a
7
+ * dropped part is retried, low disk only WARNS, and a lost final response is recovered by asking.
8
+ */
9
+ import { afterEach, describe, expect, test } from 'bun:test';
10
+ import { chunkCount, fetchSessionState, InsufficientStorageError, type UploadSession, uploadDirect } from './directUpload.js';
11
+
12
+ const SESSION: UploadSession = {
13
+ baseUrl: 'https://bs.test',
14
+ path: 'family/media/abc def',
15
+ token: 't1',
16
+ sid: 's',
17
+ totalChunks: 3,
18
+ chunkBytes: 4,
19
+ };
20
+ const FILE = new Blob(['aaaabbbbcc'], { type: 'image/jpeg' });
21
+
22
+ interface Call {
23
+ method: string;
24
+ url: URL;
25
+ body: string | null;
26
+ }
27
+
28
+ const realFetch = globalThis.fetch;
29
+ afterEach(() => {
30
+ globalThis.fetch = realFetch;
31
+ });
32
+
33
+ /** Install a `fetch` that records every call and answers from `answer`. */
34
+ function script(answer: (call: Call) => Response | Promise<Response>): Call[] {
35
+ const calls: Call[] = [];
36
+ globalThis.fetch = (async (input: string, init?: RequestInit) => {
37
+ const body = init?.body instanceof Blob ? await init.body.text() : null;
38
+ const call = { method: init?.method ?? 'GET', url: new URL(String(input)), body };
39
+ calls.push(call);
40
+ return answer(call);
41
+ }) as unknown as typeof fetch;
42
+ return calls;
43
+ }
44
+
45
+ const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status });
46
+ const status = (received: number[], extra: Record<string, unknown> = {}) => json(200, { received, complete: false, ...extra });
47
+
48
+ describe('chunkCount and fetchSessionState', () => {
49
+ test('parts round UP, and an empty file is still one part', () => {
50
+ expect(chunkCount(10, 4)).toBe(3);
51
+ expect(chunkCount(8, 4)).toBe(2);
52
+ expect(chunkCount(0, 4)).toBe(1);
53
+ });
54
+
55
+ test('a status the server cannot answer is a FRESH session, not a failure', async () => {
56
+ script(() => new Response('nope', { status: 500 }));
57
+ const state = await fetchSessionState(SESSION);
58
+ expect([...state.received]).toEqual([]);
59
+ expect(state.complete).toBe(false);
60
+ });
61
+ });
62
+
63
+ describe('uploadDirect', () => {
64
+ test('resumes: sends only the parts binary-server does not hold, with their bytes, to an encoded path', async () => {
65
+ const calls = script((c) =>
66
+ c.method === 'GET' ? status([1]) : json(200, c.url.searchParams.get('ci') === '2' ? { assembled: true, size: 10, checksum: 'h', key: SESSION.path } : {}),
67
+ );
68
+ const result = await uploadDirect(FILE, SESSION, { concurrency: 1 });
69
+ const puts = calls.filter((c) => c.method === 'PUT');
70
+ expect(puts.map((c) => [c.url.searchParams.get('ci'), c.body])).toEqual([
71
+ ['0', 'aaaa'],
72
+ ['2', 'cc'],
73
+ ]);
74
+ expect(puts[0]?.url.pathname).toBe('/upload-chunk/family/media/abc%20def');
75
+ expect(result).toEqual({ key: SESSION.path, size: 10, checksum: 'h' });
76
+ });
77
+
78
+ test('an object already complete sends NOTHING', async () => {
79
+ const calls = script(() => json(200, { received: [0, 1, 2], complete: true, size: 10, checksum: 'h' }));
80
+ expect(await uploadDirect(FILE, SESSION)).toEqual({ key: SESSION.path, size: 10, checksum: 'h' });
81
+ expect(calls.filter((c) => c.method === 'PUT')).toEqual([]);
82
+ });
83
+
84
+ test('a 403 re-mints the token and the retry carries the new one', async () => {
85
+ let first = true;
86
+ const calls = script((c) => {
87
+ if (c.method === 'GET') return status([0, 1]);
88
+ if (first) {
89
+ first = false;
90
+ return new Response('expired', { status: 403 });
91
+ }
92
+ return json(200, { assembled: true, size: 10, checksum: null });
93
+ });
94
+ await uploadDirect(FILE, SESSION, { refreshToken: async () => 't2', retries: 3 });
95
+ const puts = calls.filter((c) => c.method === 'PUT').map((c) => c.url.searchParams.get('token'));
96
+ expect(puts).toEqual(['t1', 't2']);
97
+ }, 10_000);
98
+
99
+ test('🔴 a 507 is TERMINAL — one attempt, a named error, no retry storm against a full disk', async () => {
100
+ const calls = script((c) => (c.method === 'GET' ? status([0, 1]) : json(507, { freeBytes: 0 })));
101
+ await expect(uploadDirect(FILE, SESSION, { retries: 5 })).rejects.toBeInstanceOf(InsufficientStorageError);
102
+ expect(calls.filter((c) => c.method === 'PUT')).toHaveLength(1);
103
+ });
104
+
105
+ test('🔴 low disk only WARNS — the upload still runs (the owner, 2026-08-13)', async () => {
106
+ const warned: unknown[] = [];
107
+ script((c) => (c.method === 'GET' ? status([], { freeBytes: 1 }) : json(200, { assembled: c.url.searchParams.get('ci') === '2' })));
108
+ await uploadDirect(FILE, SESSION, { onLowDisk: (info) => warned.push(info) });
109
+ expect(warned).toEqual([{ freeBytes: 1, needBytes: 10 }]);
110
+ });
111
+
112
+ test('a lost final response is recovered by ASKING, not by re-sending', async () => {
113
+ let gets = 0;
114
+ const calls = script((c) => {
115
+ if (c.method === 'GET') return ++gets === 1 ? status([0, 1]) : json(200, { received: [0, 1, 2], complete: true, size: 10, checksum: 'z' });
116
+ return json(200, {}); // the part landed; the assemble result never reached us
117
+ });
118
+ expect(await uploadDirect(FILE, SESSION)).toEqual({ key: SESSION.path, size: 10, checksum: 'z' });
119
+ expect(calls.filter((c) => c.method === 'PUT')).toHaveLength(1);
120
+ });
121
+
122
+ test('progress reaches 1', async () => {
123
+ const seen: number[] = [];
124
+ script((c) => (c.method === 'GET' ? status([]) : json(200, { assembled: c.url.searchParams.get('ci') === '2' })));
125
+ await uploadDirect(FILE, SESSION, { onProgress: (f) => seen.push(f) });
126
+ expect(seen.at(-1)).toBe(1);
127
+ });
128
+ });
@@ -0,0 +1,334 @@
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
+
37
+ /** Everything the browser needs to run an upload, minted server-side by `createUploadSession`
38
+ * in `cursedbelt-server/binary-store/upload-session`. Contains a capability token scoped to ONE key, and no key
39
+ * material — it is safe to hand to the page that will do the uploading. */
40
+ export interface UploadSession {
41
+ /** binary-server's public base, e.g. `https://binary-server.cursedalchemy.com`. */
42
+ baseUrl: string;
43
+ /** App-prefixed destination key, e.g. `family/media/<uuid>`. */
44
+ path: string;
45
+ /** Signed token authorizing this session (carries `sid`, `tc` and the source length `sz`). */
46
+ token: string;
47
+ /** Chunk session id. */
48
+ sid: string;
49
+ /** Total parts the file was split into. */
50
+ totalChunks: number;
51
+ /** Bytes per part — must match what the token's `tc` was computed from. */
52
+ chunkBytes: number;
53
+ }
54
+
55
+ export interface DirectUploadOptions {
56
+ /** 0..1, called as parts complete. */
57
+ onProgress?: (fraction: number, bytesSent: number, totalBytes: number) => void;
58
+ /** Parts in flight at once. Default 3 — enough to saturate a home upstream, few enough
59
+ * that a failure retries cheaply. */
60
+ concurrency?: number;
61
+ /** Attempts per part before giving up. Default 5, with backoff. */
62
+ retries?: number;
63
+ /**
64
+ * Called once, before any part is sent, when the store reports less free space than this
65
+ * upload is likely to need.
66
+ *
67
+ * 🔴 A WARNING and nothing else — it cannot stop the upload, and there is deliberately no
68
+ * way for it to. The owner's instruction (2026-08-13) is that space must never block them:
69
+ * *"Remove all blockers for space and only allow warning me. I'll manage space myself
70
+ * beyond that."* Returning `false` from here, or throwing, would quietly reinstate exactly
71
+ * the blocker that was removed.
72
+ */
73
+ onLowDisk?: (info: { freeBytes: number; needBytes: number }) => void;
74
+ /** Abort the whole upload (a cancel button, or unmount). */
75
+ signal?: AbortSignal;
76
+ /** Re-mint an expired session token mid-upload. A 10 GB upload can outlive any TTL short
77
+ * enough to be safe, so the page supplies a way to ask its own server for a fresh one
78
+ * for the SAME `sid`. Without it, a long upload simply fails on expiry. */
79
+ refreshToken?: () => Promise<string>;
80
+ }
81
+
82
+ /** What binary-server reports once the last part lands. */
83
+ export interface DirectUploadResult {
84
+ key: string;
85
+ size: number;
86
+ checksum: string | null;
87
+ }
88
+
89
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
90
+
91
+ /** Split `[0, total)` into `chunkBytes`-sized part indices. */
92
+ export const chunkCount = (totalBytes: number, chunkBytes: number): number =>
93
+ Math.max(1, Math.ceil(totalBytes / chunkBytes));
94
+
95
+ const chunkUrl = (s: UploadSession, token: string, ci: number): string =>
96
+ `${s.baseUrl}/upload-chunk/${s.path
97
+ .split("/")
98
+ .map(encodeURIComponent)
99
+ .join("/")}?token=${encodeURIComponent(token)}&ci=${ci}`;
100
+
101
+ const statusUrl = (s: UploadSession, token: string): string =>
102
+ `${s.baseUrl}/upload-chunk/${s.path
103
+ .split("/")
104
+ .map(encodeURIComponent)
105
+ .join("/")}?token=${encodeURIComponent(token)}`;
106
+
107
+ /** Which parts binary-server already holds, plus whether the object is already complete.
108
+ * A fresh session answers `{ received: [], complete: false }`, so callers need no special
109
+ * case for "first attempt". */
110
+ export async function fetchSessionState(
111
+ session: UploadSession,
112
+ token = session.token,
113
+ signal?: AbortSignal,
114
+ ): Promise<{
115
+ received: Set<number>;
116
+ complete: boolean;
117
+ size: number | null;
118
+ checksum: string | null;
119
+ /** Bytes binary-server still has room for, or null if it could not tell. */
120
+ freeBytes: number | null;
121
+ }> {
122
+ const res = await fetch(statusUrl(session, token), { signal });
123
+ if (!res.ok)
124
+ return {
125
+ received: new Set(),
126
+ complete: false,
127
+ size: null,
128
+ checksum: null,
129
+ freeBytes: null,
130
+ };
131
+ const body = (await res.json()) as {
132
+ received?: number[];
133
+ complete?: boolean;
134
+ size?: number | null;
135
+ checksum?: string | null;
136
+ freeBytes?: number | null;
137
+ };
138
+ return {
139
+ received: new Set(body.received ?? []),
140
+ complete: Boolean(body.complete),
141
+ size: body.size ?? null,
142
+ checksum: body.checksum ?? null,
143
+ freeBytes: body.freeBytes ?? null,
144
+ };
145
+ }
146
+
147
+ /** Thrown when binary-server has no room for the file. Distinct from a transport failure so a
148
+ * UI can say "the server is full" — which is actionable — rather than "upload failed", which
149
+ * invites the person to retry forever against a disk that will never have space. */
150
+ export class InsufficientStorageError extends Error {
151
+ constructor(
152
+ readonly needBytes: number,
153
+ readonly freeBytes: number | null,
154
+ /**
155
+ * 🔴 Vestigial since 2026-08-13 and kept only so an older deployed binary-server can
156
+ * still be understood: THIS service no longer refuses an upload for disk space at all,
157
+ * so nothing here should be producing an overridable refusal any more. If you see one,
158
+ * the origin is running a build from before that change.
159
+ */
160
+ readonly overridable = false,
161
+ ) {
162
+ super(
163
+ freeBytes === null
164
+ ? "binary-server is out of space for this upload"
165
+ : `binary-server has ${(freeBytes / 1024 ** 3).toFixed(1)} GB free, which is not enough for a ${(needBytes / 1024 ** 3).toFixed(1)} GB upload`,
166
+ );
167
+ this.name = "InsufficientStorageError";
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Upload `file` to binary-server in parts, resuming anything already delivered.
173
+ *
174
+ * Resolves once every part has landed and bs has assembled the object. The caller then tells
175
+ * its own app server to record the metadata — see each app's `/complete` route.
176
+ */
177
+ export async function uploadDirect(
178
+ file: Blob,
179
+ session: UploadSession,
180
+ opts: DirectUploadOptions = {},
181
+ ): Promise<DirectUploadResult> {
182
+ const { onProgress, signal, refreshToken } = opts;
183
+ const concurrency = Math.max(1, opts.concurrency ?? 3);
184
+ const retries = Math.max(1, opts.retries ?? 5);
185
+ const { onLowDisk } = opts;
186
+ const total = session.totalChunks;
187
+
188
+ // A token can expire mid-upload on a slow line; `refreshToken` swaps in a new one and
189
+ // every subsequent request picks it up.
190
+ let token = session.token;
191
+
192
+ const prior = await fetchSessionState(session, token, signal);
193
+ if (prior.complete) {
194
+ return {
195
+ key: session.path,
196
+ size: prior.size ?? file.size,
197
+ checksum: prior.checksum,
198
+ };
199
+ }
200
+
201
+ // Bail before sending anything if the file plainly cannot land. bs keeps a reserve so a
202
+ // full disk never takes the Mac down, and it reports free space on the status call above —
203
+ // so an upload with nowhere to go costs one request instead of gigabytes of transfer.
204
+ const remainingBytes = file.size - prior.received.size * session.chunkBytes;
205
+ /*
206
+ * 🔴 NO PREFLIGHT REFUSAL. Owner instruction, 2026-08-13: *"Remove all blockers for space
207
+ * and only allow warning me. I'll manage space myself beyond that."*
208
+ *
209
+ * This used to throw when the remaining bytes exceeded the reported free space. It is gone
210
+ * rather than softened because a client-side estimate is the WORST place to make this
211
+ * call: `remainingBytes` counts every part not yet delivered, `freeBytes` was read before
212
+ * the transfer began, and neither accounts for what the run itself frees or the owner
213
+ * clears while it goes. binary-server no longer refuses either; the real limit is ENOSPC
214
+ * from the filesystem, which is about the actual write rather than a projection.
215
+ *
216
+ * `onLowDisk` is the replacement, and it only WARNS.
217
+ */
218
+ if (prior.freeBytes !== null && remainingBytes > prior.freeBytes) {
219
+ onLowDisk?.({ freeBytes: prior.freeBytes, needBytes: remainingBytes });
220
+ }
221
+
222
+ const pending = Array.from({ length: total }, (_, i) => i).filter((i) => !prior.received.has(i));
223
+ let done = total - pending.length;
224
+ let assembled: DirectUploadResult | null = null;
225
+
226
+ const report = (): void =>
227
+ onProgress?.(total === 0 ? 1 : done / total, done * session.chunkBytes, file.size);
228
+ report();
229
+
230
+ const sendOne = async (ci: number): Promise<void> => {
231
+ const start = ci * session.chunkBytes;
232
+ // Lazy view onto the file — nothing is read until fetch streams it.
233
+ const part = file.slice(start, Math.min(start + session.chunkBytes, file.size));
234
+
235
+ let lastErr: unknown;
236
+ for (let attempt = 0; attempt < retries; attempt++) {
237
+ signal?.throwIfAborted();
238
+ try {
239
+ const res = await fetch(chunkUrl(session, token, ci), {
240
+ method: "PUT",
241
+ body: part,
242
+ // bs stores the mime from the assembling request's content-type.
243
+ // 🔴 `x-allow-low-disk` is what turns binary-server's disk RESERVE from a
244
+ // wall into a warning. Owner instruction 2026-08-13, after a large upload
245
+ // stopped dead: *"Do not let the server block me. It can warn me and I can
246
+ // say do it anyway or cancel if I want."* The reserve is sized for the worst
247
+ // case a chunked upload can transiently need, so it refused uploads that
248
+ // would have fit. bs still refuses absolutely below its hard floor, where
249
+ // the machine itself is what is at risk.
250
+ headers: file.type ? { "content-type": file.type } : undefined,
251
+ signal,
252
+ });
253
+
254
+ if (res.status === 403 && refreshToken) {
255
+ // Almost always an expired session token on a long upload. Re-mint once per
256
+ // attempt and let the loop retry with it.
257
+ token = await refreshToken();
258
+ throw new Error("token refreshed, retrying");
259
+ }
260
+ // Out of space is terminal — retrying cannot make room, and a 10 GB upload
261
+ // hammering a full disk five times over is the opposite of helpful.
262
+ if (res.status === 507) {
263
+ const body = (await res.json().catch(() => ({}))) as {
264
+ freeBytes?: number;
265
+ overridable?: boolean;
266
+ };
267
+ // Terminal — retrying cannot make room, and a 10 GB upload hammering a full
268
+ // disk five times over is the opposite of helpful. 🔴 A CURRENT
269
+ // binary-server never sends this for its own reserve any more (see
270
+ // `overridable`); reaching here means the volume is genuinely out of room
271
+ // and the filesystem said so.
272
+ throw new InsufficientStorageError(
273
+ file.size,
274
+ body.freeBytes ?? null,
275
+ body.overridable === true,
276
+ );
277
+ }
278
+ if (!res.ok) throw new Error(`chunk ${ci}: HTTP ${res.status}`);
279
+
280
+ const body = (await res.json()) as {
281
+ assembled?: boolean;
282
+ size?: number;
283
+ checksum?: string | null;
284
+ key?: string;
285
+ };
286
+ if (body.assembled) {
287
+ assembled = {
288
+ key: body.key ?? session.path,
289
+ size: body.size ?? file.size,
290
+ checksum: body.checksum ?? null,
291
+ };
292
+ }
293
+ done++;
294
+ report();
295
+ return;
296
+ } catch (err) {
297
+ if (signal?.aborted) throw err;
298
+ // Terminal: no amount of backoff creates disk space.
299
+ if (err instanceof InsufficientStorageError) throw err;
300
+ lastErr = err;
301
+ // Backoff, capped — a home connection that dropped needs seconds, not minutes.
302
+ await sleep(Math.min(1000 * 2 ** attempt, 15_000));
303
+ }
304
+ }
305
+ throw new Error(
306
+ `chunk ${ci} failed after ${retries} attempts: ${
307
+ lastErr instanceof Error ? lastErr.message : String(lastErr)
308
+ }`,
309
+ );
310
+ };
311
+
312
+ // Bounded worker pool over the pending indices.
313
+ const queue = [...pending];
314
+ const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
315
+ for (;;) {
316
+ const next = queue.shift();
317
+ if (next === undefined) return;
318
+ await sendOne(next);
319
+ }
320
+ });
321
+ await Promise.all(workers);
322
+
323
+ if (assembled) return assembled;
324
+
325
+ // Every part reported OK but no response carried the assemble result — possible if the
326
+ // final 201 was the one that got retried after its response was lost. Ask.
327
+ const after = await fetchSessionState(session, token, signal);
328
+ if (after.complete) {
329
+ return { key: session.path, size: after.size ?? file.size, checksum: after.checksum };
330
+ }
331
+ throw new Error(
332
+ `upload finished but binary-server did not assemble ${session.path} (${after.received.size}/${total} parts)`,
333
+ );
334
+ }
@@ -141,6 +141,7 @@ export interface FocusViewerProps {
141
141
  onSaveSnapshot?: (
142
142
  blob: Blob,
143
143
  meta: { atSeconds: number },
144
+ // biome-ignore lint/suspicious/noConfusingVoidType: a handler that returns nothing is the common case, and `undefined` would reject every `() => {}` an app already passes
144
145
  ) => Promise<FocusSaveResult | void> | FocusSaveResult | void;
145
146
  /** An arbitrary control in the action row — collections puts its folder picker here. */
146
147
  destination?: React.ReactNode;
@@ -136,7 +136,6 @@ export function MediaThumb({
136
136
  }
137
137
 
138
138
  return (
139
- // biome-ignore lint/performance/noImgElement: cursedbelt has no framework image loader.
140
139
  <img
141
140
  data-cb-media-thumb='image'
142
141
  src={src}
@@ -168,7 +168,6 @@ export function VideoTurner({
168
168
  full-width. Same correction, and the same measurement, as the gallery stage makes. */}
169
169
  <div className='cbpe-frame cbpe-frame-film' data-turned={swapsAxes(edit.turns) ? 'true' : undefined}>
170
170
  {cannotShow === null ? (
171
- /* biome-ignore lint/a11y/useMediaCaption: arbitrary user-stored footage carries none */
172
171
  <video
173
172
  // A new source is a new attempt — see `failedSrc`.
174
173
  key={src}
@@ -418,7 +418,6 @@ function TableVideoPreview({
418
418
  return () => releaseVideo(el);
419
419
  }, []);
420
420
  return (
421
- // biome-ignore lint/a11y/useMediaCaption: a muted 56px preview of an arbitrary user file
422
421
  <video
423
422
  ref={ref}
424
423
  src={src}
@@ -1383,7 +1383,6 @@ function GalleryStage({
1383
1383
  still takes a dwell timer: a run that reaches a track it cannot advance past stops
1384
1384
  dead. A mixed folder holding one voice memo used to end the album there. The two
1385
1385
  props stay mutually exclusive — a looping element never fires `ended`. */}
1386
- {/* biome-ignore lint/a11y/useMediaCaption: arbitrary user-stored files carry no captions */}
1387
1386
  <audio src={media.url} controls loop={repeat} {...(repeat ? {} : { onEnded })} />
1388
1387
  </div>
1389
1388
  );
@@ -1,5 +1,5 @@
1
1
  /*
2
- * cursedbelt — the `activity-bar` area. GENERATED BUILD ARTIFACT. Never hand-edit.
2
+ * cursedbelt — the `activity-bar` area's utility candidates. GENERATED BUILD ARTIFACT. Never hand-edit.
3
3
  *
4
4
  * the `cursedbelt/react/activity-bar` rail — app chrome most apps do not mount.
5
5
  *
@@ -13,18 +13,15 @@
13
13
  * @import "cursedbelt/styles-areas/activity-bar.css";
14
14
  * @import "cursedbelt/theme.css";
15
15
  *
16
- * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
- * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 71 candidate(s) scanned.
16
+ * These are CANDIDATES (`@source inline`), not CSS: the app's own Tailwind pass generates
17
+ * them, sorted together with the app's utilities, so an area can never put a base utility
18
+ * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
+ * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
+ * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
+ * 1 candidate(s).
19
22
  *
20
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
22
25
  * import closures rather than a hand-written list, is scripts/styleAreas.ts.
23
26
  */
24
- /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
25
- @layer theme, base, components, utilities;
26
- @layer utilities {
27
- .collapse {
28
- visibility: collapse;
29
- }
30
- }
27
+ @source inline("collapse");