cursedbelt 5.7.3 → 5.9.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 +11 -1
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +4 -4
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/filter-rail/FilterRail.d.ts +7 -1
- package/dist/react/filter-rail/FilterRail.d.ts.map +1 -1
- package/dist/react/filter-rail/FilterRail.js +6 -2
- package/dist/react/filter-rail/FilterRail.js.map +1 -1
- package/dist/react/filter-rail/index.d.ts +1 -0
- package/dist/react/filter-rail/index.d.ts.map +1 -1
- package/dist/react/filter-rail/index.js +1 -0
- package/dist/react/filter-rail/index.js.map +1 -1
- package/dist/react/filter-rail/railResize.d.ts +63 -0
- package/dist/react/filter-rail/railResize.d.ts.map +1 -0
- package/dist/react/filter-rail/railResize.js +79 -0
- package/dist/react/filter-rail/railResize.js.map +1 -0
- package/dist/react/filter-rail/railWidth.d.ts +11 -0
- package/dist/react/filter-rail/railWidth.d.ts.map +1 -0
- package/dist/react/filter-rail/railWidth.js +48 -0
- package/dist/react/filter-rail/railWidth.js.map +1 -0
- package/dist/react/media/VirtualMediaGrid.d.ts +3 -0
- package/dist/react/media/VirtualMediaGrid.d.ts.map +1 -1
- package/dist/react/media/VirtualMediaGrid.js +3 -0
- package/dist/react/media/VirtualMediaGrid.js.map +1 -1
- package/dist/react/media/stageResize.d.ts.map +1 -1
- package/dist/react/media/stageResize.js +2 -0
- package/dist/react/media/stageResize.js.map +1 -1
- 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 +11 -10
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/index.d.ts +3 -0
- package/dist/react/media-gallery/index.d.ts.map +1 -1
- package/dist/react/media-gallery/index.js +4 -0
- package/dist/react/media-gallery/index.js.map +1 -1
- package/dist/react/media-gallery/playbackPreferences.d.ts +7 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
- package/dist/react/media-gallery/playbackPreferences.js +22 -2
- package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
- package/dist/react/media-gallery/preferenceBus.d.ts +11 -0
- package/dist/react/media-gallery/preferenceBus.d.ts.map +1 -0
- package/dist/react/media-gallery/preferenceBus.js +20 -0
- package/dist/react/media-gallery/preferenceBus.js.map +1 -0
- package/dist/react/media-gallery/preferenceSync.d.ts +11 -0
- package/dist/react/media-gallery/preferenceSync.d.ts.map +1 -0
- package/dist/react/media-gallery/preferenceSync.js +64 -0
- package/dist/react/media-gallery/preferenceSync.js.map +1 -0
- package/dist/styles-areas/filter-rail.css +2 -2
- package/package.json +1 -1
- package/src/react/file-tree/FileTree.spec.tsx +19 -0
- package/src/react/file-tree/FileTree.tsx +20 -1
- package/src/react/filter-rail/FilterRail.spec.tsx +45 -0
- package/src/react/filter-rail/FilterRail.tsx +16 -1
- package/src/react/filter-rail/index.ts +1 -0
- package/src/react/filter-rail/railResize.spec.tsx +92 -0
- package/src/react/filter-rail/railResize.tsx +156 -0
- package/src/react/filter-rail/railWidth.ts +47 -0
- package/src/react/media/VirtualMediaGrid.tsx +4 -0
- package/src/react/media/stageResize.ts +3 -0
- package/src/react/media-gallery/MediaGallery.tsx +25 -2
- package/src/react/media-gallery/index.ts +4 -0
- package/src/react/media-gallery/playbackPreferences.ts +26 -2
- package/src/react/media-gallery/preferenceBus.ts +28 -0
- package/src/react/media-gallery/preferenceSync.spec.ts +74 -0
- package/src/react/media-gallery/preferenceSync.ts +60 -0
- package/src/styles-areas/filter-rail.css +2 -2
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drag a sidebar's right edge to resize it, like VS Code's side bar — the gesture half, for
|
|
3
|
+
* any rail, not only `FilterRail`.
|
|
4
|
+
*
|
|
5
|
+
* Owner, 2026-09-24: *"the cb filetree sidebars should allow grabbing their right edge and
|
|
6
|
+
* dragging to resize them like in vscode."* 5.8.0 shipped the handle INSIDE `FilterRail`; the
|
|
7
|
+
* sidebars in music (`LibraryRail`) and others are their own columns around a `FileTree`, so
|
|
8
|
+
* the handle came out here once rather than being copied into each app. `FilterRail` uses
|
|
9
|
+
* exactly this, so there is one gesture to get right:
|
|
10
|
+
*
|
|
11
|
+
* - **pointer capture**, or the drag ends the first time the pointer outruns the strip;
|
|
12
|
+
* - **←/→** (Shift for bigger steps), because a drag-only control is one some people cannot
|
|
13
|
+
* reach;
|
|
14
|
+
* - **a `separator` role with real `aria-value*`**, the ARIA splitter pattern;
|
|
15
|
+
* - **double-click resets** to the rail's own default width, and forgets the stored one;
|
|
16
|
+
* - remembered on RELEASE — a drag is one decision, not sixty writes a second.
|
|
17
|
+
*
|
|
18
|
+
* Clamp and storage are `railWidth.ts`, so the width is announced through `preferenceSync`
|
|
19
|
+
* like every other viewer preference.
|
|
20
|
+
*/
|
|
21
|
+
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, RefObject } from 'react';
|
|
22
|
+
import { useCallback, useRef, useState } from 'react';
|
|
23
|
+
import { cn } from '../lib/cn.js';
|
|
24
|
+
import { clampRailWidth, MAX_RAIL_PX, MIN_RAIL_PX, readRailWidth, writeRailWidth } from './railWidth.js';
|
|
25
|
+
|
|
26
|
+
export interface UseRailResizeOptions {
|
|
27
|
+
/** Where the width is remembered. Absent: not resizable — no handle, no stored width. */
|
|
28
|
+
resizeKey: string | undefined;
|
|
29
|
+
/** The rail's accessible name; the handle announces as "Resize <label>". */
|
|
30
|
+
label: string;
|
|
31
|
+
/** False hides the handle and drops the dragged width (a collapsed rail). Default true. */
|
|
32
|
+
enabled?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RailResize<E extends HTMLElement = HTMLElement> {
|
|
36
|
+
/** Put on the rail element — the handle measures it to start a drag. */
|
|
37
|
+
railRef: RefObject<E | null>;
|
|
38
|
+
/** Whether a handle is drawn: a `resizeKey` was given and `enabled` is true. */
|
|
39
|
+
active: boolean;
|
|
40
|
+
/** The dragged width in px, or `null` for "the rail's own default width". Null while inactive. */
|
|
41
|
+
width: number | null;
|
|
42
|
+
/** `{ width }` while there is a dragged width, else `undefined` — spread onto the rail's `style`. */
|
|
43
|
+
style: CSSProperties | undefined;
|
|
44
|
+
/** Forget the dragged width (what a double-click on the handle does). */
|
|
45
|
+
reset: () => void;
|
|
46
|
+
/** @internal — read by `RailResizeHandle`. */
|
|
47
|
+
readonly handle: {
|
|
48
|
+
label: string;
|
|
49
|
+
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
|
|
50
|
+
onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => void;
|
|
51
|
+
/** The rail's current rendered width, for `aria-valuenow` before any drag. */
|
|
52
|
+
measure: () => number;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useRailResize<E extends HTMLElement = HTMLElement>({
|
|
57
|
+
resizeKey,
|
|
58
|
+
label,
|
|
59
|
+
enabled = true,
|
|
60
|
+
}: UseRailResizeOptions): RailResize<E> {
|
|
61
|
+
const railRef = useRef<E | null>(null);
|
|
62
|
+
const [dragged, setDragged] = useState<number | null>(() => (resizeKey ? readRailWidth(resizeKey) : null));
|
|
63
|
+
const active = Boolean(resizeKey) && enabled;
|
|
64
|
+
const measure = useCallback(() => railRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX, []);
|
|
65
|
+
|
|
66
|
+
const onPointerDown = useCallback(
|
|
67
|
+
(event: ReactPointerEvent<HTMLElement>) => {
|
|
68
|
+
if (!resizeKey || event.button !== 0) return;
|
|
69
|
+
const handle = event.currentTarget;
|
|
70
|
+
const startX = event.clientX;
|
|
71
|
+
const startWidth = measure();
|
|
72
|
+
let latest = startWidth;
|
|
73
|
+
handle.setPointerCapture?.(event.pointerId);
|
|
74
|
+
const move = (moveEvent: PointerEvent) => {
|
|
75
|
+
latest = clampRailWidth(startWidth + moveEvent.clientX - startX);
|
|
76
|
+
setDragged(latest);
|
|
77
|
+
};
|
|
78
|
+
const end = () => {
|
|
79
|
+
if (handle.hasPointerCapture?.(event.pointerId)) handle.releasePointerCapture(event.pointerId);
|
|
80
|
+
handle.removeEventListener('pointermove', move);
|
|
81
|
+
handle.removeEventListener('pointerup', end);
|
|
82
|
+
handle.removeEventListener('pointercancel', end);
|
|
83
|
+
writeRailWidth(resizeKey, latest);
|
|
84
|
+
};
|
|
85
|
+
handle.addEventListener('pointermove', move);
|
|
86
|
+
handle.addEventListener('pointerup', end);
|
|
87
|
+
handle.addEventListener('pointercancel', end);
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
},
|
|
90
|
+
[resizeKey, measure],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const onKeyDown = useCallback(
|
|
94
|
+
(event: ReactKeyboardEvent<HTMLElement>) => {
|
|
95
|
+
if (!resizeKey) return;
|
|
96
|
+
const step = event.shiftKey ? 64 : 16;
|
|
97
|
+
const delta = event.key === 'ArrowRight' ? step : event.key === 'ArrowLeft' ? -step : 0;
|
|
98
|
+
if (!delta) return;
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
const next = clampRailWidth((dragged ?? measure()) + delta);
|
|
101
|
+
setDragged(next);
|
|
102
|
+
writeRailWidth(resizeKey, next);
|
|
103
|
+
},
|
|
104
|
+
[resizeKey, dragged, measure],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const reset = useCallback(() => {
|
|
108
|
+
setDragged(null);
|
|
109
|
+
if (resizeKey) writeRailWidth(resizeKey, null);
|
|
110
|
+
}, [resizeKey]);
|
|
111
|
+
|
|
112
|
+
const width = active ? dragged : null;
|
|
113
|
+
return {
|
|
114
|
+
railRef,
|
|
115
|
+
active,
|
|
116
|
+
width,
|
|
117
|
+
style: width === null ? undefined : { width },
|
|
118
|
+
reset,
|
|
119
|
+
handle: { label, onPointerDown, onKeyDown, measure },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The handle itself — a 12px hotspot centred on the rail's right border. The RAIL must be
|
|
125
|
+
* `position: relative` (the handle is absolutely positioned against it). Renders nothing while
|
|
126
|
+
* the resize is inactive.
|
|
127
|
+
*
|
|
128
|
+
* Only utilities the stylesheet already ships, so it adds no CSS to any app that imports the
|
|
129
|
+
* `filter-rail` area (`fixtureAppCss.spec.ts` budgets that).
|
|
130
|
+
*/
|
|
131
|
+
export function RailResizeHandle({ rail, className }: { rail: RailResize; className?: string }) {
|
|
132
|
+
if (!rail.active) return null;
|
|
133
|
+
const { label, onPointerDown, onKeyDown, measure } = rail.handle;
|
|
134
|
+
return (
|
|
135
|
+
// biome-ignore lint/a11y/useSemanticElements: an <hr> cannot take focus or a drag; a focusable separator is the ARIA splitter pattern.
|
|
136
|
+
<div
|
|
137
|
+
role='separator'
|
|
138
|
+
aria-orientation='vertical'
|
|
139
|
+
aria-label={`Resize ${label}`}
|
|
140
|
+
aria-valuemin={MIN_RAIL_PX}
|
|
141
|
+
aria-valuemax={MAX_RAIL_PX}
|
|
142
|
+
aria-valuenow={Math.round(rail.width ?? measure())}
|
|
143
|
+
tabIndex={0}
|
|
144
|
+
data-cb-rail-resize=''
|
|
145
|
+
onPointerDown={onPointerDown}
|
|
146
|
+
onKeyDown={onKeyDown}
|
|
147
|
+
onDoubleClick={rail.reset}
|
|
148
|
+
// A 1px edge you cannot hit is not a handle: the hotspot is 12px, centred on the border,
|
|
149
|
+
// and only the hairline tints — no layout shift, like `PanelSeparator`.
|
|
150
|
+
className={cn(
|
|
151
|
+
'absolute inset-y-0 -right-1.5 z-20 w-3 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-accent/20 active:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
152
|
+
className,
|
|
153
|
+
)}
|
|
154
|
+
/>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How wide the person dragged a sidebar — the pure half, so the clamp is testable without a
|
|
3
|
+
* pointer.
|
|
4
|
+
*
|
|
5
|
+
* Owner, 2026-09-24: *"the cb filetree sidebars should allow grabbing their right edge and
|
|
6
|
+
* dragging to resize them like in vscode … in collections app I should be able to drag it to
|
|
7
|
+
* the right to see the full file names or shrink it by moving it to the left."*
|
|
8
|
+
*
|
|
9
|
+
* Remembered under a key the APP names (`FilterRail`'s `resizeKey`), for the reason
|
|
10
|
+
* `media/stageResize.ts` keys its heights: two rails on one origin must not resize each other.
|
|
11
|
+
* Announced through `preferenceSync`, so an app that keeps preferences on its server carries
|
|
12
|
+
* the width to every device like the rest of them.
|
|
13
|
+
*/
|
|
14
|
+
import { announcePreference } from '../media-gallery/preferenceBus.js';
|
|
15
|
+
|
|
16
|
+
/** Narrower than this and a row is an icon and an ellipsis. */
|
|
17
|
+
export const MIN_RAIL_PX = 160;
|
|
18
|
+
/** Wider than this and the rail is eating the page it navigates. */
|
|
19
|
+
export const MAX_RAIL_PX = 640;
|
|
20
|
+
|
|
21
|
+
export const clampRailWidth = (px: number): number =>
|
|
22
|
+
Math.round(Math.min(MAX_RAIL_PX, Math.max(MIN_RAIL_PX, px)));
|
|
23
|
+
|
|
24
|
+
/** The remembered width, or null for "the rail's default". Wrapped: `localStorage` throws in
|
|
25
|
+
* a sandboxed iframe and in Safari's private mode. */
|
|
26
|
+
export function readRailWidth(key: string): number | null {
|
|
27
|
+
try {
|
|
28
|
+
const raw = globalThis.localStorage?.getItem(key);
|
|
29
|
+
if (raw == null) return null;
|
|
30
|
+
const stored = Number(raw);
|
|
31
|
+
return Number.isFinite(stored) && stored > 0 ? clampRailWidth(stored) : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Remember it, or forget it (`null` — the double-click reset). */
|
|
38
|
+
export function writeRailWidth(key: string, width: number | null): void {
|
|
39
|
+
const value = width === null ? null : String(clampRailWidth(width));
|
|
40
|
+
try {
|
|
41
|
+
if (value === null) globalThis.localStorage?.removeItem(key);
|
|
42
|
+
else globalThis.localStorage?.setItem(key, value);
|
|
43
|
+
} catch {
|
|
44
|
+
// A width that cannot be remembered is still a width that works this session.
|
|
45
|
+
}
|
|
46
|
+
announcePreference(key, value);
|
|
47
|
+
}
|
|
@@ -21,6 +21,10 @@ import { VirtualCardGrid, type VirtualCardGridProps } from '../VirtualCardGrid.j
|
|
|
21
21
|
*/
|
|
22
22
|
export const CBGD_META_HEIGHT = 67;
|
|
23
23
|
|
|
24
|
+
/** A tile with its caption turned off (`MediaGallery`'s `tileCaptions={false}`): only the
|
|
25
|
+
* photo tile's 1px border top and bottom remain below the media box. */
|
|
26
|
+
export const CBGD_BARE_TILE_HEIGHT = 2;
|
|
27
|
+
|
|
24
28
|
export interface VirtualMediaGridProps
|
|
25
29
|
extends Omit<VirtualCardGridProps, 'aspect' | 'cardExtraHeight'> {
|
|
26
30
|
/** Media aspect ratio (height/width). Default 9/16 (landscape). */
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* thumbnails underneath" stays literally true at every point of the drag.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { announcePreference } from '../media-gallery/preferenceBus.js';
|
|
19
|
+
|
|
18
20
|
/** Below this the stage is a letterbox sliver — no picture, all chrome. */
|
|
19
21
|
export const MIN_STAGE_PX = 180;
|
|
20
22
|
|
|
@@ -83,6 +85,7 @@ export function writeStageHeight(key: string, height: number | null): void {
|
|
|
83
85
|
try {
|
|
84
86
|
if (height === null) globalThis.localStorage?.removeItem(key);
|
|
85
87
|
else globalThis.localStorage?.setItem(key, String(Math.round(height)));
|
|
88
|
+
announcePreference(key, height === null ? null : String(Math.round(height)));
|
|
86
89
|
} catch {
|
|
87
90
|
// A size that cannot be remembered is still a size that works this session.
|
|
88
91
|
}
|
|
@@ -76,6 +76,7 @@ import {
|
|
|
76
76
|
useCheckboxSelection,
|
|
77
77
|
} from '../hooks/useCheckboxSelection.js';
|
|
78
78
|
import { DenseGalleryDetail, DenseGalleryShell } from '../media/DenseGalleryShell.js';
|
|
79
|
+
import { CBGD_BARE_TILE_HEIGHT } from '../media/VirtualMediaGrid.js';
|
|
79
80
|
import { useHlsSource } from '../media/hlsSource.js';
|
|
80
81
|
import {
|
|
81
82
|
isRecoverableFault,
|
|
@@ -651,6 +652,7 @@ function GalleryTile({
|
|
|
651
652
|
autoPreview,
|
|
652
653
|
measure,
|
|
653
654
|
stageMark,
|
|
655
|
+
caption = true,
|
|
654
656
|
}: {
|
|
655
657
|
item: MediaGalleryItem;
|
|
656
658
|
cache: MediaCache;
|
|
@@ -663,6 +665,8 @@ function GalleryTile({
|
|
|
663
665
|
measure?: TileMeasure;
|
|
664
666
|
/** See {@link TileShell}'s `stageMark` — the label for the tile that is ON the stage. */
|
|
665
667
|
stageMark?: string;
|
|
668
|
+
/** Draw the name/size/date under the picture — see {@link MediaGalleryProps.tileCaptions}. */
|
|
669
|
+
caption?: boolean;
|
|
666
670
|
}) {
|
|
667
671
|
/*
|
|
668
672
|
* 🔴 ALWAYS wrapped, even with no selection and no drag.
|
|
@@ -686,6 +690,7 @@ function GalleryTile({
|
|
|
686
690
|
onOpen={onOpen}
|
|
687
691
|
autoPreview={autoPreview}
|
|
688
692
|
measure={measure}
|
|
693
|
+
caption={caption}
|
|
689
694
|
/>
|
|
690
695
|
</TileShell>
|
|
691
696
|
);
|
|
@@ -757,12 +762,14 @@ function RawGalleryTile({
|
|
|
757
762
|
onOpen,
|
|
758
763
|
autoPreview,
|
|
759
764
|
measure,
|
|
765
|
+
caption = true,
|
|
760
766
|
}: {
|
|
761
767
|
item: MediaGalleryItem;
|
|
762
768
|
cache: MediaCache;
|
|
763
769
|
onOpen: () => void;
|
|
764
770
|
autoPreview?: boolean;
|
|
765
771
|
measure?: TileMeasure;
|
|
772
|
+
caption?: boolean;
|
|
766
773
|
}) {
|
|
767
774
|
const category = galleryItemCategory(item);
|
|
768
775
|
const { media } = useItemMedia(item, cache);
|
|
@@ -788,7 +795,7 @@ function RawGalleryTile({
|
|
|
788
795
|
// whose detail view is upright, which reads as the fix not having worked.
|
|
789
796
|
{...(item.orientation ? { orientation: item.orientation } : {})}
|
|
790
797
|
>
|
|
791
|
-
<GalleryMeta item={item} measure={measure} />
|
|
798
|
+
{caption ? <GalleryMeta item={item} measure={measure} /> : null}
|
|
792
799
|
</HoverScrubVideoThumb>
|
|
793
800
|
);
|
|
794
801
|
}
|
|
@@ -835,7 +842,7 @@ function RawGalleryTile({
|
|
|
835
842
|
/>
|
|
836
843
|
</div>
|
|
837
844
|
)}
|
|
838
|
-
<GalleryMeta item={item} measure={measure} />
|
|
845
|
+
{caption ? <GalleryMeta item={item} measure={measure} /> : null}
|
|
839
846
|
</button>
|
|
840
847
|
);
|
|
841
848
|
}
|
|
@@ -1505,6 +1512,17 @@ export interface MediaGalleryProps {
|
|
|
1505
1512
|
previewAll?: boolean;
|
|
1506
1513
|
/** Told when the app-owned toggle changes — see {@link MediaGalleryProps.previewAll}. */
|
|
1507
1514
|
onPreviewAllChange?: (next: boolean) => void;
|
|
1515
|
+
/**
|
|
1516
|
+
* The name, size and date under every tile. `false` leaves the picture alone on the card, and
|
|
1517
|
+
* the row shrinks by exactly the caption's height, so more of the grid fits on screen.
|
|
1518
|
+
*
|
|
1519
|
+
* Owner, collections, 2026-09-21: *"I want a way to see more of the media files sometimes …
|
|
1520
|
+
* add a button to toggle hiding and showing all the text under the media gallery images."*
|
|
1521
|
+
* CONTROLLED by the app for the same reason `previewAll` is — the button lives in the app's
|
|
1522
|
+
* own chrome. The name is still the tile's accessible label, so nothing is lost to a reader.
|
|
1523
|
+
* Default `true`: every existing consumer keeps its captions.
|
|
1524
|
+
*/
|
|
1525
|
+
tileCaptions?: boolean;
|
|
1508
1526
|
/** Whether there is anything to preview, so an app's own button can hide itself the way the
|
|
1509
1527
|
* header pill does — a toggle that provably does nothing is worse than an absent one. */
|
|
1510
1528
|
onPreviewableChange?: (hasVideo: boolean) => void;
|
|
@@ -1799,6 +1817,7 @@ export function MediaGallery({
|
|
|
1799
1817
|
headerExtra,
|
|
1800
1818
|
chrome = 'header',
|
|
1801
1819
|
previewAll,
|
|
1820
|
+
tileCaptions = true,
|
|
1802
1821
|
onPreviewAllChange,
|
|
1803
1822
|
onPreviewableChange,
|
|
1804
1823
|
viewMode: controlledViewMode,
|
|
@@ -2577,6 +2596,7 @@ export function MediaGallery({
|
|
|
2577
2596
|
<>
|
|
2578
2597
|
{capabilityDialogs}
|
|
2579
2598
|
<DenseGalleryDetail
|
|
2599
|
+
{...(tileCaptions ? {} : { rowExtraHeight: CBGD_BARE_TILE_HEIGHT })}
|
|
2580
2600
|
fullStage={fullStage}
|
|
2581
2601
|
onFullStageChange={setFullStage}
|
|
2582
2602
|
/* 🔴 A VIDEO enters browser fullscreen from its own player, so the frame must not also
|
|
@@ -3119,6 +3139,7 @@ export function MediaGallery({
|
|
|
3119
3139
|
// file is the same grid, and a tile that told you its length on one screen and
|
|
3120
3140
|
// its size on the other would be two tiles.
|
|
3121
3141
|
measure={meta?.tileMeasure || undefined}
|
|
3142
|
+
caption={tileCaptions}
|
|
3122
3143
|
// The one tile the stage is showing. Clicking it re-selects what is already
|
|
3123
3144
|
// open, which `select` makes a no-op change — it stays a live tile rather than
|
|
3124
3145
|
// a dead one, because a tile you cannot press reads as broken.
|
|
@@ -3141,6 +3162,7 @@ export function MediaGallery({
|
|
|
3141
3162
|
about separately. */}
|
|
3142
3163
|
{capabilityDialogs}
|
|
3143
3164
|
<DenseGalleryShell<MediaGalleryOrder>
|
|
3165
|
+
{...(tileCaptions ? {} : { rowExtraHeight: CBGD_BARE_TILE_HEIGHT })}
|
|
3144
3166
|
title={title}
|
|
3145
3167
|
order={{
|
|
3146
3168
|
value: order,
|
|
@@ -3317,6 +3339,7 @@ export function MediaGallery({
|
|
|
3317
3339
|
drag={tileDrag(item)}
|
|
3318
3340
|
autoPreview={thumbAutoplay}
|
|
3319
3341
|
measure={meta?.tileMeasure || undefined}
|
|
3342
|
+
caption={tileCaptions}
|
|
3320
3343
|
/>
|
|
3321
3344
|
) : null;
|
|
3322
3345
|
}}
|
|
@@ -59,6 +59,10 @@ export {
|
|
|
59
59
|
writeThumbAutoplay,
|
|
60
60
|
writeViewMode,
|
|
61
61
|
} from './playbackPreferences.js';
|
|
62
|
+
// Server-side memory for the above, for an app that opts in — see `preferenceSync.ts`.
|
|
63
|
+
export { onPreferenceWrite, seedPreferences } from './preferenceSync.js';
|
|
64
|
+
export { STAGE_VOLUME_KEY } from './playbackPreferences.js';
|
|
65
|
+
export { FOCUS_HEIGHT_KEY, STAGE_HEIGHT_KEY } from '../media/stageResize.js';
|
|
62
66
|
// The run algebra — pure, so an app that drives its own player (music's queue, roms' reel)
|
|
63
67
|
// can share the shuffle bag and the wrapping stepper without mounting the gallery.
|
|
64
68
|
export {
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
* is only the value, its labels and its storage.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
+
import { announcePreference } from './preferenceBus.js';
|
|
33
|
+
|
|
32
34
|
/** What happens when the item on the stage finishes. */
|
|
33
35
|
export type PlaybackEndBehavior =
|
|
34
36
|
/** Stop on the last frame — the browser default, and the least surprising. */
|
|
@@ -97,6 +99,7 @@ export function readPlaybackEnd(): PlaybackEndBehavior {
|
|
|
97
99
|
export function writePlaybackEnd(behavior: PlaybackEndBehavior): void {
|
|
98
100
|
try {
|
|
99
101
|
globalThis.localStorage?.setItem(STORAGE_KEY, behavior);
|
|
102
|
+
announcePreference(STORAGE_KEY, behavior);
|
|
100
103
|
} catch {
|
|
101
104
|
// A preference that cannot be remembered is still a preference that works this session.
|
|
102
105
|
}
|
|
@@ -144,6 +147,7 @@ export function readImageDwell(): number {
|
|
|
144
147
|
export function writeImageDwell(seconds: number): void {
|
|
145
148
|
try {
|
|
146
149
|
globalThis.localStorage?.setItem(DWELL_KEY, String(seconds));
|
|
150
|
+
announcePreference(DWELL_KEY, String(seconds));
|
|
147
151
|
} catch {
|
|
148
152
|
// Unremembered is still usable this session.
|
|
149
153
|
}
|
|
@@ -175,6 +179,7 @@ export function readPinStage(): boolean {
|
|
|
175
179
|
export function writePinStage(pinned: boolean): void {
|
|
176
180
|
try {
|
|
177
181
|
globalThis.localStorage?.setItem(PIN_KEY, pinned ? '1' : '0');
|
|
182
|
+
announcePreference(PIN_KEY, pinned ? '1' : '0');
|
|
178
183
|
} catch {
|
|
179
184
|
// Unremembered is still usable this session.
|
|
180
185
|
}
|
|
@@ -213,6 +218,7 @@ export function readThumbAutoplay(): boolean {
|
|
|
213
218
|
export function writeThumbAutoplay(on: boolean): void {
|
|
214
219
|
try {
|
|
215
220
|
globalThis.localStorage?.setItem(THUMB_AUTOPLAY_KEY, on ? '1' : '0');
|
|
221
|
+
announcePreference(THUMB_AUTOPLAY_KEY, on ? '1' : '0');
|
|
216
222
|
} catch {
|
|
217
223
|
// Unremembered is still usable this session.
|
|
218
224
|
}
|
|
@@ -253,6 +259,7 @@ export function readViewMode(): GalleryViewMode {
|
|
|
253
259
|
export function writeViewMode(mode: GalleryViewMode): void {
|
|
254
260
|
try {
|
|
255
261
|
globalThis.localStorage?.setItem(VIEW_MODE_KEY, mode);
|
|
262
|
+
announcePreference(VIEW_MODE_KEY, mode);
|
|
256
263
|
} catch {
|
|
257
264
|
// Unremembered is still usable this session.
|
|
258
265
|
}
|
|
@@ -350,10 +357,27 @@ export function readSessionVolume(key: string): SessionVolume | null {
|
|
|
350
357
|
|
|
351
358
|
/** Remember it for the rest of this PAGE. Never longer — see {@link SessionVolume}. */
|
|
352
359
|
export function writeSessionVolume(key: string, value: SessionVolume): void {
|
|
353
|
-
|
|
360
|
+
const held = writeSessionVolumeSilently(key, value);
|
|
361
|
+
// Announced for an app that keeps it longer than the page — see `preferenceSync.ts`. This
|
|
362
|
+
// module still never does; the owner's 09-14 rule holds for every app that does not opt in.
|
|
363
|
+
announcePreference(key, JSON.stringify(held));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The same write without the announcement — what `seedPreferences` uses to put a stored level
|
|
368
|
+
* back, so restoring it is never reported as the person changing it.
|
|
369
|
+
*/
|
|
370
|
+
export function writeSessionVolumeSilently(key: string, value: SessionVolume): SessionVolume {
|
|
371
|
+
const held = {
|
|
354
372
|
// Clamped here rather than at every call site: this lands on `HTMLMediaElement.volume`,
|
|
355
373
|
// which throws `IndexSizeError` outside 0–1.
|
|
356
374
|
volume: Math.min(1, Math.max(0, value.volume)),
|
|
357
375
|
muted: value.muted,
|
|
358
|
-
}
|
|
376
|
+
};
|
|
377
|
+
sessionVolumes.set(key, held);
|
|
378
|
+
return { ...held };
|
|
359
379
|
}
|
|
380
|
+
|
|
381
|
+
/** Is `key` one of the in-memory session volumes rather than a `localStorage` preference? */
|
|
382
|
+
export const isSessionVolumeKey = (key: string): boolean =>
|
|
383
|
+
key === STAGE_VOLUME_KEY || key === PLAYER_VOLUME_KEY;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The listener set behind `preferenceSync.ts`, in a module of its own so that the writers
|
|
3
|
+
* (`playbackPreferences.ts`, `media/stageResize.ts`) and the seeder can both import it
|
|
4
|
+
* without importing each other. See `preferenceSync.ts` for what it is for.
|
|
5
|
+
*/
|
|
6
|
+
export type PreferenceListener = (key: string, value: string | null) => void;
|
|
7
|
+
|
|
8
|
+
const listeners = new Set<PreferenceListener>();
|
|
9
|
+
|
|
10
|
+
/** Hear every preference this package remembers. Returns the unsubscribe. */
|
|
11
|
+
export function onPreferenceWrite(listener: PreferenceListener): () => void {
|
|
12
|
+
listeners.add(listener);
|
|
13
|
+
return () => {
|
|
14
|
+
listeners.delete(listener);
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Called by each writer in this package — not part of the public surface. */
|
|
19
|
+
export function announcePreference(key: string, value: string | null): void {
|
|
20
|
+
for (const listener of listeners) {
|
|
21
|
+
try {
|
|
22
|
+
listener(key, value);
|
|
23
|
+
} catch {
|
|
24
|
+
// A listener that throws must not take a volume slider down with it.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { readStageHeight, STAGE_HEIGHT_KEY, writeStageHeight } from '../media/stageResize.js';
|
|
3
|
+
import {
|
|
4
|
+
readSessionVolume,
|
|
5
|
+
readThumbAutoplay,
|
|
6
|
+
STAGE_VOLUME_KEY,
|
|
7
|
+
writeSessionVolume,
|
|
8
|
+
writeThumbAutoplay,
|
|
9
|
+
} from './playbackPreferences.js';
|
|
10
|
+
import { onPreferenceWrite, seedPreferences } from './preferenceSync.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 🔴 The seam an app uses to keep these preferences on its SERVER — owner, collections,
|
|
14
|
+
* 2026-09-21: *"remember my settings in the db and utilize them when I sign back in on any
|
|
15
|
+
* device."* Three promises, each of which fails silently if broken:
|
|
16
|
+
*
|
|
17
|
+
* · every writer ANNOUNCES — a writer that forgets is a setting that quietly stays on one device
|
|
18
|
+
* · a seed lands where the READER reads — or the restore is a no-op nobody sees
|
|
19
|
+
* · a seed announces NOTHING — or signing in writes every stored value straight back
|
|
20
|
+
*/
|
|
21
|
+
const heard: [string, string | null][] = [];
|
|
22
|
+
let stop: (() => void) | null = null;
|
|
23
|
+
const listen = (): void => {
|
|
24
|
+
stop = onPreferenceWrite((key, value) => heard.push([key, value]));
|
|
25
|
+
};
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
stop?.();
|
|
28
|
+
stop = null;
|
|
29
|
+
heard.length = 0;
|
|
30
|
+
globalThis.localStorage?.clear();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('preferenceSync', () => {
|
|
34
|
+
test('the writers announce what they remembered', () => {
|
|
35
|
+
listen();
|
|
36
|
+
writeThumbAutoplay(true);
|
|
37
|
+
writeStageHeight(STAGE_HEIGHT_KEY, 512.4);
|
|
38
|
+
writeSessionVolume(STAGE_VOLUME_KEY, { volume: 0.4, muted: false });
|
|
39
|
+
writeStageHeight(STAGE_HEIGHT_KEY, null);
|
|
40
|
+
expect(heard).toEqual([
|
|
41
|
+
['cbmg:thumb-autoplay', '1'],
|
|
42
|
+
[STAGE_HEIGHT_KEY, '512'],
|
|
43
|
+
[STAGE_VOLUME_KEY, '{"volume":0.4,"muted":false}'],
|
|
44
|
+
[STAGE_HEIGHT_KEY, null],
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a seed lands where each reader reads it, and announces nothing', () => {
|
|
49
|
+
listen();
|
|
50
|
+
seedPreferences({
|
|
51
|
+
'cbmg:thumb-autoplay': '1',
|
|
52
|
+
[STAGE_HEIGHT_KEY]: '640',
|
|
53
|
+
[STAGE_VOLUME_KEY]: '{"volume":0.25,"muted":false}',
|
|
54
|
+
});
|
|
55
|
+
expect(readThumbAutoplay()).toBe(true);
|
|
56
|
+
expect(readStageHeight(STAGE_HEIGHT_KEY)).toBe(640);
|
|
57
|
+
expect(readSessionVolume(STAGE_VOLUME_KEY)).toEqual({ volume: 0.25, muted: false });
|
|
58
|
+
expect(heard).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a malformed stored volume is ignored, and null forgets a stored value', () => {
|
|
62
|
+
writeStageHeight(STAGE_HEIGHT_KEY, 700);
|
|
63
|
+
seedPreferences({ [STAGE_VOLUME_KEY]: 'not json', [STAGE_HEIGHT_KEY]: null });
|
|
64
|
+
expect(readStageHeight(STAGE_HEIGHT_KEY)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('an app that never subscribes changes nothing — the listener is gone after unsubscribe', () => {
|
|
68
|
+
listen();
|
|
69
|
+
stop?.();
|
|
70
|
+
stop = null;
|
|
71
|
+
writeThumbAutoplay(false);
|
|
72
|
+
expect(heard).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A viewer preference that follows the PERSON, not the browser — the seam an app opts into.
|
|
3
|
+
*
|
|
4
|
+
* Every preference in this package is remembered on one device: `localStorage` for ways of
|
|
5
|
+
* working (the stage height, preview-all, the end behaviour), page memory for loudness
|
|
6
|
+
* (`playbackPreferences.ts` → `SessionVolume`). That is the right default for a shared
|
|
7
|
+
* component, because only the APP knows who is signed in and where it could keep a value.
|
|
8
|
+
*
|
|
9
|
+
* Owner, collections, 2026-09-21: *"remember my settings in the db and utilize them when I
|
|
10
|
+
* sign back in on any device … It should use debouncing for settings that may change a lot."*
|
|
11
|
+
*
|
|
12
|
+
* So this module is two verbs and nothing else:
|
|
13
|
+
*
|
|
14
|
+
* · {@link onPreferenceWrite} — every writer in this package announces what it just
|
|
15
|
+
* remembered, as `(key, value)`. An app that wants server-side memory listens, filters
|
|
16
|
+
* the keys it cares about, and debounces its own writes (a volume slider announces on
|
|
17
|
+
* every tick; the debounce is the app's, because the app owns the request).
|
|
18
|
+
* · {@link seedPreferences} — the app hands back what it stored, once it knows who is
|
|
19
|
+
* signed in, and each value lands in the SAME place the package reads it from. Seeding
|
|
20
|
+
* announces nothing, so a restore never echoes back to the server as a change.
|
|
21
|
+
*
|
|
22
|
+
* 🔴 An app that never calls either behaves exactly as before — nothing here writes on its
|
|
23
|
+
* own, and a listener set with no listeners is a loop over nothing.
|
|
24
|
+
*
|
|
25
|
+
* Values are strings (or `null` for "forget it"), because that is what the underlying stores
|
|
26
|
+
* hold; a session volume travels as its JSON, `{"volume":0.4,"muted":false}`.
|
|
27
|
+
*/
|
|
28
|
+
import { isSessionVolumeKey, writeSessionVolumeSilently } from './playbackPreferences.js';
|
|
29
|
+
|
|
30
|
+
export { onPreferenceWrite, type PreferenceListener } from './preferenceBus.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Put stored values back where this package reads them from, without announcing them.
|
|
34
|
+
*
|
|
35
|
+
* A session-volume key goes into page memory (where every player reads its opening level);
|
|
36
|
+
* everything else goes into `localStorage` under the same key its reader uses. `null`
|
|
37
|
+
* forgets. Wrapped for the reason every storage access here is — `localStorage` throws in a
|
|
38
|
+
* sandboxed iframe and Safari's private mode.
|
|
39
|
+
*/
|
|
40
|
+
export function seedPreferences(values: Readonly<Record<string, string | null>>): void {
|
|
41
|
+
for (const [key, value] of Object.entries(values)) {
|
|
42
|
+
if (isSessionVolumeKey(key)) {
|
|
43
|
+
if (value === null) continue;
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(value) as { volume?: unknown; muted?: unknown };
|
|
46
|
+
if (typeof parsed.volume === 'number' && typeof parsed.muted === 'boolean')
|
|
47
|
+
writeSessionVolumeSilently(key, { volume: parsed.volume, muted: parsed.muted });
|
|
48
|
+
} catch {
|
|
49
|
+
// A malformed stored volume is no stored volume.
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
if (value === null) globalThis.localStorage?.removeItem(key);
|
|
55
|
+
else globalThis.localStorage?.setItem(key, value);
|
|
56
|
+
} catch {
|
|
57
|
+
// Unremembered is still usable this session.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* after an app's responsive variant (task 2124). An app that imports every area gets exactly
|
|
19
19
|
* what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
|
|
20
20
|
* src/stylesAreas.spec.ts, which is also what fails when this file is stale.
|
|
21
|
-
*
|
|
21
|
+
* 33 candidate(s).
|
|
22
22
|
*
|
|
23
23
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
24
24
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -30,4 +30,4 @@
|
|
|
30
30
|
@source inline("opacity-100 outline-2 outline-accent outline-dashed outline-offset-[-2px] pb-1");
|
|
31
31
|
@source inline("place-items-center pointer-coarse:[&_button]:min-h-11 pointer-coarse:[&_button]:min-w-11");
|
|
32
32
|
@source inline("pointer-coarse:[&_button]:size-11 pt-1 self-stretch shadow-[inset_3px_0_0_0_var(--accent)] w-14");
|
|
33
|
-
@source inline("w-56");
|
|
33
|
+
@source inline("w-3 w-56");
|