cursedbelt 4.5.0 → 4.7.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 (45) hide show
  1. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  2. package/dist/react/file-tree/FileTree.js +19 -1
  3. package/dist/react/file-tree/FileTree.js.map +1 -1
  4. package/dist/react/media/HoverScrubVideoThumb.d.ts +7 -1
  5. package/dist/react/media/HoverScrubVideoThumb.d.ts.map +1 -1
  6. package/dist/react/media/HoverScrubVideoThumb.js +18 -3
  7. package/dist/react/media/HoverScrubVideoThumb.js.map +1 -1
  8. package/dist/react/media/previewGate.d.ts +36 -0
  9. package/dist/react/media/previewGate.d.ts.map +1 -0
  10. package/dist/react/media/previewGate.js +161 -0
  11. package/dist/react/media/previewGate.js.map +1 -0
  12. package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
  13. package/dist/react/media-gallery/GalleryTable.js +15 -3
  14. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  15. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  16. package/dist/react/media-gallery/MediaGallery.js +3 -1
  17. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  18. package/dist/react/media-gallery/galleryItemMedia.d.ts +32 -1
  19. package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
  20. package/dist/react/media-gallery/galleryItemMedia.js +64 -9
  21. package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
  22. package/dist/react/media-gallery/types.d.ts +8 -1
  23. package/dist/react/media-gallery/types.d.ts.map +1 -1
  24. package/dist/styles-areas/file-tree.css +1 -1
  25. package/dist/styles-areas/media-gallery.css +1 -1
  26. package/dist/styles-areas/media.css +1 -1
  27. package/package.json +3 -1
  28. package/src/declaredImports.spec.ts +66 -0
  29. package/src/publicSurface.spec.ts +12 -4
  30. package/src/react/file-tree/FileTree.tsx +19 -1
  31. package/src/react/file-tree/fileTreeThumbs.spec.tsx +90 -0
  32. package/src/react/media/HoverScrubVideoThumb.spec.tsx +4 -3
  33. package/src/react/media/HoverScrubVideoThumb.tsx +25 -3
  34. package/src/react/media/previewGate.spec.tsx +144 -0
  35. package/src/react/media/previewGate.ts +160 -0
  36. package/src/react/media-gallery/GalleryTable.tsx +14 -1
  37. package/src/react/media-gallery/MediaGallery.spec.tsx +52 -3
  38. package/src/react/media-gallery/MediaGallery.tsx +9 -1
  39. package/src/react/media-gallery/galleryItemMedia.spec.tsx +143 -0
  40. package/src/react/media-gallery/galleryItemMedia.tsx +80 -9
  41. package/src/react/media-gallery/types.ts +8 -1
  42. package/src/styles-areas/file-tree.css +1 -1
  43. package/src/styles-areas/media-gallery.css +1 -1
  44. package/src/styles-areas/media.css +1 -1
  45. package/scripts/publicSurface.ts +0 -458
@@ -166,7 +166,14 @@ export interface MediaGalleryItem extends MediaGalleryItemMedia {
166
166
  * Called once per item id per gallery mount (cached); return `null` when the bytes
167
167
  * are gone. Fields set directly on the item win over resolved ones. If the resolver
168
168
  * mints object URLs, revoking them stays the app's job.
169
+ *
170
+ * 🔴 `signal` aborts when every tile, row and stage waiting on this answer has unmounted
171
+ * before it arrived — a tile the viewer scrolled past. Hand it to your `fetch` (or drop the
172
+ * ask from your own queue) so an abandoned resolve stops costing origin time. The gallery
173
+ * evicts the abandoned entry, so a remount asks again rather than inheriting the abort; an
174
+ * abort is never read as "this item has no bytes". Ignoring the parameter is valid — the
175
+ * answer is then simply thrown away.
169
176
  */
170
- resolveMedia?: () => Promise<MediaGalleryItemMedia | null>;
177
+ resolveMedia?: (signal?: AbortSignal) => Promise<MediaGalleryItemMedia | null>;
171
178
  }
172
179
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/react/media-gallery/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACnD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAE9D;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,iBAAiB,GACzB,QAAQ,GACR,QAAQ,GACR,MAAM,GACN,WAAW,GACX,MAAM,GACN,UAAU,CAAC;AAEf;;;;GAIG;AACH,MAAM,WAAW,qBAAqB;IACpC,oFAAoF;IACpF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4CAA4C;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0FAA0F;IAC1F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;;;;;;;OAgBG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;CACrC;AAED;;;;;GAKG;AACH,MAAM,WAAW,gBAAiB,SAAQ,qBAAqB;IAC7D,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,+FAA+F;IAC/F,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,0FAA0F;IAC1F,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB;;;;;;;;;;;;;;;;;OAiBG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;;;;;;;;OAcG;IACH,WAAW,CAAC,EAAE;QAAE,KAAK,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IACzE;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,EAAE,YAAY,EAAE,CAAC;IAC1B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAAC;CAC5D"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/react/media-gallery/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACnD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAE9D;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,iBAAiB,GACzB,QAAQ,GACR,QAAQ,GACR,MAAM,GACN,WAAW,GACX,MAAM,GACN,UAAU,CAAC;AAEf;;;;GAIG;AACH,MAAM,WAAW,qBAAqB;IACpC,oFAAoF;IACpF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4CAA4C;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0FAA0F;IAC1F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;;;;;;;;;;OAgBG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;CACrC;AAED;;;;;GAKG;AACH,MAAM,WAAW,gBAAiB,SAAQ,qBAAqB;IAC7D,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,+FAA+F;IAC/F,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,0FAA0F;IAC1F,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB;;;;;;;;;;;;;;;;;OAiBG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;;;;;;;;OAcG;IACH,WAAW,CAAC,EAAE;QAAE,KAAK,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC;IACzE;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,EAAE,YAAY,EAAE,CAAC;IAC1B;;;;;;;;;;;;OAYG;IACH,YAAY,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAAC;CAChF"}
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 535 candidate(s) scanned.
18
+ * when this file is stale. 574 candidate(s) scanned.
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
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 2116 candidate(s) scanned.
18
+ * when this file is stale. 2162 candidate(s) scanned.
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
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 2825 candidate(s) scanned.
18
+ * when this file is stale. 2849 candidate(s) scanned.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "4.5.0",
3
+ "version": "4.7.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.",
@@ -47,6 +47,7 @@
47
47
  "e2e:touch": "playwright test --config e2e/playwright.config.ts --project=touch",
48
48
  "done:serial": "bun run typecheck && bun run lint && bun run test && bun run build",
49
49
  "paths": "bun run scripts/paths.ts",
50
+ "surface": "public-surface",
50
51
  "verify": "bun run gate",
51
52
  "prepublishOnly": "bun run gate --force",
52
53
  "styles:areas": "bun run scripts/generateAreaStyles.ts",
@@ -645,6 +646,7 @@
645
646
  "@vidstack/react": "1.15.6",
646
647
  "@vitejs/plugin-react": "^5.2.0",
647
648
  "cursedbelt-server": "^4.13.0",
649
+ "cursedops": "^0.2.7",
648
650
  "diff": "^9.0.0",
649
651
  "emoji-mart": "^5.6.0",
650
652
  "hls.js": "^1.5.0",
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { builtinModules } from 'node:module';
3
+ import pkg from '../package.json';
4
+
5
+ /**
6
+ * 🔴 Every bare module `src/` imports is declared in `package.json` — or no bundler has to agree.
7
+ *
8
+ * Measured 2026-09-18 (task 329): `mediabunny` and `react-filerobot-image-editor` were imported by
9
+ * the media barrel and declared nowhere, because 3.0.0 dropped them from `package.json` while the
10
+ * source kept both. Eight apps build with Vite, which treats an unresolvable dynamic import as a
11
+ * WARNING; `binary-server` builds with `Bun.build`, which fails the whole build. So the gap was
12
+ * invisible in eight repos and fatal in the ninth — and on 2026-09-22 a clean checkout of this
13
+ * repo could not even typecheck, because only this Mac's stale `node_modules` still held both.
14
+ * A `package.json` that is right for one bundler is documentation, not a contract; this makes it one.
15
+ *
16
+ * Specs are exempt (they may import devDependencies); `import type` is not — a consumer's
17
+ * typecheck resolves it too.
18
+ */
19
+ const declared = new Set([
20
+ pkg.name,
21
+ ...Object.keys((pkg as { dependencies?: Record<string, string> }).dependencies ?? {}),
22
+ ...Object.keys((pkg as { peerDependencies?: Record<string, string> }).peerDependencies ?? {}),
23
+ ]);
24
+ const builtin = new Set(builtinModules);
25
+
26
+ /** `@scope/pkg/deep` → `@scope/pkg`, `react/jsx-runtime` → `react`. */
27
+ export const packageOf = (specifier: string): string => {
28
+ const parts = specifier.split('/');
29
+ return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : (parts[0] as string);
30
+ };
31
+
32
+ const IMPORT = /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\(\s*)(['"])([^'"]+)\1/g;
33
+
34
+ export const undeclaredIn = (text: string, known: ReadonlySet<string>): string[] => {
35
+ const out: string[] = [];
36
+ for (const m of text.matchAll(IMPORT)) {
37
+ const spec = m[2] as string;
38
+ if (spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('node:') || spec.startsWith('bun:')) continue;
39
+ if (spec.startsWith('virtual:') || spec.includes('?')) continue;
40
+ const name = packageOf(spec);
41
+ if (builtin.has(name) || known.has(name)) continue;
42
+ out.push(spec);
43
+ }
44
+ return out;
45
+ };
46
+
47
+ describe('declared imports', () => {
48
+ it('the scanner sees a bare dynamic import, a scoped deep import and a type import', () => {
49
+ const known = new Set(['react']);
50
+ const text = "import('mediabunny');\nimport x from '@scope/pkg/deep';\nimport type { T } from 'zod';\nimport 'react/jsx-runtime';\nimport './local';\nimport fs from 'node:fs';";
51
+ expect(undeclaredIn(text, known)).toEqual(['mediabunny', '@scope/pkg/deep', 'zod']);
52
+ });
53
+
54
+ it('🔴 every bare module src/ imports is a dependency or peerDependency', () => {
55
+ const offenders: string[] = [];
56
+ // Bun.Glob, not node:fs — this suite's preload may virtualize node:fs (see publishShape).
57
+ for (const rel of new Bun.Glob('**/*.{ts,tsx}').scanSync({ cwd: import.meta.dir })) {
58
+ if (/\.(spec|test)\.tsx?$/.test(rel) || rel.endsWith('.d.ts')) continue;
59
+ const text = require('node:fs').readFileSync(`${import.meta.dir}/${rel}`, 'utf8') as string;
60
+ // Comments and prose may NAME a package; only code imports one.
61
+ const code = text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
62
+ for (const spec of undeclaredIn(code, declared)) offenders.push(`src/${rel}: ${spec}`);
63
+ }
64
+ expect(offenders, `imported but declared nowhere — add it to dependencies or (optional) peerDependencies:\n ${offenders.join('\n ')}`).toEqual([]);
65
+ });
66
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
- import { run } from '../scripts/publicSurface.js';
2
+ import { dirname } from 'node:path';
3
+ import { run } from 'cursedops/public-surface';
3
4
 
4
5
  /**
5
6
  * The public-surface ratchet, wired into `bun run verify` at its cheapest correct
@@ -9,10 +10,15 @@ import { run } from '../scripts/publicSurface.js';
9
10
  *
10
11
  * WHY it exists, WHAT a symbol count includes and excludes, why `bin` targets and
11
12
  * the dependency list are deliberately NOT counted here, and what `--prune` may
12
- * and may not do are all in the header of `scripts/publicSurface.ts` — one check,
13
+ * and may not do are all in the header of `cursedops/src/publicSurface.ts` — one check,
13
14
  * one baseline (`publicSurface.baseline`), one place that explains itself. This
14
15
  * file is only the wiring.
15
16
  *
17
+ * Since 2026-09-22 (task 084) the measurement is ONE body, `cursedops/public-surface`, a
18
+ * devDependency: this package's own `scripts/publicSurface.ts` had forked from the other
19
+ * two libraries' copies. `bun run surface [--prune]` is the CLI. The probes below were
20
+ * run against that former copy; the shared body measured identical per-subpath counts.
21
+ *
16
22
  * 🔴 Verified failing, 2026-09-13, before it was trusted — every branch, not just
17
23
  * the easy one. Each probe was applied, observed, and removed again:
18
24
  *
@@ -38,7 +44,9 @@ import { run } from '../scripts/publicSurface.js';
38
44
  *
39
45
  * A ratchet nobody has seen red is a ratchet nobody knows is wired up.
40
46
  */
41
- const result = await run();
47
+ // The package this spec lives in — resolved from this file, never from `process.cwd()`,
48
+ // because `bun test` can be started from anywhere.
49
+ const result = await run({ root: dirname(import.meta.dir) });
42
50
 
43
51
  describe('public surface', () => {
44
52
  it('has measured a real surface', () => {
@@ -69,7 +77,7 @@ describe('public surface', () => {
69
77
  // allowance behind for the next one to grow into.
70
78
  expect(
71
79
  result.stale,
72
- `surface came off and the ratchet was not tightened — run \`bun scripts/publicSurface.ts --prune\`:\n ${result.stale.join('\n ')}`,
80
+ `surface came off and the ratchet was not tightened — run \`bun run surface --prune\`:\n ${result.stale.join('\n ')}`,
73
81
  ).toEqual([]);
74
82
  });
75
83
  });
@@ -189,6 +189,7 @@ import {
189
189
  toggleSelected,
190
190
  topmostSelected,
191
191
  } from './fileTreeSelection.js';
192
+ import { useInView } from '../media/previewGate.js';
192
193
  import './fileTree.css';
193
194
 
194
195
  /** How far a press travels before it stops being a click and becomes a drag. */
@@ -597,6 +598,16 @@ function DefaultIcon({ node, open }: { node: FileTreeNode; open: boolean }): Rea
597
598
  *
598
599
  * `loading='lazy'` and `decoding='async'` because the consumer hands over a URL per row and a
599
600
  * 2,000-row tree must not open 2,000 connections to draw the twelve rows on screen.
601
+ *
602
+ * 🔴 …and `src` is withheld until the row has actually been ON SCREEN, because `lazy` alone
603
+ * does not bound it. A browser's lazy threshold is thousands of pixels past the viewport, and
604
+ * rows are ~24 px: measured 2026-09-22 in `apps/collections`' browser check, a 240-row folder
605
+ * in the rail had **174** thumbnail loads open at once under `loading='lazy'` — the whole of
606
+ * the owner's "I could not close the dev tools" peak, from sixteen-pixel pictures. `useInView`
607
+ * reports true only while the row is visible inside its scrolling ancestors; the first time it
608
+ * does, the picture is latched and the row stops being observed. The `<img>` itself stays in
609
+ * place meanwhile, so the row keeps its one glyph box — an off-screen row is blank, which
610
+ * nobody is looking at. No reporting observer (jsdom, happy-dom) means visible at once.
600
611
  */
601
612
  function RowGlyph({
602
613
  node,
@@ -610,12 +621,19 @@ function RowGlyph({
610
621
  const [failed, setFailed] = useState<string | null>(null);
611
622
  const icon = renderIcon ? renderIcon(node) : <DefaultIcon node={node} open={open} />;
612
623
  const src = node.thumbnailUrl;
624
+ const ref = useRef<HTMLImageElement>(null);
625
+ const [seen, setSeen] = useState(false);
626
+ const inView = useInView(ref, Boolean(src) && failed !== src && !seen);
627
+ useEffect(() => {
628
+ if (inView) setSeen(true);
629
+ }, [inView]);
613
630
  if (!src || failed === src) return icon;
614
631
  return (
615
632
  <img
633
+ ref={ref}
616
634
  key={src}
617
635
  className='cbft-thumb'
618
- src={src}
636
+ src={seen || inView ? src : undefined}
619
637
  alt=''
620
638
  aria-hidden='true'
621
639
  loading='lazy'
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The rail's row thumbnails load only once their row has been ON SCREEN — `RowGlyph` in
3
+ * `FileTree.tsx`. `loading='lazy'` alone let a 240-row folder open 174 thumbnail loads at once
4
+ * (measured 2026-09-22 in `apps/collections`' browser check), because a browser's lazy threshold
5
+ * is thousands of pixels and a row is ~24.
6
+ *
7
+ * happy-dom's `IntersectionObserver` never reports, so this installs one it can drive, the way
8
+ * `media/previewGate.spec.tsx` does.
9
+ */
10
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
11
+ import { act, cleanup, render } from '@testing-library/react';
12
+ import { FileTree } from './FileTree.js';
13
+
14
+ type Callback = (entries: Array<{ target: Element; isIntersecting: boolean }>) => void;
15
+ let observers: FakeObserver[] = [];
16
+
17
+ class FakeObserver {
18
+ targets = new Set<Element>();
19
+ constructor(private readonly callback: Callback) {
20
+ observers.push(this);
21
+ }
22
+ observe(el: Element) {
23
+ this.targets.add(el);
24
+ }
25
+ unobserve(el: Element) {
26
+ this.targets.delete(el);
27
+ }
28
+ disconnect() {
29
+ this.targets.clear();
30
+ }
31
+ takeRecords() {
32
+ return [];
33
+ }
34
+ get thresholds(): number[] {
35
+ return [0];
36
+ }
37
+ fire(el: Element, isIntersecting: boolean) {
38
+ this.callback([{ target: el, isIntersecting }]);
39
+ }
40
+ }
41
+
42
+ const g = globalThis as { IntersectionObserver?: unknown };
43
+ const original = g.IntersectionObserver;
44
+
45
+ beforeEach(() => {
46
+ observers = [];
47
+ g.IntersectionObserver = class extends FakeObserver {};
48
+ });
49
+
50
+ afterEach(() => {
51
+ cleanup();
52
+ if (original === undefined) delete g.IntersectionObserver;
53
+ else g.IntersectionObserver = original;
54
+ });
55
+
56
+ const rows = Array.from({ length: 3 }, (_, i) => ({
57
+ id: `p${i}`,
58
+ name: `photo${i}.jpg`,
59
+ kind: 'file' as const,
60
+ thumbnailUrl: `/t/p${i}.jpg`,
61
+ }));
62
+
63
+ describe('a row thumbnail waits for its row to be seen', () => {
64
+ test('🔴 an off-screen row asks for nothing; the first sight loads it, and it stays loaded', () => {
65
+ const { container } = render(<FileTree nodes={rows} selectedId={null} onSelect={() => undefined} />);
66
+ const imgs = [...container.querySelectorAll('img.cbft-thumb')] as HTMLImageElement[];
67
+ expect(imgs).toHaveLength(3);
68
+ // Nothing reported yet: no row has a src, so no request has gone out.
69
+ expect(imgs.map((img) => img.getAttribute('src'))).toEqual([null, null, null]);
70
+
71
+ const observer = observers.find((o) => o.targets.size > 0) as FakeObserver;
72
+ const [first] = imgs;
73
+ act(() => observer.fire(first as Element, true));
74
+ expect(first?.getAttribute('src')).toBe('/t/p0.jpg');
75
+ expect(imgs[1]?.getAttribute('src')).toBeNull();
76
+
77
+ // Scrolled away again: the picture is kept, and the row is no longer observed.
78
+ act(() => observer.fire(first as Element, false));
79
+ expect(first?.getAttribute('src')).toBe('/t/p0.jpg');
80
+ expect(observer.targets.has(first as Element)).toBe(false);
81
+ expect(observer.targets.size).toBe(2);
82
+ });
83
+
84
+ test('with no reporting observer every thumbnail loads at once — the old behavior, never none', () => {
85
+ delete g.IntersectionObserver;
86
+ const { container } = render(<FileTree nodes={rows} selectedId={null} onSelect={() => undefined} />);
87
+ const srcs = [...container.querySelectorAll('img.cbft-thumb')].map((img) => img.getAttribute('src'));
88
+ expect(srcs).toEqual(['/t/p0.jpg', '/t/p1.jpg', '/t/p2.jpg']);
89
+ });
90
+ });
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test';
2
- import { cleanup, fireEvent, render } from '@testing-library/react';
2
+ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
3
3
  import type { PlayableVideo } from 'cursedbelt-core/media';
4
4
  import { HoverScrubVideoThumb } from './HoverScrubVideoThumb.js';
5
5
 
@@ -68,9 +68,10 @@ describe('HoverScrubVideoThumb', () => {
68
68
  expect(container.querySelectorAll('svg').length).toBeLessThan(before);
69
69
  });
70
70
 
71
- test('autoPlay mode previews without hover and renders without onOpen as a non-button', () => {
71
+ test('autoPlay mode previews without hover and renders without onOpen as a non-button', async () => {
72
72
  const { container, queryByRole } = render(<HoverScrubVideoThumb video={video} autoPlay />);
73
- expect(container.querySelector('video')).not.toBeNull();
73
+ // No IntersectionObserver in happy-dom → the tile counts as visible, and starts once settled.
74
+ await waitFor(() => expect(container.querySelector('video')).not.toBeNull());
74
75
  expect(queryByRole('button')).toBeNull();
75
76
  });
76
77
 
@@ -4,6 +4,7 @@ import type { PlayableVideo } from 'cursedbelt-core/media';
4
4
  import { formatTimecode } from 'cursedbelt-core/media';
5
5
  import { cn } from '../lib/cn.js';
6
6
  import { NO_EDIT, previewTransform, swapsAxes } from './pictureEdit.js';
7
+ import { releaseVideo, useInView, useSettled } from './previewGate.js';
7
8
  import type { MediaOrientation } from './VideoTurner.js';
8
9
 
9
10
  /**
@@ -104,7 +105,13 @@ export interface HoverScrubVideoThumbProps {
104
105
  * - `loop`: plays from the start, muted, looping.
105
106
  */
106
107
  previewMode?: 'scrub' | 'loop';
107
- /** Preview continuously without hover — the watch page's silent side tiles. */
108
+ /**
109
+ * Preview continuously without hover — the watch page's silent side tiles.
110
+ *
111
+ * 🔴 Gated: the preview starts only once the tile is ON SCREEN and has stayed there for
112
+ * `PREVIEW_SETTLE_MS` (`previewGate.ts`), so a grid scrolled past fast never opens a stream
113
+ * per row. Without an `IntersectionObserver` the tile counts as visible.
114
+ */
108
115
  autoPlay?: boolean;
109
116
  /** Small overlay chips (top-left slot) — e.g. a quality badge. */
110
117
  badge?: ReactNode;
@@ -149,7 +156,11 @@ export function HoverScrubVideoThumb({
149
156
  // On touch devices ignore `autoPlay` — an unrequested video playing under the
150
157
  // finger reads as a bug (and iOS shows a stack of them below the watch player).
151
158
  // Touch-and-hold still triggers `hovering` → a preview on demand.
152
- const previewing = (autoPlay && canHover) || hovering;
159
+ const boxRef = useRef<HTMLDivElement>(null);
160
+ const wantsAuto = autoPlay && canHover;
161
+ // A hover is not gated: the pointer on the tile IS the intent the settle exists to infer.
162
+ const autoReady = useSettled(useInView(boxRef, wantsAuto));
163
+ const previewing = autoReady || hovering;
153
164
  const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
154
165
  const videoRef = useRef<HTMLVideoElement>(null);
155
166
 
@@ -167,15 +178,23 @@ export function HoverScrubVideoThumb({
167
178
  durationSeconds: video.durationSeconds,
168
179
  });
169
180
 
181
+ // A preview that stops closes its connection — an unmounted `<video>` keeps buffering.
182
+ useEffect(() => {
183
+ if (!previewing) return;
184
+ const el = videoRef.current;
185
+ return () => releaseVideo(el);
186
+ }, [previewing]);
187
+
170
188
  const turned = turnStyle(orientation);
171
189
 
172
190
  const media = (
173
- <div className='relative aspect-video w-full overflow-hidden bg-muted'>
191
+ <div ref={boxRef} className='relative aspect-video w-full overflow-hidden bg-muted'>
174
192
  {video.posterUrl && !posterFailed ? (
175
193
  <img
176
194
  src={video.posterUrl}
177
195
  alt={video.name}
178
196
  loading='lazy'
197
+ decoding='async'
179
198
  /*
180
199
  * 🔴 A poster URL that 404s falls back to the Play mark, and this is
181
200
  * REQUIRED rather than defensive.
@@ -255,6 +274,9 @@ export function HoverScrubVideoThumb({
255
274
  <video
256
275
  ref={videoRef}
257
276
  src={video.streamUrl}
277
+ // Only mounted once previewing (settled or hovered), so metadata is the most it may
278
+ // fetch before it plays — never the whole-file `auto` a browser may default to.
279
+ preload='metadata'
258
280
  muted
259
281
  loop
260
282
  autoPlay
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The video half of the gallery's load ceiling (`previewGate.ts`): an automatic preview starts
3
+ * only once its tile is on screen AND has settled there, and a tile scrolled past never starts.
4
+ *
5
+ * happy-dom's `IntersectionObserver` never reports, so each test installs a fake it can drive —
6
+ * a fresh class per test, which is also what proves the shared observer follows a replaced
7
+ * global instead of observing through a stale one.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
10
+ import { act, cleanup, render, waitFor } from '@testing-library/react';
11
+ import type { PlayableVideo } from 'cursedbelt-core/media';
12
+ import { HoverScrubVideoThumb } from './HoverScrubVideoThumb.js';
13
+ import { PREVIEW_SETTLE_MS, releaseVideo } from './previewGate.js';
14
+
15
+ const video: PlayableVideo = {
16
+ id: 'v1',
17
+ name: 'Clip one',
18
+ streamUrl: 'https://example.test/v1.mp4',
19
+ posterUrl: 'https://example.test/v1.jpg',
20
+ durationSeconds: 90,
21
+ };
22
+
23
+ type Callback = (entries: Array<{ target: Element; isIntersecting: boolean }>) => void;
24
+ let observers: FakeObserver[] = [];
25
+
26
+ class FakeObserver {
27
+ targets = new Set<Element>();
28
+ constructor(private readonly callback: Callback) {
29
+ observers.push(this);
30
+ }
31
+ observe(el: Element) {
32
+ this.targets.add(el);
33
+ }
34
+ unobserve(el: Element) {
35
+ this.targets.delete(el);
36
+ }
37
+ disconnect() {
38
+ this.targets.clear();
39
+ }
40
+ takeRecords() {
41
+ return [];
42
+ }
43
+ /** On the PROTOTYPE, as the real interface has it — what `reportingObserver` checks for. */
44
+ get thresholds(): number[] {
45
+ return [0];
46
+ }
47
+ fire(isIntersecting: boolean) {
48
+ this.callback([...this.targets].map((target) => ({ target, isIntersecting })));
49
+ }
50
+ }
51
+
52
+ const g = globalThis as { IntersectionObserver?: unknown };
53
+ /** Whatever the environment had, restored after each test so no later spec file inherits a fake. */
54
+ const original = g.IntersectionObserver;
55
+
56
+ beforeEach(() => {
57
+ observers = [];
58
+ // A NEW class each test: the gate must rebuild its shared observer for a replaced global.
59
+ g.IntersectionObserver = class extends FakeObserver {};
60
+ });
61
+
62
+ afterEach(() => {
63
+ cleanup();
64
+ if (original === undefined) delete g.IntersectionObserver;
65
+ else g.IntersectionObserver = original;
66
+ });
67
+
68
+ const live = () => observers.find((o) => o.targets.size > 0) as FakeObserver;
69
+ const pause = (ms: number) =>
70
+ act(async () => {
71
+ await new Promise((resolve) => setTimeout(resolve, ms));
72
+ });
73
+
74
+ describe('previewGate — an automatic preview waits until the tile is on screen and settled', () => {
75
+ test('🔴 an off-screen autoPlay tile has no <video> — no src, nothing autoplaying', async () => {
76
+ const { container } = render(<HoverScrubVideoThumb video={video} onOpen={() => {}} autoPlay />);
77
+ await waitFor(() => expect(live()).toBeDefined());
78
+ act(() => live().fire(false));
79
+ await pause(PREVIEW_SETTLE_MS + 60);
80
+ expect(container.querySelector('video')).toBeNull();
81
+ // The poster still stands in, and it is itself lazy.
82
+ expect(container.querySelector('img')?.getAttribute('loading')).toBe('lazy');
83
+ });
84
+
85
+ test('in view: nothing at once, the preview after it settles — with preload capped', async () => {
86
+ const { container } = render(<HoverScrubVideoThumb video={video} onOpen={() => {}} autoPlay />);
87
+ await waitFor(() => expect(live()).toBeDefined());
88
+ act(() => live().fire(true));
89
+ // Not yet: a tile crossing the viewport during a scroll is exactly this moment.
90
+ expect(container.querySelector('video')).toBeNull();
91
+ await waitFor(() => expect(container.querySelector('video')).not.toBeNull());
92
+ const el = container.querySelector('video') as HTMLVideoElement;
93
+ expect(el.getAttribute('src')).toBe(video.streamUrl);
94
+ expect(el.getAttribute('preload')).toBe('metadata');
95
+ });
96
+
97
+ test('🔴 a tile that leaves before settling never starts its load — the fast scroll', async () => {
98
+ const { container } = render(<HoverScrubVideoThumb video={video} onOpen={() => {}} autoPlay />);
99
+ await waitFor(() => expect(live()).toBeDefined());
100
+ act(() => live().fire(true));
101
+ await pause(PREVIEW_SETTLE_MS / 3);
102
+ act(() => live().fire(false));
103
+ await pause(PREVIEW_SETTLE_MS + 60);
104
+ expect(container.querySelector('video')).toBeNull();
105
+ });
106
+
107
+ test('a preview that scrolls off stops at once and its connection is released', async () => {
108
+ const { container } = render(<HoverScrubVideoThumb video={video} onOpen={() => {}} autoPlay />);
109
+ await waitFor(() => expect(live()).toBeDefined());
110
+ act(() => live().fire(true));
111
+ await waitFor(() => expect(container.querySelector('video')).not.toBeNull());
112
+ const el = container.querySelector('video') as HTMLVideoElement;
113
+ act(() => live().fire(false));
114
+ expect(container.querySelector('video')).toBeNull();
115
+ // Removing the element does not close its request; emptying `src` does.
116
+ expect(el.hasAttribute('src')).toBe(false);
117
+ });
118
+
119
+ test('🔴 an INERT observer (happy-dom 20 ships one) counts as absent, so the tile still previews', async () => {
120
+ g.IntersectionObserver = class {
121
+ observe() {}
122
+ unobserve() {}
123
+ disconnect() {}
124
+ takeRecords() {
125
+ return [];
126
+ }
127
+ };
128
+ const { container } = render(<HoverScrubVideoThumb video={video} onOpen={() => {}} autoPlay />);
129
+ await waitFor(() => expect(container.querySelector('video')).not.toBeNull());
130
+ });
131
+
132
+ test('without autoPlay no tile is observed at all', () => {
133
+ render(<HoverScrubVideoThumb video={video} onOpen={() => {}} />);
134
+ expect(observers.every((o) => o.targets.size === 0)).toBe(true);
135
+ });
136
+
137
+ test('releaseVideo tolerates null and an element with no media support', () => {
138
+ expect(() => releaseVideo(null)).not.toThrow();
139
+ const el = document.createElement('video');
140
+ el.setAttribute('src', '/x.mp4');
141
+ releaseVideo(el);
142
+ expect(el.hasAttribute('src')).toBe(false);
143
+ });
144
+ });