cursedbelt 4.1.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/react/master-lock/MasterLockDialog.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockDialog.js +25 -5
- package/dist/react/master-lock/MasterLockDialog.js.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.d.ts +19 -0
- package/dist/react/master-lock/MasterLockProvider.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.js +19 -0
- package/dist/react/master-lock/MasterLockProvider.js.map +1 -1
- package/dist/react/media/CanvasVideoPlayer.d.ts +3 -2
- package/dist/react/media/CanvasVideoPlayer.d.ts.map +1 -1
- package/dist/react/media/CanvasVideoPlayer.js +4 -3
- package/dist/react/media/CanvasVideoPlayer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +16 -2
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +68 -43
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/PictureEditor.d.ts +17 -2
- package/dist/react/media/PictureEditor.d.ts.map +1 -1
- package/dist/react/media/PictureEditor.js +33 -7
- package/dist/react/media/PictureEditor.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +17 -2
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
- package/dist/react/media/VideoChapterEditor.js +21 -3
- package/dist/react/media/VideoChapterEditor.js.map +1 -1
- package/dist/react/media/VideoCutEditor.d.ts +16 -2
- package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
- package/dist/react/media/VideoCutEditor.js +21 -3
- package/dist/react/media/VideoCutEditor.js.map +1 -1
- package/dist/react/media/VideoPlayer.d.ts +37 -9
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +130 -39
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/hlsSource.d.ts +24 -3
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +109 -8
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/pictureEditor.css +2 -0
- package/dist/react/media/sourceFault.d.ts +146 -0
- package/dist/react/media/sourceFault.d.ts.map +1 -0
- package/dist/react/media/sourceFault.js +139 -0
- package/dist/react/media/sourceFault.js.map +1 -0
- package/dist/react/media/videoChapterEditor.css +19 -0
- package/dist/react/media/videoCutEditor.css +22 -0
- package/dist/react/media-gallery/MediaGallery.d.ts +12 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +166 -13
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.d.ts +4 -0
- package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.js +27 -3
- package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
- package/dist/react/media-gallery/mediaGalleryModel.d.ts.map +1 -1
- package/dist/react/media-gallery/mediaGalleryModel.js +5 -0
- package/dist/react/media-gallery/mediaGalleryModel.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +23 -0
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts +1 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/master-lock.css +1 -1
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/media.css +1 -1
- package/package.json +3 -2
- package/scripts/guardrailsEnforce.spec.ts +244 -1
- package/scripts/guardrailsEnforce.ts +224 -7
- package/src/iframesMakeNoRealRequest.spec.ts +92 -0
- package/src/react/components/IsolatedFrame.spec.tsx +27 -9
- package/src/react/master-lock/MasterLockDialog.tsx +24 -5
- package/src/react/master-lock/MasterLockProvider.spec.tsx +39 -0
- package/src/react/master-lock/MasterLockProvider.tsx +19 -0
- package/src/react/media/CanvasVideoPlayer.tsx +11 -6
- package/src/react/media/FrameGrabber.spec.tsx +57 -1
- package/src/react/media/FrameGrabber.tsx +138 -84
- package/src/react/media/PictureEditor.spec.tsx +90 -0
- package/src/react/media/PictureEditor.tsx +78 -23
- package/src/react/media/VideoChapterEditor.spec.tsx +86 -0
- package/src/react/media/VideoChapterEditor.tsx +64 -18
- package/src/react/media/VideoCutEditor.spec.tsx +44 -0
- package/src/react/media/VideoCutEditor.tsx +63 -18
- package/src/react/media/VideoPlayer.spec.tsx +101 -0
- package/src/react/media/VideoPlayer.tsx +221 -73
- package/src/react/media/hlsSource.answer.spec.ts +255 -0
- package/src/react/media/hlsSource.ts +136 -8
- package/src/react/media/pictureEditor.css +2 -0
- package/src/react/media/sourceFault.spec.tsx +224 -0
- package/src/react/media/sourceFault.tsx +220 -0
- package/src/react/media/videoChapterEditor.css +19 -0
- package/src/react/media/videoCutEditor.css +22 -0
- package/src/react/media-gallery/MediaGallery.spec.tsx +288 -0
- package/src/react/media-gallery/MediaGallery.tsx +356 -45
- package/src/react/media-gallery/galleryItemMedia.tsx +27 -4
- package/src/react/media-gallery/mediaGalleryModel.spec.ts +36 -0
- package/src/react/media-gallery/mediaGalleryModel.ts +4 -0
- package/src/react/media-gallery/types.ts +23 -0
- package/src/styles-areas/master-lock.css +1 -1
- package/src/styles-areas/media-gallery.css +1 -1
- package/src/styles-areas/media.css +1 -1
|
@@ -77,6 +77,12 @@ import {
|
|
|
77
77
|
} from '../hooks/useCheckboxSelection';
|
|
78
78
|
import { DenseGalleryDetail, DenseGalleryShell } from '../media/DenseGalleryShell';
|
|
79
79
|
import { useHlsSource } from '../media/hlsSource';
|
|
80
|
+
import {
|
|
81
|
+
isRecoverableFault,
|
|
82
|
+
probeSourceFault,
|
|
83
|
+
type SourceFault,
|
|
84
|
+
sourceFaultMessage,
|
|
85
|
+
} from '../media/sourceFault';
|
|
80
86
|
import { HoverScrubVideoThumb } from '../media/HoverScrubVideoThumb';
|
|
81
87
|
import { isPlayPauseSpace } from '../media/playerSpaceKey';
|
|
82
88
|
import {
|
|
@@ -218,6 +224,45 @@ function TurnVideoDialog({
|
|
|
218
224
|
);
|
|
219
225
|
}
|
|
220
226
|
|
|
227
|
+
/**
|
|
228
|
+
* The picture editor, over the item's RESOLVED media — never over the raw row.
|
|
229
|
+
*
|
|
230
|
+
* 🔴 The same defect as {@link TurnVideoDialog}'s, and the worst of the five to leave in place:
|
|
231
|
+
* this dialog BAKES pixels. Mounted as `src={item.url ?? ''}` it showed an empty frame on any
|
|
232
|
+
* app that signs its URLs, with a live-looking row of sliders over it and a Save that could
|
|
233
|
+
* only write a blank or silently do nothing. The editor now states the reason and holds its
|
|
234
|
+
* saves (see `PictureEditor`'s `cannotShow`), and the source it is handed is the one
|
|
235
|
+
* {@link useItemMedia} resolved — `url`, because a picture has no stream and a thumbnail is
|
|
236
|
+
* the wrong bytes to re-encode.
|
|
237
|
+
*/
|
|
238
|
+
function EditPictureDialog({
|
|
239
|
+
item,
|
|
240
|
+
cache,
|
|
241
|
+
allowReplace,
|
|
242
|
+
onClose,
|
|
243
|
+
onSave,
|
|
244
|
+
}: {
|
|
245
|
+
item: MediaGalleryItem;
|
|
246
|
+
cache: MediaCache;
|
|
247
|
+
allowReplace: boolean;
|
|
248
|
+
onClose: () => void;
|
|
249
|
+
onSave: (result: PictureEditResult) => void | Promise<void>;
|
|
250
|
+
}) {
|
|
251
|
+
const { media, resolving } = useItemMedia(item, cache);
|
|
252
|
+
return (
|
|
253
|
+
<LazyPictureEditor
|
|
254
|
+
src={media.url ?? ''}
|
|
255
|
+
// So an unresolved picture reads as "still finding it" for the moment it takes, rather
|
|
256
|
+
// than flashing the "cannot show it" reason at somebody whose photograph is on its way.
|
|
257
|
+
resolving={resolving}
|
|
258
|
+
fileName={item.name}
|
|
259
|
+
allowReplace={allowReplace}
|
|
260
|
+
onClose={onClose}
|
|
261
|
+
onSave={onSave}
|
|
262
|
+
/>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
221
266
|
// Marking sections of a clip to cut out. Lazy for the usual reason and one extra: an app that
|
|
222
267
|
// has no pipeline to execute a cut list never passes `onTrimVideo`, so it never loads a byte
|
|
223
268
|
// of this — which is what makes "enabled for collections only, for now" a fact about the
|
|
@@ -225,12 +270,128 @@ function TurnVideoDialog({
|
|
|
225
270
|
const LazyVideoCutEditor = React.lazy(() =>
|
|
226
271
|
import('../media/VideoCutEditor').then((m) => ({ default: m.VideoCutEditor })),
|
|
227
272
|
);
|
|
273
|
+
/**
|
|
274
|
+
* The trim dialog, over the item's RESOLVED media — never over the raw row. Same defect, same
|
|
275
|
+
* fix and the same shared cache as {@link TurnVideoDialog}, whose header carries the whole
|
|
276
|
+
* story; what is particular to this one is that marking a cut means scrubbing to a moment, so
|
|
277
|
+
* a source-less player does not merely look broken, it makes the dialog's one output
|
|
278
|
+
* unreachable — which is why `VideoCutEditor` now says so rather than painting black.
|
|
279
|
+
*/
|
|
280
|
+
function TrimVideoDialog({
|
|
281
|
+
item,
|
|
282
|
+
cache,
|
|
283
|
+
note,
|
|
284
|
+
onClose,
|
|
285
|
+
onSubmit,
|
|
286
|
+
}: {
|
|
287
|
+
item: MediaGalleryItem;
|
|
288
|
+
cache: MediaCache;
|
|
289
|
+
note?: React.ReactNode;
|
|
290
|
+
onClose: () => void;
|
|
291
|
+
onSubmit: (cuts: VideoCut[]) => void | Promise<void>;
|
|
292
|
+
}) {
|
|
293
|
+
const { media, resolving } = useItemMedia(item, cache);
|
|
294
|
+
// `streamUrl ?? url`, the same preference the stage and the turner make: a plain `url` is not
|
|
295
|
+
// always a streamable source, and a scrubber given the wrong one cannot seek.
|
|
296
|
+
const src = media.streamUrl ?? media.url ?? '';
|
|
297
|
+
const poster = media.posterUrl ?? media.thumbnailUrl;
|
|
298
|
+
return (
|
|
299
|
+
<LazyVideoCutEditor
|
|
300
|
+
src={src}
|
|
301
|
+
{...(poster ? { posterUrl: poster } : {})}
|
|
302
|
+
resolving={resolving}
|
|
303
|
+
name={item.name}
|
|
304
|
+
durationSeconds={item.durationSeconds ?? null}
|
|
305
|
+
{...(note ? { note } : {})}
|
|
306
|
+
onClose={onClose}
|
|
307
|
+
onSubmit={onSubmit}
|
|
308
|
+
/>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
228
311
|
// Naming the parts of a clip. The same bargain as the cut editor one line up: an app that has
|
|
229
312
|
// nowhere to PUT a chapter list never passes `onSaveChapters`, so it never loads a byte of
|
|
230
313
|
// this dialog — the capability and the bundle cost turn on together, with no flag between them.
|
|
231
314
|
const LazyVideoChapterEditor = React.lazy(() =>
|
|
232
315
|
import('../media/VideoChapterEditor').then((m) => ({ default: m.VideoChapterEditor })),
|
|
233
316
|
);
|
|
317
|
+
/**
|
|
318
|
+
* The sections dialog, over the item's RESOLVED media — never over the raw row. Same defect,
|
|
319
|
+
* same fix and the same shared cache as {@link TurnVideoDialog}; what is particular here is
|
|
320
|
+
* that a section is metadata stored BESIDE the film, so a clip nobody can play is still a clip
|
|
321
|
+
* whose existing sections can be renamed, removed and saved — `VideoChapterEditor` says that
|
|
322
|
+
* in the same breath as it says there is nothing to scrub.
|
|
323
|
+
*/
|
|
324
|
+
function ChapterVideoDialog({
|
|
325
|
+
item,
|
|
326
|
+
cache,
|
|
327
|
+
onClose,
|
|
328
|
+
onSubmit,
|
|
329
|
+
}: {
|
|
330
|
+
item: MediaGalleryItem;
|
|
331
|
+
cache: MediaCache;
|
|
332
|
+
onClose: () => void;
|
|
333
|
+
onSubmit: (chapters: VideoChapter[]) => void | Promise<void>;
|
|
334
|
+
}) {
|
|
335
|
+
const { media, resolving } = useItemMedia(item, cache);
|
|
336
|
+
const src = media.streamUrl ?? media.url ?? '';
|
|
337
|
+
const poster = media.posterUrl ?? media.thumbnailUrl;
|
|
338
|
+
return (
|
|
339
|
+
<LazyVideoChapterEditor
|
|
340
|
+
src={src}
|
|
341
|
+
{...(poster ? { posterUrl: poster } : {})}
|
|
342
|
+
resolving={resolving}
|
|
343
|
+
name={item.name}
|
|
344
|
+
durationSeconds={item.durationSeconds ?? null}
|
|
345
|
+
{...(item.chapters ? { chapters: item.chapters } : {})}
|
|
346
|
+
onClose={onClose}
|
|
347
|
+
onSubmit={onSubmit}
|
|
348
|
+
/>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* The frame grabber, over the item's RESOLVED media — never over the raw row. Same defect,
|
|
353
|
+
* same fix and the same shared cache as {@link TurnVideoDialog}; what is particular here is
|
|
354
|
+
* that every output of that dialog is pixels read back off the `<video>`, so an unresolved
|
|
355
|
+
* source leaves it with nothing to save at all — which is why `FrameGrabber` now states the
|
|
356
|
+
* reason and holds its two save buttons rather than offering them over a black frame.
|
|
357
|
+
*
|
|
358
|
+
* `hasPoster` is read off the RESOLVED media too: an app that mints its poster URL alongside
|
|
359
|
+
* its stream would otherwise be told this film has no picture, and the thumbnail button would
|
|
360
|
+
* offer to "choose" one it is in fact about to replace.
|
|
361
|
+
*/
|
|
362
|
+
function GrabFrameDialog({
|
|
363
|
+
item,
|
|
364
|
+
at,
|
|
365
|
+
cache,
|
|
366
|
+
onClose,
|
|
367
|
+
onSaveThumbnail,
|
|
368
|
+
onSaveSnapshot,
|
|
369
|
+
}: {
|
|
370
|
+
item: MediaGalleryItem;
|
|
371
|
+
at: number;
|
|
372
|
+
cache: MediaCache;
|
|
373
|
+
onClose: () => void;
|
|
374
|
+
onSaveThumbnail?: ((poster: Blob) => void | Promise<void>) | undefined;
|
|
375
|
+
onSaveSnapshot?: ((still: Blob, atSeconds: number) => void | Promise<void>) | undefined;
|
|
376
|
+
}) {
|
|
377
|
+
const { media, resolving } = useItemMedia(item, cache);
|
|
378
|
+
const src = media.streamUrl ?? media.url ?? '';
|
|
379
|
+
return (
|
|
380
|
+
<LazyFrameGrabber
|
|
381
|
+
src={src}
|
|
382
|
+
resolving={resolving}
|
|
383
|
+
name={item.name}
|
|
384
|
+
startAt={at}
|
|
385
|
+
// So the dialog can SAY that saving replaces the picture this film shows now, rather
|
|
386
|
+
// than overwriting somebody's chosen poster silently — the owner's copy-not-overwrite
|
|
387
|
+
// ruling applied to the one case in that dialog where a replacement is unavoidable.
|
|
388
|
+
hasPoster={Boolean(media.posterUrl)}
|
|
389
|
+
onClose={onClose}
|
|
390
|
+
{...(onSaveThumbnail ? { onSaveThumbnail } : {})}
|
|
391
|
+
{...(onSaveSnapshot ? { onSaveSnapshot } : {})}
|
|
392
|
+
/>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
234
395
|
// Tagging and describing, on one file or on a ticked selection. Same bargain again: an app
|
|
235
396
|
// with nowhere to PUT a tag never passes `onSaveMeta`, so it never loads a byte of this
|
|
236
397
|
// dialog — and the gallery keeps reading whatever tags and descriptions its items already
|
|
@@ -800,12 +961,21 @@ function BytesStage({
|
|
|
800
961
|
* listed here, so the feature was invisible with nothing anywhere reporting a fault. It is a
|
|
801
962
|
* pure function so a test can read it directly; the rendered player is a Vidstack custom
|
|
802
963
|
* element that happy-dom never brings to life, so no DOM assertion can see any of this.
|
|
964
|
+
*
|
|
965
|
+
* 🔴 The return type is spelled out rather than imported as `PlayerVideo` from
|
|
966
|
+
* `../media/VideoPlayer`, and the reason is BYTES, not taste. The player is reached from here
|
|
967
|
+
* through `React.lazy(() => import(…))` on purpose, and `scripts/styleAreas.ts`'s closure walk
|
|
968
|
+
* reads STATIC specifiers — an `import type` is one, and tsc erases it long after that walk has
|
|
969
|
+
* decided. Measured 2026-09-18: naming the type pulled the player's whole static closure into
|
|
970
|
+
* the `media-gallery` area stylesheet, 2023 candidates to 2190, for a line that emits nothing
|
|
971
|
+
* at runtime. The two definitions are structurally identical, so tsc still joins them at the
|
|
972
|
+
* one call site that matters (`<LazyVideoPlayer video={…}>` below).
|
|
803
973
|
*/
|
|
804
974
|
export function stagePlayableVideo(
|
|
805
975
|
item: MediaGalleryItem,
|
|
806
976
|
media: MediaGalleryItemMedia,
|
|
807
977
|
streamUrl: string,
|
|
808
|
-
): ChapteredVideo {
|
|
978
|
+
): ChapteredVideo & { playbackDegraded?: boolean } {
|
|
809
979
|
return {
|
|
810
980
|
id: item.id,
|
|
811
981
|
name: item.name,
|
|
@@ -817,6 +987,14 @@ export function stagePlayableVideo(
|
|
|
817
987
|
* upload, so its absence is the ordinary case, not an error.
|
|
818
988
|
*/
|
|
819
989
|
...(media.hlsUrl ? { hlsUrl: media.hlsUrl } : {}),
|
|
990
|
+
/*
|
|
991
|
+
* 🔴 …and the other half of that sentence: the app ASKED for the ladder and its store did
|
|
992
|
+
* not answer, so the line above is absent for a reason worth saying out loud. It rides the
|
|
993
|
+
* same mapping as `hlsUrl` because it is the same fact seen from the other side, and
|
|
994
|
+
* because a field that is not listed here is a field that reaches the player dead — which
|
|
995
|
+
* is exactly how `vttUrl`, `processingStatus` and the ladder itself each shipped invisible.
|
|
996
|
+
*/
|
|
997
|
+
...(media.playbackDegraded ? { playbackDegraded: true } : {}),
|
|
820
998
|
...(media.posterUrl ? { posterUrl: media.posterUrl } : {}),
|
|
821
999
|
...(item.durationSeconds != null ? { durationSeconds: item.durationSeconds } : {}),
|
|
822
1000
|
// Scrub-preview thumbnails and the "still optimizing" indicator. Both were already
|
|
@@ -888,15 +1066,18 @@ function NativeVideoStage({
|
|
|
888
1066
|
poster,
|
|
889
1067
|
repeat,
|
|
890
1068
|
onEnded,
|
|
1069
|
+
onFault,
|
|
891
1070
|
}: {
|
|
892
1071
|
hlsUrl?: string | undefined;
|
|
893
1072
|
streamUrl: string;
|
|
894
1073
|
poster?: string | undefined;
|
|
895
1074
|
repeat?: boolean;
|
|
896
1075
|
onEnded?: () => void;
|
|
1076
|
+
/** The source ANSWERED and there is nothing left to play — see {@link NativeVideoSurface}. */
|
|
1077
|
+
onFault?: (fault: SourceFault) => void;
|
|
897
1078
|
}) {
|
|
898
1079
|
const ref = React.useRef<HTMLVideoElement>(null);
|
|
899
|
-
useHlsSource(ref, { hlsUrl, streamUrl });
|
|
1080
|
+
useHlsSource(ref, { hlsUrl, streamUrl }, { ...(onFault ? { onFault } : {}) });
|
|
900
1081
|
useStageVolume(ref);
|
|
901
1082
|
return (
|
|
902
1083
|
// biome-ignore lint/a11y/useMediaCaption: arbitrary user-stored files carry no captions
|
|
@@ -915,6 +1096,155 @@ function NativeVideoStage({
|
|
|
915
1096
|
);
|
|
916
1097
|
}
|
|
917
1098
|
|
|
1099
|
+
/**
|
|
1100
|
+
* The stage for a source that ANSWERED — `404`, `410`, a spent `403`, anything that is not
|
|
1101
|
+
* the bytes and is not still in flight.
|
|
1102
|
+
*
|
|
1103
|
+
* ── 🔴 Why this is a panel and not a longer spinner ─────────────────────────────────────
|
|
1104
|
+
* Owner, 2026-09-17: *"make a 404 stop the media player spinner so I don't think it's going
|
|
1105
|
+
* to eventually load."* One item in his catalogue has no ladder, so the first request the
|
|
1106
|
+
* stage made was answered `404` in 0.2 s — and that was the one status every surface here
|
|
1107
|
+
* treated as "still loading". **A hang and an answer must not look the same**, which is the
|
|
1108
|
+
* whole report: a timeout may keep spinning, because something might still arrive; an answer
|
|
1109
|
+
* may not, because nothing will.
|
|
1110
|
+
*
|
|
1111
|
+
* It borrows `.cbmg-stage-panel`, the same frame the audio stage and the no-viewer fallback
|
|
1112
|
+
* already use, so a stage that cannot show its file looks like one thing across every
|
|
1113
|
+
* category rather than three near-misses. `role='status'` because the sentence REPLACED
|
|
1114
|
+
* something the viewer was waiting on, and a screen reader that was told "loading" has to be
|
|
1115
|
+
* told how that ended.
|
|
1116
|
+
*/
|
|
1117
|
+
function StageSourceFault({
|
|
1118
|
+
item,
|
|
1119
|
+
category,
|
|
1120
|
+
fault,
|
|
1121
|
+
subject,
|
|
1122
|
+
}: {
|
|
1123
|
+
item: MediaGalleryItem;
|
|
1124
|
+
category: FileCategory;
|
|
1125
|
+
fault: SourceFault;
|
|
1126
|
+
/** The noun that leads the sentence — see {@link sourceFaultMessage}. */
|
|
1127
|
+
subject: string;
|
|
1128
|
+
}) {
|
|
1129
|
+
return (
|
|
1130
|
+
<div className='cbmg-stage-panel' role='status'>
|
|
1131
|
+
<FileTypeIcon category={category} size={48} />
|
|
1132
|
+
<span className='cbmg-stage-panel-name'>{item.name}</span>
|
|
1133
|
+
<span>{sourceFaultMessage(fault, subject)}</span>
|
|
1134
|
+
</div>
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* One fault, and the single decision every stage makes about it: re-ask, or say it.
|
|
1140
|
+
*
|
|
1141
|
+
* 🔴 A `403` is the one fault that comes back on its own — this fleet's signed URLs carry
|
|
1142
|
+
* `exp = iat + 2 × window`, so a page left open across a window boundary meets a stale token
|
|
1143
|
+
* legitimately and the app can simply mint another. So the recoverable kind is spent on a
|
|
1144
|
+
* re-ask FIRST and only becomes a sentence if there is no re-ask left ({@link useItemMedia}
|
|
1145
|
+
* allows exactly one per item, which is what stops this being a loop).
|
|
1146
|
+
*/
|
|
1147
|
+
function useSourceFault(reAsk: () => boolean): {
|
|
1148
|
+
fault: SourceFault | null;
|
|
1149
|
+
report: (fault: SourceFault) => void;
|
|
1150
|
+
} {
|
|
1151
|
+
const [fault, setFault] = useState<SourceFault | null>(null);
|
|
1152
|
+
const report = useCallback(
|
|
1153
|
+
(next: SourceFault) => {
|
|
1154
|
+
if (isRecoverableFault(next) && reAsk()) return;
|
|
1155
|
+
setFault(next);
|
|
1156
|
+
},
|
|
1157
|
+
[reAsk],
|
|
1158
|
+
);
|
|
1159
|
+
return { fault, report };
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* The `nativeVideoStage` element plus the one thing a `<video>` cannot say for itself.
|
|
1164
|
+
*
|
|
1165
|
+
* Mounted with a `key` on its source by {@link GalleryStage}, so stepping to another clip —
|
|
1166
|
+
* or getting a freshly signed URL out of the re-ask — starts from no fault rather than
|
|
1167
|
+
* inheriting the previous source's.
|
|
1168
|
+
*/
|
|
1169
|
+
function NativeVideoSurface({
|
|
1170
|
+
item,
|
|
1171
|
+
media,
|
|
1172
|
+
streamUrl,
|
|
1173
|
+
repeat,
|
|
1174
|
+
onEnded,
|
|
1175
|
+
reAsk,
|
|
1176
|
+
}: {
|
|
1177
|
+
item: MediaGalleryItem;
|
|
1178
|
+
media: MediaGalleryItemMedia;
|
|
1179
|
+
streamUrl: string;
|
|
1180
|
+
repeat?: boolean;
|
|
1181
|
+
onEnded?: () => void;
|
|
1182
|
+
reAsk: () => boolean;
|
|
1183
|
+
}) {
|
|
1184
|
+
const { fault, report } = useSourceFault(reAsk);
|
|
1185
|
+
if (fault) {
|
|
1186
|
+
return <StageSourceFault item={item} category='video' fault={fault} subject='This video' />;
|
|
1187
|
+
}
|
|
1188
|
+
return (
|
|
1189
|
+
/*
|
|
1190
|
+
* 🔴 The source is ATTACHED, not declared, because a ladder is not a `src`.
|
|
1191
|
+
*
|
|
1192
|
+
* An `.m3u8` on `video.src` plays on Safari and every iPhone and on nothing else, so
|
|
1193
|
+
* the choice between native HLS, hls.js and the progressive remux has to be made
|
|
1194
|
+
* against the element — see `NativeVideoStage` / `hlsSource.ts`. A plain `src={src}`
|
|
1195
|
+
* here is what made `hlsUrl` reach this stage and do nothing.
|
|
1196
|
+
*/
|
|
1197
|
+
<NativeVideoStage
|
|
1198
|
+
hlsUrl={media.hlsUrl}
|
|
1199
|
+
streamUrl={streamUrl}
|
|
1200
|
+
poster={media.posterUrl}
|
|
1201
|
+
repeat={repeat}
|
|
1202
|
+
onFault={report}
|
|
1203
|
+
{...(onEnded ? { onEnded } : {})}
|
|
1204
|
+
/>
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* The picture stage, and the same distinction for an `<img>`.
|
|
1210
|
+
*
|
|
1211
|
+
* 🔴 An `<img>` that 404s is the image half of the owner's report: `onError` carries no
|
|
1212
|
+
* status, so "the file is gone", "the token went stale" and "these bytes are not a picture"
|
|
1213
|
+
* arrived as one undifferentiated failure, and the stage drew a torn-page glyph next to a
|
|
1214
|
+
* title — which reads as the archive being broken. {@link probeSourceFault} is the only way
|
|
1215
|
+
* to tell them apart and it runs solely on this path, after the browser has already given up.
|
|
1216
|
+
*/
|
|
1217
|
+
function ImageSurface({
|
|
1218
|
+
item,
|
|
1219
|
+
url,
|
|
1220
|
+
reAsk,
|
|
1221
|
+
}: {
|
|
1222
|
+
item: MediaGalleryItem;
|
|
1223
|
+
url: string;
|
|
1224
|
+
reAsk: () => boolean;
|
|
1225
|
+
}) {
|
|
1226
|
+
const { fault, report } = useSourceFault(reAsk);
|
|
1227
|
+
if (fault) {
|
|
1228
|
+
return <StageSourceFault item={item} category='image' fault={fault} subject='This picture' />;
|
|
1229
|
+
}
|
|
1230
|
+
return (
|
|
1231
|
+
<div className='cbgd-stage-img' data-turned={isTurned(item) ? 'true' : undefined}>
|
|
1232
|
+
{/* A stored orientation is applied as a transform, never baked — see
|
|
1233
|
+
`MediaGalleryItem.orientation`. `data-turned` lets the stylesheet swap the fit
|
|
1234
|
+
constraint: a quarter-turned picture is laid out in its ORIGINAL box, so without it
|
|
1235
|
+
a portrait scan turned upright overflows the frame it is centred in. */}
|
|
1236
|
+
<img
|
|
1237
|
+
src={url}
|
|
1238
|
+
alt={item.name}
|
|
1239
|
+
style={orientationStyle(item)}
|
|
1240
|
+
onError={() => {
|
|
1241
|
+
void probeSourceFault(url).then(report);
|
|
1242
|
+
}}
|
|
1243
|
+
/>
|
|
1244
|
+
</div>
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
918
1248
|
/** The big stage — the right viewer per category. */
|
|
919
1249
|
function GalleryStage({
|
|
920
1250
|
item,
|
|
@@ -947,7 +1277,7 @@ function GalleryStage({
|
|
|
947
1277
|
fullscreenOnDoubleClick?: boolean;
|
|
948
1278
|
}) {
|
|
949
1279
|
const category = galleryItemCategory(item);
|
|
950
|
-
const { media, resolving } = useItemMedia(item, mediaCache);
|
|
1280
|
+
const { media, resolving, refresh } = useItemMedia(item, mediaCache);
|
|
951
1281
|
|
|
952
1282
|
if (category === 'video') {
|
|
953
1283
|
const src = media.streamUrl ?? media.url;
|
|
@@ -983,20 +1313,16 @@ function GalleryStage({
|
|
|
983
1313
|
);
|
|
984
1314
|
if (nativeVideoStage) {
|
|
985
1315
|
return frame(
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
* the choice between native HLS, hls.js and the progressive remux has to be made
|
|
991
|
-
* against the element — see `NativeVideoStage` / `hlsSource.ts`. A plain `src={src}`
|
|
992
|
-
* here is what made `hlsUrl` reach this stage and do nothing.
|
|
993
|
-
*/
|
|
994
|
-
<NativeVideoStage
|
|
1316
|
+
// `key`ed on the source: stepping to the next clip remounts rather than re-attaching
|
|
1317
|
+
// into an element mid-buffer on the previous one — and it is what clears a recorded
|
|
1318
|
+
// fault when a re-ask comes back with a freshly signed URL.
|
|
1319
|
+
<NativeVideoSurface
|
|
995
1320
|
key={media.hlsUrl ?? src}
|
|
996
|
-
|
|
1321
|
+
item={item}
|
|
1322
|
+
media={media}
|
|
997
1323
|
streamUrl={src}
|
|
998
|
-
poster={media.posterUrl}
|
|
999
1324
|
repeat={repeat}
|
|
1325
|
+
reAsk={refresh}
|
|
1000
1326
|
{...(onEnded ? { onEnded } : {})}
|
|
1001
1327
|
/>,
|
|
1002
1328
|
);
|
|
@@ -1021,15 +1347,9 @@ function GalleryStage({
|
|
|
1021
1347
|
|
|
1022
1348
|
if (category === 'image') {
|
|
1023
1349
|
if (!media.url) return <StageLoading label={resolving ? 'Loading…' : 'Image unavailable.'} />;
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
`MediaGalleryItem.orientation`. `data-turned` lets the stylesheet swap the fit
|
|
1028
|
-
constraint: a quarter-turned picture is laid out in its ORIGINAL box, so without it
|
|
1029
|
-
a portrait scan turned upright overflows the frame it is centred in. */}
|
|
1030
|
-
<img src={media.url} alt={item.name} style={orientationStyle(item)} />
|
|
1031
|
-
</div>
|
|
1032
|
-
);
|
|
1350
|
+
// `key`ed on the URL for the reason the video stage is: a new URL — including the one a
|
|
1351
|
+
// re-ask mints — is a new attempt and must not inherit the previous one's fault.
|
|
1352
|
+
return <ImageSurface key={media.url} item={item} url={media.url} reAsk={refresh} />;
|
|
1033
1353
|
}
|
|
1034
1354
|
|
|
1035
1355
|
if (category === 'audio') {
|
|
@@ -2122,9 +2442,9 @@ export function MediaGallery({
|
|
|
2122
2442
|
const capabilityDialogs = (
|
|
2123
2443
|
<React.Suspense fallback={null}>
|
|
2124
2444
|
{editingPicture ? (
|
|
2125
|
-
<
|
|
2126
|
-
|
|
2127
|
-
|
|
2445
|
+
<EditPictureDialog
|
|
2446
|
+
item={editingPicture}
|
|
2447
|
+
cache={mediaCache}
|
|
2128
2448
|
allowReplace={allowPictureReplace}
|
|
2129
2449
|
onClose={() => setEditingPicture(null)}
|
|
2130
2450
|
onSave={async (result: PictureEditResult) => {
|
|
@@ -2143,11 +2463,9 @@ export function MediaGallery({
|
|
|
2143
2463
|
/>
|
|
2144
2464
|
) : null}
|
|
2145
2465
|
{trimmingVideo && onTrimVideo ? (
|
|
2146
|
-
<
|
|
2147
|
-
|
|
2148
|
-
{
|
|
2149
|
-
name={trimmingVideo.name}
|
|
2150
|
-
durationSeconds={trimmingVideo.durationSeconds ?? null}
|
|
2466
|
+
<TrimVideoDialog
|
|
2467
|
+
item={trimmingVideo}
|
|
2468
|
+
cache={mediaCache}
|
|
2151
2469
|
{...(trimNote ? { note: trimNote } : {})}
|
|
2152
2470
|
onClose={() => setTrimmingVideo(null)}
|
|
2153
2471
|
onSubmit={async (cuts: VideoCut[]) => {
|
|
@@ -2156,12 +2474,9 @@ export function MediaGallery({
|
|
|
2156
2474
|
/>
|
|
2157
2475
|
) : null}
|
|
2158
2476
|
{chapteringVideo && onSaveChapters ? (
|
|
2159
|
-
<
|
|
2160
|
-
|
|
2161
|
-
{
|
|
2162
|
-
name={chapteringVideo.name}
|
|
2163
|
-
durationSeconds={chapteringVideo.durationSeconds ?? null}
|
|
2164
|
-
{...(chapteringVideo.chapters ? { chapters: chapteringVideo.chapters } : {})}
|
|
2477
|
+
<ChapterVideoDialog
|
|
2478
|
+
item={chapteringVideo}
|
|
2479
|
+
cache={mediaCache}
|
|
2165
2480
|
onClose={() => setChapteringVideo(null)}
|
|
2166
2481
|
onSubmit={async (chapters: VideoChapter[]) => {
|
|
2167
2482
|
await onSaveChapters(chapteringVideo, chapters);
|
|
@@ -2182,14 +2497,10 @@ export function MediaGallery({
|
|
|
2182
2497
|
/>
|
|
2183
2498
|
) : null}
|
|
2184
2499
|
{grabbing ? (
|
|
2185
|
-
<
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
// So the dialog can SAY that saving replaces the picture this film shows now, rather
|
|
2190
|
-
// than overwriting somebody's chosen poster silently — the owner's copy-not-overwrite
|
|
2191
|
-
// ruling applied to the one case in that dialog where a replacement is unavoidable.
|
|
2192
|
-
hasPoster={Boolean(grabbing.item.posterUrl)}
|
|
2500
|
+
<GrabFrameDialog
|
|
2501
|
+
item={grabbing.item}
|
|
2502
|
+
at={grabbing.at}
|
|
2503
|
+
cache={mediaCache}
|
|
2193
2504
|
onClose={() => setGrabbing(null)}
|
|
2194
2505
|
{...(onSaveThumbnail
|
|
2195
2506
|
? { onSaveThumbnail: (poster: Blob) => onSaveThumbnail(grabbing.item, poster) }
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* signed-URL app is a request per row per toggle. A module both import is the only shape
|
|
9
9
|
* that cannot drift; re-implementing either inside the table is what this file prevents.
|
|
10
10
|
*/
|
|
11
|
-
import { type MutableRefObject, useEffect, useState } from 'react';
|
|
11
|
+
import { type MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
|
|
12
12
|
import { staticItemMedia } from './mediaGalleryModel';
|
|
13
13
|
import type { MediaGalleryItem, MediaGalleryItemMedia } from './types';
|
|
14
14
|
|
|
@@ -16,15 +16,37 @@ export type MediaCache = MutableRefObject<Map<string, Promise<MediaGalleryItemMe
|
|
|
16
16
|
/**
|
|
17
17
|
* The item's resolved media: static URL fields immediately, `resolveMedia` results merged
|
|
18
18
|
* in when the item needs them (cached per id for the gallery's lifetime).
|
|
19
|
+
*
|
|
20
|
+
* `refresh` throws the cached answer away and asks the app again — see its own note; it is
|
|
21
|
+
* what makes a `403` from a stale signed URL recoverable instead of a sentence on screen.
|
|
19
22
|
*/
|
|
20
23
|
export function useItemMedia(
|
|
21
24
|
item: MediaGalleryItem,
|
|
22
25
|
cache: MediaCache,
|
|
23
|
-
): { media: MediaGalleryItemMedia; resolving: boolean } {
|
|
26
|
+
): { media: MediaGalleryItemMedia; resolving: boolean; refresh: () => boolean } {
|
|
24
27
|
const staticMedia = staticItemMedia(item);
|
|
25
28
|
const needsResolve = Boolean(item.resolveMedia) && !item.url;
|
|
26
29
|
const [resolved, setResolved] = useState<MediaGalleryItemMedia | null>(null);
|
|
27
30
|
const [resolving, setResolving] = useState(needsResolve);
|
|
31
|
+
/** Bumped by {@link refresh} so the effect below re-runs against an emptied cache. */
|
|
32
|
+
const [attempt, setAttempt] = useState(0);
|
|
33
|
+
/**
|
|
34
|
+
* 🔴 ONE re-ask per item, for the life of this mount.
|
|
35
|
+
*
|
|
36
|
+
* The whole reason to re-ask is a signed URL that went stale, and the app's answer to that
|
|
37
|
+
* is a fresh token — so if the second URL fails the same way, asking a third time is an
|
|
38
|
+
* unbounded loop against something that is not going to change, which is the same spinner
|
|
39
|
+
* this batch removed with more requests in it. `RECOVERY_BUDGET` in `hlsSource.ts` bounds
|
|
40
|
+
* its retries for exactly this reason; this is that rule one layer up.
|
|
41
|
+
*/
|
|
42
|
+
const reAsked = useRef<Set<string>>(new Set());
|
|
43
|
+
const refresh = useCallback((): boolean => {
|
|
44
|
+
if (!needsResolve || reAsked.current.has(item.id)) return false;
|
|
45
|
+
reAsked.current.add(item.id);
|
|
46
|
+
cache.current.delete(item.id);
|
|
47
|
+
setAttempt((n) => n + 1);
|
|
48
|
+
return true;
|
|
49
|
+
}, [item.id, needsResolve, cache]);
|
|
28
50
|
|
|
29
51
|
useEffect(() => {
|
|
30
52
|
if (!needsResolve) return;
|
|
@@ -49,9 +71,10 @@ export function useItemMedia(
|
|
|
49
71
|
return () => {
|
|
50
72
|
live = false;
|
|
51
73
|
};
|
|
52
|
-
|
|
74
|
+
// `attempt` is the re-ask: it is not read in the body, it is what re-runs it.
|
|
75
|
+
}, [item.id, needsResolve, cache, item.resolveMedia, attempt]);
|
|
53
76
|
|
|
54
|
-
return { media: { ...(resolved ?? {}), ...staticMedia }, resolving };
|
|
77
|
+
return { media: { ...(resolved ?? {}), ...staticMedia }, resolving, refresh };
|
|
55
78
|
}
|
|
56
79
|
|
|
57
80
|
/**
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
galleryTags,
|
|
9
9
|
isMarkdownFile,
|
|
10
10
|
sortGalleryItems,
|
|
11
|
+
staticItemMedia,
|
|
11
12
|
} from './mediaGalleryModel';
|
|
12
13
|
import type { MediaGalleryItem, MediaGalleryOrder } from './types';
|
|
13
14
|
|
|
@@ -180,3 +181,38 @@ describe('every sort pill is a two-way toggle', () => {
|
|
|
180
181
|
expect(sortGalleryItems(ITEMS, 'oldest').map((i) => i.id)).toEqual(['b', 'c', 'a', 'd']);
|
|
181
182
|
});
|
|
182
183
|
});
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 🔴 A field inlined on the item has to survive `staticItemMedia`, because that is the other
|
|
187
|
+
* road into the stage — the one an app takes when it has no `resolveMedia` and simply puts the
|
|
188
|
+
* URLs on the row. A field missing from this function is a field that is silently gone for
|
|
189
|
+
* every such app, exactly as it would be missing from `stagePlayableVideo`.
|
|
190
|
+
*/
|
|
191
|
+
describe('staticItemMedia carries the transport facts, and only the true ones', () => {
|
|
192
|
+
test('an unreachable ladder rides along with the URLs', () => {
|
|
193
|
+
const media = staticItemMedia(
|
|
194
|
+
item({
|
|
195
|
+
id: 'v',
|
|
196
|
+
name: 'clip.mp4',
|
|
197
|
+
streamUrl: '/files/v.web.mp4',
|
|
198
|
+
playbackDegraded: true,
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
expect(media.streamUrl).toBe('/files/v.web.mp4');
|
|
202
|
+
expect(media.playbackDegraded).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('a video that simply has no ladder leaves the key ABSENT, so a spread cannot clobber it', () => {
|
|
206
|
+
// Every entry here is dropped when falsy on purpose: `{...staticMedia, ...resolved}` merges
|
|
207
|
+
// the two roads, and an explicit `playbackDegraded: false` would overwrite a resolver that
|
|
208
|
+
// had just found out otherwise.
|
|
209
|
+
const media = staticItemMedia(item({ id: 'v', name: 'clip.mp4', streamUrl: '/files/v.mp4' }));
|
|
210
|
+
expect('playbackDegraded' in media).toBe(false);
|
|
211
|
+
expect(
|
|
212
|
+
'playbackDegraded' in
|
|
213
|
+
staticItemMedia(
|
|
214
|
+
item({ id: 'v', name: 'clip.mp4', streamUrl: '/f.mp4', playbackDegraded: false }),
|
|
215
|
+
),
|
|
216
|
+
).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -209,6 +209,10 @@ export function staticItemMedia(item: MediaGalleryItem): MediaGalleryItemMedia {
|
|
|
209
209
|
if (item.streamUrl) media.streamUrl = item.streamUrl;
|
|
210
210
|
// Additional to `streamUrl`, never instead of it — see `MediaGalleryItemMedia.hlsUrl`.
|
|
211
211
|
if (item.hlsUrl) media.hlsUrl = item.hlsUrl;
|
|
212
|
+
// "We could not ASK about the ladder", which is not the same as not having one — see
|
|
213
|
+
// `MediaGalleryItemMedia.playbackDegraded`. Forwarded only when true, so the ordinary
|
|
214
|
+
// ladder-less video carries nothing and the stage has nothing to say about it.
|
|
215
|
+
if (item.playbackDegraded) media.playbackDegraded = true;
|
|
212
216
|
if (item.vttUrl) media.vttUrl = item.vttUrl;
|
|
213
217
|
if (item.processingStatus) media.processingStatus = item.processingStatus;
|
|
214
218
|
return media;
|
|
@@ -55,6 +55,29 @@ export interface MediaGalleryItemMedia {
|
|
|
55
55
|
* and `preferBundledHls`, which decides where the engine comes from.
|
|
56
56
|
*/
|
|
57
57
|
hlsUrl?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The ladder was not ASKED ABOUT successfully — so this play is the progressive fallback and
|
|
60
|
+
* the app knows it, rather than merely having no ladder.
|
|
61
|
+
*
|
|
62
|
+
* 🔴 It is NOT "there is no ladder". A video whose ladder has simply never been built is the
|
|
63
|
+
* ordinary case and stays quiet: {@link hlsUrl} is absent, `streamUrl` plays, nothing is
|
|
64
|
+
* wrong. This flag is the other thing — the app's probe of its own media store did not
|
|
65
|
+
* answer, so it cannot tell a ladder that is missing from one it could not reach, and it has
|
|
66
|
+
* served the progressive copy on that ignorance.
|
|
67
|
+
*
|
|
68
|
+
* 🔴 And it is NOT {@link processingStatus} `'failed'`. That badge says the optimization
|
|
69
|
+
* failed; here the optimization may well have succeeded and we could not ask. Different
|
|
70
|
+
* cause, different fix, different lifetime — this one usually clears on the next open — so
|
|
71
|
+
* the player draws a SECOND badge rather than borrowing that one's sentence.
|
|
72
|
+
*
|
|
73
|
+
* Worth a badge because the fallback is expensive and silent: in `apps/collections` the
|
|
74
|
+
* progressive `.web.mp4` averages 467 MB and reaches 4.4 GB, which is past what Cloudflare
|
|
75
|
+
* will cache (`x-bs-edge-cache: MISS-TOO-LARGE`), so every play and every seek is pulled off
|
|
76
|
+
* the owner's own Mac; a ladder segment is ~1 MB and comes from the edge. For eight days
|
|
77
|
+
* nothing in the fleet could say which of the two it was serving, and the owner found out by
|
|
78
|
+
* watching a video stall.
|
|
79
|
+
*/
|
|
80
|
+
playbackDegraded?: boolean;
|
|
58
81
|
/**
|
|
59
82
|
* WebVTT scrub-preview track — the sprite-sheet index that puts real frame thumbnails on
|
|
60
83
|
* the scrub bar instead of a bare timecode. Passed straight to the player's `thumbnails`.
|
|
@@ -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. 147 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
|