cursedbelt 5.7.2 โ†’ 5.8.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 (55) 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 +53 -2
  8. package/dist/react/filter-rail/FilterRail.js.map +1 -1
  9. package/dist/react/filter-rail/railWidth.d.ts +11 -0
  10. package/dist/react/filter-rail/railWidth.d.ts.map +1 -0
  11. package/dist/react/filter-rail/railWidth.js +48 -0
  12. package/dist/react/filter-rail/railWidth.js.map +1 -0
  13. package/dist/react/media/VirtualMediaGrid.d.ts +3 -0
  14. package/dist/react/media/VirtualMediaGrid.d.ts.map +1 -1
  15. package/dist/react/media/VirtualMediaGrid.js +3 -0
  16. package/dist/react/media/VirtualMediaGrid.js.map +1 -1
  17. package/dist/react/media/stageResize.d.ts.map +1 -1
  18. package/dist/react/media/stageResize.js +2 -0
  19. package/dist/react/media/stageResize.js.map +1 -1
  20. package/dist/react/media-gallery/MediaGallery.d.ts +12 -1
  21. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  22. package/dist/react/media-gallery/MediaGallery.js +11 -10
  23. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  24. package/dist/react/media-gallery/index.d.ts +3 -0
  25. package/dist/react/media-gallery/index.d.ts.map +1 -1
  26. package/dist/react/media-gallery/index.js +4 -0
  27. package/dist/react/media-gallery/index.js.map +1 -1
  28. package/dist/react/media-gallery/playbackPreferences.d.ts +7 -0
  29. package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
  30. package/dist/react/media-gallery/playbackPreferences.js +22 -2
  31. package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
  32. package/dist/react/media-gallery/preferenceBus.d.ts +11 -0
  33. package/dist/react/media-gallery/preferenceBus.d.ts.map +1 -0
  34. package/dist/react/media-gallery/preferenceBus.js +20 -0
  35. package/dist/react/media-gallery/preferenceBus.js.map +1 -0
  36. package/dist/react/media-gallery/preferenceSync.d.ts +11 -0
  37. package/dist/react/media-gallery/preferenceSync.d.ts.map +1 -0
  38. package/dist/react/media-gallery/preferenceSync.js +64 -0
  39. package/dist/react/media-gallery/preferenceSync.js.map +1 -0
  40. package/dist/styles-areas/filter-rail.css +2 -2
  41. package/package.json +2 -2
  42. package/src/react/file-tree/FileTree.spec.tsx +19 -0
  43. package/src/react/file-tree/FileTree.tsx +20 -1
  44. package/src/react/filter-rail/FilterRail.spec.tsx +45 -0
  45. package/src/react/filter-rail/FilterRail.tsx +85 -2
  46. package/src/react/filter-rail/railWidth.ts +47 -0
  47. package/src/react/media/VirtualMediaGrid.tsx +4 -0
  48. package/src/react/media/stageResize.ts +3 -0
  49. package/src/react/media-gallery/MediaGallery.tsx +25 -2
  50. package/src/react/media-gallery/index.ts +4 -0
  51. package/src/react/media-gallery/playbackPreferences.ts +26 -2
  52. package/src/react/media-gallery/preferenceBus.ts +28 -0
  53. package/src/react/media-gallery/preferenceSync.spec.ts +74 -0
  54. package/src/react/media-gallery/preferenceSync.ts +60 -0
  55. package/src/styles-areas/filter-rail.css +2 -2
@@ -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");