cursedbelt 4.1.1 → 4.4.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 (108) hide show
  1. package/dist/react/components/Autocomplete.d.ts.map +1 -1
  2. package/dist/react/components/Autocomplete.js +1 -1
  3. package/dist/react/components/Autocomplete.js.map +1 -1
  4. package/dist/react/components/ComboboxField.d.ts.map +1 -1
  5. package/dist/react/components/ComboboxField.js +1 -1
  6. package/dist/react/components/ComboboxField.js.map +1 -1
  7. package/dist/react/media/CanvasVideoPlayer.d.ts +3 -2
  8. package/dist/react/media/CanvasVideoPlayer.d.ts.map +1 -1
  9. package/dist/react/media/CanvasVideoPlayer.js +4 -3
  10. package/dist/react/media/CanvasVideoPlayer.js.map +1 -1
  11. package/dist/react/media/FrameGrabber.d.ts +16 -2
  12. package/dist/react/media/FrameGrabber.d.ts.map +1 -1
  13. package/dist/react/media/FrameGrabber.js +68 -43
  14. package/dist/react/media/FrameGrabber.js.map +1 -1
  15. package/dist/react/media/PictureEditor.d.ts +17 -2
  16. package/dist/react/media/PictureEditor.d.ts.map +1 -1
  17. package/dist/react/media/PictureEditor.js +33 -7
  18. package/dist/react/media/PictureEditor.js.map +1 -1
  19. package/dist/react/media/VideoChapterEditor.d.ts +17 -2
  20. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
  21. package/dist/react/media/VideoChapterEditor.js +21 -3
  22. package/dist/react/media/VideoChapterEditor.js.map +1 -1
  23. package/dist/react/media/VideoCutEditor.d.ts +16 -2
  24. package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
  25. package/dist/react/media/VideoCutEditor.js +21 -3
  26. package/dist/react/media/VideoCutEditor.js.map +1 -1
  27. package/dist/react/media/VideoPlayer.d.ts +37 -9
  28. package/dist/react/media/VideoPlayer.d.ts.map +1 -1
  29. package/dist/react/media/VideoPlayer.js +130 -39
  30. package/dist/react/media/VideoPlayer.js.map +1 -1
  31. package/dist/react/media/hlsSource.d.ts +24 -3
  32. package/dist/react/media/hlsSource.d.ts.map +1 -1
  33. package/dist/react/media/hlsSource.js +109 -8
  34. package/dist/react/media/hlsSource.js.map +1 -1
  35. package/dist/react/media/pictureEditor.css +2 -0
  36. package/dist/react/media/sourceFault.d.ts +146 -0
  37. package/dist/react/media/sourceFault.d.ts.map +1 -0
  38. package/dist/react/media/sourceFault.js +139 -0
  39. package/dist/react/media/sourceFault.js.map +1 -0
  40. package/dist/react/media/videoChapterEditor.css +19 -0
  41. package/dist/react/media/videoCutEditor.css +22 -0
  42. package/dist/react/media-gallery/MediaGallery.d.ts +21 -3
  43. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  44. package/dist/react/media-gallery/MediaGallery.js +178 -17
  45. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  46. package/dist/react/media-gallery/galleryItemMedia.d.ts +4 -0
  47. package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
  48. package/dist/react/media-gallery/galleryItemMedia.js +27 -3
  49. package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
  50. package/dist/react/media-gallery/mediaGalleryModel.d.ts.map +1 -1
  51. package/dist/react/media-gallery/mediaGalleryModel.js +5 -0
  52. package/dist/react/media-gallery/mediaGalleryModel.js.map +1 -1
  53. package/dist/react/media-gallery/types.d.ts +23 -0
  54. package/dist/react/media-gallery/types.d.ts.map +1 -1
  55. package/dist/scripts/guardrailsEnforce.d.ts +1 -0
  56. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  57. package/dist/styles-areas/analytics.css +1 -1
  58. package/dist/styles-areas/data-table.css +1 -1
  59. package/dist/styles-areas/fields.css +1 -1
  60. package/dist/styles-areas/media-gallery.css +1 -1
  61. package/dist/styles-areas/media.css +1 -1
  62. package/dist/styles-areas/wizard.css +1 -1
  63. package/package.json +51 -6
  64. package/scripts/demoServer.ts +211 -0
  65. package/scripts/gate.ts +101 -0
  66. package/scripts/guardrailsEnforce.spec.ts +313 -1
  67. package/scripts/guardrailsEnforce.ts +283 -26
  68. package/scripts/paths.ts +1 -1
  69. package/src/demoFixture.spec.ts +54 -10
  70. package/src/demoStaticServer.spec.ts +169 -0
  71. package/src/publishShape.spec.ts +79 -0
  72. package/src/react/components/Autocomplete.tsx +11 -1
  73. package/src/react/components/ComboboxField.spec.tsx +12 -0
  74. package/src/react/components/ComboboxField.tsx +11 -1
  75. package/src/react/media/CanvasVideoPlayer.tsx +11 -6
  76. package/src/react/media/FrameGrabber.spec.tsx +57 -1
  77. package/src/react/media/FrameGrabber.tsx +138 -84
  78. package/src/react/media/PictureEditor.spec.tsx +90 -0
  79. package/src/react/media/PictureEditor.tsx +78 -23
  80. package/src/react/media/VideoChapterEditor.spec.tsx +86 -0
  81. package/src/react/media/VideoChapterEditor.tsx +64 -18
  82. package/src/react/media/VideoCutEditor.spec.tsx +44 -0
  83. package/src/react/media/VideoCutEditor.tsx +63 -18
  84. package/src/react/media/VideoPlayer.spec.tsx +101 -0
  85. package/src/react/media/VideoPlayer.tsx +221 -73
  86. package/src/react/media/hlsSource.answer.spec.ts +255 -0
  87. package/src/react/media/hlsSource.ts +136 -8
  88. package/src/react/media/pictureEditor.css +2 -0
  89. package/src/react/media/sourceFault.spec.tsx +224 -0
  90. package/src/react/media/sourceFault.tsx +220 -0
  91. package/src/react/media/videoChapterEditor.css +19 -0
  92. package/src/react/media/videoCutEditor.css +22 -0
  93. package/src/react/media-gallery/MediaGallery.spec.tsx +311 -0
  94. package/src/react/media-gallery/MediaGallery.tsx +382 -54
  95. package/src/react/media-gallery/galleryItemMedia.tsx +27 -4
  96. package/src/react/media-gallery/mediaGalleryModel.spec.ts +36 -0
  97. package/src/react/media-gallery/mediaGalleryModel.ts +4 -0
  98. package/src/react/media-gallery/types.ts +23 -0
  99. package/src/styles-areas/analytics.css +1 -1
  100. package/src/styles-areas/data-table.css +1 -1
  101. package/src/styles-areas/fields.css +1 -1
  102. package/src/styles-areas/media-gallery.css +1 -1
  103. package/src/styles-areas/media.css +1 -1
  104. package/src/styles-areas/wizard.css +1 -1
  105. package/src/testFilesRunInParallel.spec.ts +3 -3
  106. package/src/typecheckCachesAreSeparate.spec.ts +2 -2
  107. package/src/verifyGraph.spec.ts +75 -108
  108. package/scripts/verify.ts +0 -296
@@ -0,0 +1,169 @@
1
+ /**
2
+ * 🔴 The demo's static server, and specifically the fault that used to end the gate.
3
+ *
4
+ * `bun run verify` went red about one run in four on an unmodified tree (task 388,
5
+ * 2026-09-18): `vite preview` — connect on Bun's `node:http` shim — threw
6
+ * `ERR_STREAM_WRITE_AFTER_END` out of `advanceResponsePipeline` when a Playwright worker
7
+ * hung up on a response that was still being written, and the throw landed after the
8
+ * handler had returned, where no middleware could catch it. The process exited 1, the
9
+ * `webServer` never came ready, and 138 innocent specs went red 120 seconds later.
10
+ *
11
+ * So the first assertion here is the one that matters: abort requests mid-flight and prove
12
+ * the server is still serving afterwards. Everything else in this file is the ordinary
13
+ * contract that makes the fixture pages reachable at all.
14
+ */
15
+ import { describe, expect, it } from 'bun:test';
16
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join, resolve } from 'node:path';
19
+ import { DEMO_HOST, DEMO_URL } from '../demo/devServer';
20
+ import { isPeerHangup, resolveRequestPath, startDemoServer } from '../scripts/demoServer';
21
+
22
+ /**
23
+ * A throwaway `dist` shaped like the one `bun run demo:build` writes. `$TMPDIR` is already
24
+ * redirected into this repo's scratch root by `scripts/testTmpRoot.ts` in the preload.
25
+ */
26
+ function fixtureRoot(): string {
27
+ const root = mkdtempSync(join(tmpdir(), 'demo-server-'));
28
+ mkdirSync(resolve(root, 'assets'), { recursive: true });
29
+ writeFileSync(resolve(root, 'index.html'), '<!doctype html><title>kitchen sink</title>');
30
+ writeFileSync(resolve(root, 'table-layout.html'), '<!doctype html><title>table layout</title>');
31
+ // Big enough that an abort lands mid-body rather than after the whole thing arrived.
32
+ writeFileSync(resolve(root, 'assets', 'app.js'), `export const pad = '${'x'.repeat(4_000_000)}';\n`);
33
+ writeFileSync(resolve(root, 'assets', 'app.css'), ':root { color-scheme: dark; }\n');
34
+ return root;
35
+ }
36
+
37
+ /** Runs `body` against a server on an ephemeral port, and always stops it. */
38
+ async function withServer(body: (base: string) => Promise<void>): Promise<void> {
39
+ const server = startDemoServer({ root: fixtureRoot(), port: 0, hostname: '127.0.0.1' });
40
+ try {
41
+ await body(`http://127.0.0.1:${server.port}`);
42
+ } finally {
43
+ await server.stop(true);
44
+ }
45
+ }
46
+
47
+ describe('🔴 a client that hangs up is a non-event', () => {
48
+ it('keeps serving after a burst of aborted requests', async () => {
49
+ await withServer(async (base) => {
50
+ expect((await fetch(`${base}/`)).status, 'the server must be up before we abort at it').toBe(200);
51
+
52
+ // Eight, because that is how many Playwright workers this repo runs, and each one
53
+ // abandons in-flight subresources on every navigation.
54
+ for (let i = 0; i < 8; i++) {
55
+ const control = new AbortController();
56
+ const inFlight = fetch(`${base}/assets/app.js`, { signal: control.signal });
57
+ // Abort on the next turn of the loop: late enough that the response is being
58
+ // written, early enough that the body has not finished.
59
+ queueMicrotask(() => control.abort());
60
+ await inFlight.then(
61
+ async (response) => {
62
+ // If it won the race, drain it and abort during the read instead.
63
+ control.abort();
64
+ await response.text().catch(() => undefined);
65
+ },
66
+ () => undefined,
67
+ );
68
+ }
69
+
70
+ // The whole point. Under `vite preview` the process was gone by now.
71
+ const after = await fetch(`${base}/`);
72
+ expect(after.status, 'the server died on a client hang-up — the exact task-388 bug').toBe(200);
73
+ expect(await after.text()).toInclude('kitchen sink');
74
+ });
75
+ });
76
+
77
+ it('names the peer faults it tolerates, and nothing else', () => {
78
+ // 🔴 A blanket handler would keep a broken server alive and let the gate measure a
79
+ // corpse that answers 200. The list is closed; this is the negative control.
80
+ expect(isPeerHangup({ code: 'ERR_STREAM_WRITE_AFTER_END' })).toBe(true);
81
+ expect(isPeerHangup({ code: 'ECONNRESET' })).toBe(true);
82
+ expect(isPeerHangup({ code: 'EPIPE' })).toBe(true);
83
+ expect(isPeerHangup(Object.assign(new Error('aborted'), { name: 'AbortError' }))).toBe(true);
84
+ expect(isPeerHangup(new TypeError('x is not a function'))).toBe(false);
85
+ expect(isPeerHangup({ code: 'ENOSPC' })).toBe(false);
86
+ expect(isPeerHangup(null)).toBe(false);
87
+ expect(isPeerHangup('ECONNRESET')).toBe(false);
88
+ });
89
+ });
90
+
91
+ describe('the pages the specs navigate to', () => {
92
+ it('serves the fixture pages, with the right content type and no caching', async () => {
93
+ await withServer(async (base) => {
94
+ const index = await fetch(`${base}/`);
95
+ expect(index.status).toBe(200);
96
+ expect(index.headers.get('content-type')).toInclude('text/html');
97
+ // 🔴 A cached bundle is a gate reading pre-checkout code, which is the trap this
98
+ // whole change exists to close.
99
+ expect(index.headers.get('cache-control')).toInclude('no-store');
100
+
101
+ const page = await fetch(`${base}/table-layout.html`);
102
+ expect(page.status).toBe(200);
103
+ expect(await page.text()).toInclude('table layout');
104
+
105
+ const css = await fetch(`${base}/assets/app.css`);
106
+ expect(css.status).toBe(200);
107
+ expect(css.headers.get('content-type')).toInclude('text/css');
108
+ });
109
+ });
110
+
111
+ it('404s what it does not have, rather than answering with index.html', async () => {
112
+ // A SPA fallback here would make `demoFixture.spec.ts`'s "every page the specs
113
+ // navigate to is real" assertion unfalsifiable at runtime: a typo'd fixture URL
114
+ // would load the kitchen sink and fail on a missing selector instead.
115
+ await withServer(async (base) => {
116
+ expect((await fetch(`${base}/no-such-page.html`)).status).toBe(404);
117
+ expect((await fetch(`${base}/assets/`)).status, 'no directory listings').toBe(404);
118
+ });
119
+ });
120
+ });
121
+
122
+ describe('what it refuses to serve', () => {
123
+ const root = '/tmp/demo-dist';
124
+
125
+ it('resolves the shapes the fixture actually uses', () => {
126
+ expect(resolveRequestPath(root, '/../../etc/passwd')).toBeNull();
127
+ expect(resolveRequestPath(root, '/%2e%2e/%2e%2e/etc/passwd')).toBeNull();
128
+ expect(resolveRequestPath(root, '/%zz')).toBeNull();
129
+ expect(resolveRequestPath(root, '/nope.html')).toBeNull();
130
+ });
131
+
132
+ it('cannot be walked out of its root even through an encoded traversal', async () => {
133
+ // The pure check above says `null`; this says the socket agrees, because a server
134
+ // that resolved correctly and then read the file anyway would pass the first.
135
+ await withServer(async (base) => {
136
+ for (const path of ['/../package.json', '/%2e%2e/package.json', '/..%2fpackage.json']) {
137
+ const response = await fetch(`${base}${path}`);
138
+ expect(await response.text(), `${path} escaped the demo dist`).not.toInclude('cursedbelt');
139
+ }
140
+ });
141
+ });
142
+ });
143
+
144
+ describe('the port', () => {
145
+ it('🔴 binds one literal address, so a second server cannot hide beside it', async () => {
146
+ // Measured 2026-09-18: with `hostname: 'localhost'` two processes bound 4318 at once,
147
+ // one on `[::1]` and one on `127.0.0.1`, and NEITHER raised EADDRINUSE — `localhost`
148
+ // resolves to both, so they were never competing for the same socket. The gate would
149
+ // then test whichever one the client happened to reach.
150
+ const server = startDemoServer({ root: fixtureRoot(), port: 0 });
151
+ try {
152
+ expect(server.hostname, 'a resolvable NAME here is two sockets pretending to be one').toBe(DEMO_HOST);
153
+ expect(DEMO_URL).toInclude(DEMO_HOST);
154
+ } finally {
155
+ await server.stop(true);
156
+ }
157
+ });
158
+
159
+ it('🔴 refuses a taken port instead of walking to the next one', async () => {
160
+ // The same rule `strictPort: true` gave the vite servers: a fixture that quietly
161
+ // moves to 4319 is a suite that times out against the port it was pointed at.
162
+ const first = startDemoServer({ root: fixtureRoot(), port: 0, hostname: '127.0.0.1' });
163
+ try {
164
+ expect(() => startDemoServer({ root: fixtureRoot(), port: first.port, hostname: '127.0.0.1' })).toThrow();
165
+ } finally {
166
+ await first.stop(true);
167
+ }
168
+ });
169
+ });
@@ -52,6 +52,31 @@ const typesThatAreNotDeclarations = (exports: Record<string, unknown>): string[]
52
52
  .filter(([, target]) => !target.endsWith('.d.ts'))
53
53
  .map(([subpath, target]) => `${subpath} → ${target}`);
54
54
 
55
+ /**
56
+ * Every `[subpath, condition, target]` this manifest resolves OUTSIDE `dist/`.
57
+ *
58
+ * `./dist/` is deliberately excluded: those are `scripts/checkDistExports.ts`' job, and it
59
+ * has to be a build step because `dist/` is the one directory no spec here may open
60
+ * (`src/verifyGraph.spec.ts` asserts it, because `build` runs beside `test`). Everything
61
+ * else is a file that is already on disk when the tests run, so it belongs here.
62
+ *
63
+ * A string-valued export (`'./styles.css': './src/styles.css'`) is one target under the
64
+ * implicit `default` condition — 48 of this manifest's entries are that shape.
65
+ */
66
+ const targetsOutsideDist = (exports: Record<string, unknown>): [string, string, string][] => {
67
+ const out: [string, string, string][] = [];
68
+ for (const [subpath, entry] of Object.entries(exports)) {
69
+ const conditions: Record<string, unknown> =
70
+ typeof entry === 'string' ? { default: entry } : typeof entry === 'object' && entry !== null ? (entry as Record<string, unknown>) : {};
71
+ for (const [condition, target] of Object.entries(conditions)) {
72
+ if (typeof target === 'string' && target.startsWith('./') && !target.startsWith('./dist/')) {
73
+ out.push([subpath, condition, target]);
74
+ }
75
+ }
76
+ }
77
+ return out;
78
+ };
79
+
55
80
  describe('publish shape', () => {
56
81
  it('ships every directory its exports resolve into', () => {
57
82
  const files = (pkg as { files?: string[] }).files ?? [];
@@ -147,7 +172,61 @@ describe('publish shape', () => {
147
172
  expect(typesThatAreNotDeclarations({ './a': { types: './dist/a.js' } })).toHaveLength(1);
148
173
  });
149
174
 
175
+ it('🔴 points every non-dist export condition at a file that EXISTS', async () => {
176
+ // ── The third of three ways this manifest can lie, and the only one nothing watched ──
177
+ // `publishShape` above proves an export's ROOT directory is in `files`;
178
+ // `scripts/checkDistExports.ts` proves every `./dist/` target was emitted. Between
179
+ // them sits the condition a consumer actually RUNS — `bun`/`source`/`default`, which
180
+ // for this package is raw `./src/**` and `./scripts/guardrailsEnforce.ts` — and
181
+ // nothing proved those files are there. `files` ships whole directories, so renaming
182
+ // or moving one leaves the root shipped, the tarball valid, and the target dangling.
183
+ //
184
+ // It is not hypothetical and it is not ours to survive: since task 202 folded
185
+ // `--baseline` into the scanner, SIX apps run
186
+ // `bun node_modules/cursedbelt/scripts/guardrailsEnforce.ts …` inside their own
187
+ // `verify`. That path and this manifest's `./guardrails` conditions name the same
188
+ // file, so the day it moves, six repos go red in a file none of them wrote — the
189
+ // exact three-repos-away failure that tasks 172 and 219 each cost a session to find.
190
+ //
191
+ // The 48 string-valued CSS entries are the sharper half: they are GENERATED
192
+ // (`bun run styles:areas`) and have no second condition to fall through to, so a
193
+ // generator that stops emitting one is an unresolvable import for every consumer
194
+ // with nothing red on this side at all.
195
+ const repo = `${import.meta.dir}/..`;
196
+ const missing: string[] = [];
197
+ for (const [subpath, condition, target] of targetsOutsideDist(pkg.exports as unknown as Record<string, unknown>)) {
198
+ // 🔴 Bun.file, NOT node:fs — the preload virtualizes node:fs and an existsSync
199
+ // here would answer from MOCK data. Same trap as the header of this file.
200
+ if (!(await Bun.file(`${repo}/${target.slice(2)}`).exists())) missing.push(`${subpath} [${condition}] → ${target}`);
201
+ }
202
+ expect(
203
+ missing,
204
+ 'these export conditions name a file that is not on disk. A consumer does not get a\n' +
205
+ 'helpful error — the resolver falls through to whatever condition is left, or fails\n' +
206
+ 'outright for the CSS entries, which have none:\n ' +
207
+ missing.join('\n '),
208
+ ).toEqual([]);
209
+ });
210
+
211
+ it('catches a dangling non-dist target, and leaves dist to the build step', () => {
212
+ // The negative control for the rule above. Without it, `targetsOutsideDist` could
213
+ // quietly stop matching and the assertion would be green against nothing.
214
+ const found = targetsOutsideDist({
215
+ './guardrails': { types: './dist/scripts/guardrailsEnforce.d.ts', bun: './scripts/moved.ts' },
216
+ './styles.css': './src/styles.css',
217
+ './react': { types: './dist/react/index.d.ts', import: './dist/react/index.js' },
218
+ });
219
+ // The `./dist/` conditions are checkDistExports' to police, so neither appears here.
220
+ expect(found).toEqual([
221
+ ['./guardrails', 'bun', './scripts/moved.ts'],
222
+ ['./styles.css', 'default', './src/styles.css'],
223
+ ]);
224
+ });
225
+
150
226
  it('reads the real manifest, so the rule cannot pass vacuously', () => {
227
+ // The scan above must see the whole non-dist surface, not a handful of it: 97 targets
228
+ // across the `bun`/`source`/`default` conditions and the 48 bare CSS entries.
229
+ expect(targetsOutsideDist(pkg.exports as unknown as Record<string, unknown>).length).toBeGreaterThan(90);
151
230
  // `types` is what every conditional export in this package leads with; if the scan
152
231
  // ever returns nothing, both assertions above are green about a manifest they never
153
232
  // looked at.
@@ -138,7 +138,17 @@ export function AutocompleteField<S = string>({
138
138
  </Autocomplete.InputGroup>
139
139
 
140
140
  <Autocomplete.Portal>
141
- <Autocomplete.Positioner sideOffset={4} collisionPadding={FLOATING_COLLISION_PADDING}>
141
+ {/* 🔴 The z-index goes on the POSITIONER, not only the popup. Base UI's positioner is
142
+ the element that is `position: absolute` (with a transform); the popup inside it is
143
+ `position: static`, so the popup's own `z-[var(--z-popover)]` does nothing and the
144
+ list painted UNDER any sibling with a z-index — measured 2026-09-22 in the
145
+ binary-server inspector, a sticky table header (z-index 1) covering the second option
146
+ of an open MultiSelectField. */}
147
+ <Autocomplete.Positioner
148
+ sideOffset={4}
149
+ collisionPadding={FLOATING_COLLISION_PADDING}
150
+ className='z-[var(--z-popover)]'
151
+ >
142
152
  <Autocomplete.Popup
143
153
  className={cn(
144
154
  FLOATING_CONTENT_BASE,
@@ -53,6 +53,18 @@ describe('ComboboxField (single)', () => {
53
53
  expect(getByText('Banana')).not.toBeNull();
54
54
  });
55
55
 
56
+ test('🔴 the POSITIONER carries the popover z-index — the popup is static inside it', () => {
57
+ const { container, getByRole } = render(<Controlled />);
58
+ const trigger = container.querySelector('[aria-label="Toggle options"]') as HTMLElement;
59
+ act(() => {
60
+ fireEvent.click(trigger);
61
+ });
62
+ const popup = getByRole('listbox').closest('[data-side]') as HTMLElement;
63
+ const positioner = popup.parentElement?.closest('[data-side]') as HTMLElement;
64
+ expect(positioner).not.toBeNull();
65
+ expect(positioner.className).toContain('z-[var(--z-popover)]');
66
+ });
67
+
56
68
  test('disabled prevents the trigger from responding', () => {
57
69
  const onChange = mock(() => {});
58
70
  const { container } = render(
@@ -291,7 +291,17 @@ export function ComboboxField(props: ComboboxFieldProps) {
291
291
  </Combobox.InputGroup>
292
292
 
293
293
  <Combobox.Portal>
294
- <Combobox.Positioner sideOffset={4} collisionPadding={FLOATING_COLLISION_PADDING}>
294
+ {/* 🔴 The z-index goes on the POSITIONER, not only the popup. Base UI's positioner is
295
+ the element that is `position: absolute` (with a transform); the popup inside it is
296
+ `position: static`, so the popup's own `z-[var(--z-popover)]` does nothing and the
297
+ list painted UNDER any sibling with a z-index — measured 2026-09-22 in the
298
+ binary-server inspector, a sticky table header (z-index 1) covering the second option
299
+ of an open MultiSelectField. */}
300
+ <Combobox.Positioner
301
+ sideOffset={4}
302
+ collisionPadding={FLOATING_COLLISION_PADDING}
303
+ className='z-[var(--z-popover)]'
304
+ >
295
305
  <Combobox.Popup
296
306
  className={cn(
297
307
  FLOATING_CONTENT_BASE,
@@ -1,9 +1,8 @@
1
1
  import React from 'react';
2
- import type { PlayableVideo } from 'cursedbelt-core/media';
3
2
  import { Button } from '../components/Button';
4
3
  import { IconButton } from '../components/IconButton';
5
4
  import { useHlsSource } from './hlsSource';
6
- import { DEFAULT_VIDEO_ASPECT_RATIO, ProcessingIndicator } from './VideoPlayer';
5
+ import { DEFAULT_VIDEO_ASPECT_RATIO, type PlayerVideo, ProcessingIndicator } from './VideoPlayer';
7
6
 
8
7
  // ── The green-frame-immune fallback player ───────────────────────────────────────
9
8
  // On some GPUs Chrome composites a hardware-decoded <video> as a solid GREEN frame when it's
@@ -12,7 +11,7 @@ import { DEFAULT_VIDEO_ASPECT_RATIO, ProcessingIndicator } from './VideoPlayer';
12
11
  // reintroduced it). The decode is correct — drawing the video to a <canvas> yields right pixels.
13
12
  // So this keeps a HIDDEN <video> as the decode/audio/seek engine and paints each presented frame
14
13
  // to a <canvas> (composited as an ordinary texture, immune to the bug). It's a drop-in swap for
15
- // the Vidstack <VideoPlayer> (same PlayableVideo + props); switch to it if you see green frames.
14
+ // the Vidstack <VideoPlayer> (same PlayerVideo + props); switch to it if you see green frames.
16
15
  // Ported from ca's CanvasVideoPlayer (Tailwind, themed off cursedbelt's --accent token). The
17
16
  // source attach — native HLS / hls.js / progressive — is `hlsSource.ts`, shared with the
18
17
  // gallery's native stage.
@@ -26,7 +25,8 @@ function fmtTime(s: number): string {
26
25
  }
27
26
 
28
27
  export interface CanvasVideoPlayerProps {
29
- video: PlayableVideo;
28
+ /** Same clip shape as {@link VideoPlayer} — see {@link PlayerVideo}. */
29
+ video: PlayerVideo;
30
30
  autoPlay?: boolean;
31
31
  muted?: boolean;
32
32
  className?: string;
@@ -204,7 +204,8 @@ export function CanvasVideoPlayer({
204
204
  };
205
205
 
206
206
  const status = video.processingStatus;
207
- const showProcessing = status != null && status !== 'ready';
207
+ // An unreachable ladder is its own reason to draw — see `VideoPlayer`'s copy of this line.
208
+ const showProcessing = (status != null && status !== 'ready') || video.playbackDegraded === true;
208
209
 
209
210
  return (
210
211
  // Same intrinsic-sizing contract as <VideoPlayer>: aspect-ratio keeps the surface visible
@@ -234,7 +235,11 @@ export function CanvasVideoPlayer({
234
235
  />
235
236
 
236
237
  {showProcessing ? (
237
- <ProcessingIndicator status={status} progress={video.processingProgress} />
238
+ <ProcessingIndicator
239
+ status={status}
240
+ progress={video.processingProgress}
241
+ playbackDegraded={video.playbackDegraded}
242
+ />
238
243
  ) : null}
239
244
 
240
245
  {/* Controls */}
@@ -1,7 +1,8 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
- import { cleanup, render } from '@testing-library/react';
4
+ import type React from 'react';
5
+ import { cleanup, fireEvent, render } from '@testing-library/react';
5
6
  import { FrameGrabber } from './FrameGrabber';
6
7
 
7
8
  afterEach(cleanup);
@@ -173,3 +174,58 @@ describe('FrameGrabber — the exit is named in the dialog itself', () => {
173
174
  expect(text, 'nothing tells the reader how to leave without saving').toContain('escape');
174
175
  });
175
176
  });
177
+
178
+ /**
179
+ * 🔴 The dialog SAYS when it has no film, and keeps its hands off the save buttons.
180
+ *
181
+ * The turn dialog's report (`ideas/collections-09-14.md`: *"When I click the turn the video
182
+ * button I see a black screen"*) turned out to be true of four siblings, this one included: it
183
+ * was mounted `src={item.streamUrl ?? item.url ?? ''}` off the raw row, and an item is allowed
184
+ * to carry neither field (`resolveMedia` is the documented path for a signed URL). Here the
185
+ * consequence is a step beyond a bad preview: every output of this dialog is pixels read back
186
+ * off the `<video>`, so a black frame came with two live-looking save buttons that could only
187
+ * fail at `toBlob` — after somebody had scrubbed for their moment.
188
+ */
189
+ describe('FrameGrabber — when it cannot show the film', () => {
190
+ const openWith = (props: Partial<React.ComponentProps<typeof FrameGrabber>>) =>
191
+ render(
192
+ <FrameGrabber
193
+ src=''
194
+ name='wedding.mp4'
195
+ onClose={() => {}}
196
+ onSaveThumbnail={() => {}}
197
+ onSaveSnapshot={() => {}}
198
+ {...props}
199
+ />,
200
+ );
201
+
202
+ test('🔴 no source at all is a stated reason, not a black box', () => {
203
+ const { getByRole } = openWith({});
204
+ expect(body().querySelector('video')).toBeNull();
205
+ expect(getByRole('status').textContent).toContain('no video source');
206
+ });
207
+
208
+ test('🔴 the save button is HELD, because there is nothing to read a frame from', () => {
209
+ const { getByRole } = openWith({});
210
+ const keep = getByRole('button', { name: /thumbnail/i }) as HTMLButtonElement;
211
+ // DISABLED and still on screen, which is the `DESIGN.md` rule for a control that cannot
212
+ // work here: hiding it would make "this film has not arrived" look like "this app cannot
213
+ // choose a thumbnail", and the reason is already stated on the frame beside it.
214
+ expect(keep.disabled).toBe(true);
215
+ expect(body().contains(keep)).toBe(true);
216
+ });
217
+
218
+ test('🔴 a source that fails to load says so too', () => {
219
+ const { getByRole, queryByRole } = openWith({ src: '/films/gone.mp4' });
220
+ const film = body().querySelector('video');
221
+ expect(film).not.toBeNull();
222
+ expect(queryByRole('status')).toBeNull();
223
+ fireEvent.error(film as HTMLVideoElement);
224
+ expect(getByRole('status').textContent).toContain('could not be loaded');
225
+ });
226
+
227
+ test('a source still being resolved reads as "on its way", not as a failure', () => {
228
+ const { getByRole } = openWith({ src: '', resolving: true });
229
+ expect(getByRole('status').textContent).toContain('Finding this film');
230
+ });
231
+ });