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.
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +19 -1
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/media/HoverScrubVideoThumb.d.ts +7 -1
- package/dist/react/media/HoverScrubVideoThumb.d.ts.map +1 -1
- package/dist/react/media/HoverScrubVideoThumb.js +18 -3
- package/dist/react/media/HoverScrubVideoThumb.js.map +1 -1
- package/dist/react/media/previewGate.d.ts +36 -0
- package/dist/react/media/previewGate.d.ts.map +1 -0
- package/dist/react/media/previewGate.js +161 -0
- package/dist/react/media/previewGate.js.map +1 -0
- package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
- package/dist/react/media-gallery/GalleryTable.js +15 -3
- package/dist/react/media-gallery/GalleryTable.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +3 -1
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.d.ts +32 -1
- package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.js +64 -9
- package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +8 -1
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/styles-areas/file-tree.css +1 -1
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/media.css +1 -1
- package/package.json +3 -1
- package/src/declaredImports.spec.ts +66 -0
- package/src/publicSurface.spec.ts +12 -4
- package/src/react/file-tree/FileTree.tsx +19 -1
- package/src/react/file-tree/fileTreeThumbs.spec.tsx +90 -0
- package/src/react/media/HoverScrubVideoThumb.spec.tsx +4 -3
- package/src/react/media/HoverScrubVideoThumb.tsx +25 -3
- package/src/react/media/previewGate.spec.tsx +144 -0
- package/src/react/media/previewGate.ts +160 -0
- package/src/react/media-gallery/GalleryTable.tsx +14 -1
- package/src/react/media-gallery/MediaGallery.spec.tsx +52 -3
- package/src/react/media-gallery/MediaGallery.tsx +9 -1
- package/src/react/media-gallery/galleryItemMedia.spec.tsx +143 -0
- package/src/react/media-gallery/galleryItemMedia.tsx +80 -9
- package/src/react/media-gallery/types.ts +8 -1
- package/src/styles-areas/file-tree.css +1 -1
- package/src/styles-areas/media-gallery.css +1 -1
- package/src/styles-areas/media.css +1 -1
- 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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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 `
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
+
});
|