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.
Files changed (66) hide show
  1. package/dist/react/file-tree/FileTree.d.ts +11 -1
  2. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  3. package/dist/react/file-tree/FileTree.js +4 -4
  4. package/dist/react/file-tree/FileTree.js.map +1 -1
  5. package/dist/react/filter-rail/FilterRail.d.ts +7 -1
  6. package/dist/react/filter-rail/FilterRail.d.ts.map +1 -1
  7. package/dist/react/filter-rail/FilterRail.js +6 -2
  8. package/dist/react/filter-rail/FilterRail.js.map +1 -1
  9. package/dist/react/filter-rail/index.d.ts +1 -0
  10. package/dist/react/filter-rail/index.d.ts.map +1 -1
  11. package/dist/react/filter-rail/index.js +1 -0
  12. package/dist/react/filter-rail/index.js.map +1 -1
  13. package/dist/react/filter-rail/railResize.d.ts +63 -0
  14. package/dist/react/filter-rail/railResize.d.ts.map +1 -0
  15. package/dist/react/filter-rail/railResize.js +79 -0
  16. package/dist/react/filter-rail/railResize.js.map +1 -0
  17. package/dist/react/filter-rail/railWidth.d.ts +11 -0
  18. package/dist/react/filter-rail/railWidth.d.ts.map +1 -0
  19. package/dist/react/filter-rail/railWidth.js +48 -0
  20. package/dist/react/filter-rail/railWidth.js.map +1 -0
  21. package/dist/react/media/VirtualMediaGrid.d.ts +3 -0
  22. package/dist/react/media/VirtualMediaGrid.d.ts.map +1 -1
  23. package/dist/react/media/VirtualMediaGrid.js +3 -0
  24. package/dist/react/media/VirtualMediaGrid.js.map +1 -1
  25. package/dist/react/media/stageResize.d.ts.map +1 -1
  26. package/dist/react/media/stageResize.js +2 -0
  27. package/dist/react/media/stageResize.js.map +1 -1
  28. package/dist/react/media-gallery/MediaGallery.d.ts +12 -1
  29. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  30. package/dist/react/media-gallery/MediaGallery.js +11 -10
  31. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  32. package/dist/react/media-gallery/index.d.ts +3 -0
  33. package/dist/react/media-gallery/index.d.ts.map +1 -1
  34. package/dist/react/media-gallery/index.js +4 -0
  35. package/dist/react/media-gallery/index.js.map +1 -1
  36. package/dist/react/media-gallery/playbackPreferences.d.ts +7 -0
  37. package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
  38. package/dist/react/media-gallery/playbackPreferences.js +22 -2
  39. package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
  40. package/dist/react/media-gallery/preferenceBus.d.ts +11 -0
  41. package/dist/react/media-gallery/preferenceBus.d.ts.map +1 -0
  42. package/dist/react/media-gallery/preferenceBus.js +20 -0
  43. package/dist/react/media-gallery/preferenceBus.js.map +1 -0
  44. package/dist/react/media-gallery/preferenceSync.d.ts +11 -0
  45. package/dist/react/media-gallery/preferenceSync.d.ts.map +1 -0
  46. package/dist/react/media-gallery/preferenceSync.js +64 -0
  47. package/dist/react/media-gallery/preferenceSync.js.map +1 -0
  48. package/dist/styles-areas/filter-rail.css +2 -2
  49. package/package.json +1 -1
  50. package/src/react/file-tree/FileTree.spec.tsx +19 -0
  51. package/src/react/file-tree/FileTree.tsx +20 -1
  52. package/src/react/filter-rail/FilterRail.spec.tsx +45 -0
  53. package/src/react/filter-rail/FilterRail.tsx +16 -1
  54. package/src/react/filter-rail/index.ts +1 -0
  55. package/src/react/filter-rail/railResize.spec.tsx +92 -0
  56. package/src/react/filter-rail/railResize.tsx +156 -0
  57. package/src/react/filter-rail/railWidth.ts +47 -0
  58. package/src/react/media/VirtualMediaGrid.tsx +4 -0
  59. package/src/react/media/stageResize.ts +3 -0
  60. package/src/react/media-gallery/MediaGallery.tsx +25 -2
  61. package/src/react/media-gallery/index.ts +4 -0
  62. package/src/react/media-gallery/playbackPreferences.ts +26 -2
  63. package/src/react/media-gallery/preferenceBus.ts +28 -0
  64. package/src/react/media-gallery/preferenceSync.spec.ts +74 -0
  65. package/src/react/media-gallery/preferenceSync.ts +60 -0
  66. 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
- sessionVolumes.set(key, {
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
- * 32 candidate(s).
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");