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
@@ -0,0 +1,20 @@
1
+ const listeners = new Set();
2
+ /** Hear every preference this package remembers. Returns the unsubscribe. */
3
+ export function onPreferenceWrite(listener) {
4
+ listeners.add(listener);
5
+ return () => {
6
+ listeners.delete(listener);
7
+ };
8
+ }
9
+ /** Called by each writer in this package — not part of the public surface. */
10
+ export function announcePreference(key, value) {
11
+ for (const listener of listeners) {
12
+ try {
13
+ listener(key, value);
14
+ }
15
+ catch {
16
+ // A listener that throws must not take a volume slider down with it.
17
+ }
18
+ }
19
+ }
20
+ //# sourceMappingURL=preferenceBus.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preferenceBus.js","sourceRoot":"","sources":["../../../src/react/media-gallery/preferenceBus.ts"],"names":[],"mappings":"AAOA,MAAM,SAAS,GAAG,IAAI,GAAG,EAAsB,CAAC;AAEhD,6EAA6E;AAC7E,MAAM,UAAU,iBAAiB,CAAC,QAA4B;IAC5D,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IACxB,OAAO,GAAG,EAAE;QACV,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC7B,CAAC,CAAC;AACJ,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,kBAAkB,CAAC,GAAW,EAAE,KAAoB;IAClE,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;QACjC,IAAI,CAAC;YACH,QAAQ,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;QACvB,CAAC;QAAC,MAAM,CAAC;YACP,qEAAqE;QACvE,CAAC;IACH,CAAC;AACH,CAAC"}
@@ -0,0 +1,11 @@
1
+ export { onPreferenceWrite, type PreferenceListener } from './preferenceBus.js';
2
+ /**
3
+ * Put stored values back where this package reads them from, without announcing them.
4
+ *
5
+ * A session-volume key goes into page memory (where every player reads its opening level);
6
+ * everything else goes into `localStorage` under the same key its reader uses. `null`
7
+ * forgets. Wrapped for the reason every storage access here is — `localStorage` throws in a
8
+ * sandboxed iframe and Safari's private mode.
9
+ */
10
+ export declare function seedPreferences(values: Readonly<Record<string, string | null>>): void;
11
+ //# sourceMappingURL=preferenceSync.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preferenceSync.d.ts","sourceRoot":"","sources":["../../../src/react/media-gallery/preferenceSync.ts"],"names":[],"mappings":"AA6BA,OAAO,EAAE,iBAAiB,EAAE,KAAK,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAEhF;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC,GAAG,IAAI,CAoBrF"}
@@ -0,0 +1,64 @@
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
+ export { onPreferenceWrite } from './preferenceBus.js';
30
+ /**
31
+ * Put stored values back where this package reads them from, without announcing them.
32
+ *
33
+ * A session-volume key goes into page memory (where every player reads its opening level);
34
+ * everything else goes into `localStorage` under the same key its reader uses. `null`
35
+ * forgets. Wrapped for the reason every storage access here is — `localStorage` throws in a
36
+ * sandboxed iframe and Safari's private mode.
37
+ */
38
+ export function seedPreferences(values) {
39
+ for (const [key, value] of Object.entries(values)) {
40
+ if (isSessionVolumeKey(key)) {
41
+ if (value === null)
42
+ continue;
43
+ try {
44
+ const parsed = JSON.parse(value);
45
+ if (typeof parsed.volume === 'number' && typeof parsed.muted === 'boolean')
46
+ writeSessionVolumeSilently(key, { volume: parsed.volume, muted: parsed.muted });
47
+ }
48
+ catch {
49
+ // A malformed stored volume is no stored volume.
50
+ }
51
+ continue;
52
+ }
53
+ try {
54
+ if (value === null)
55
+ globalThis.localStorage?.removeItem(key);
56
+ else
57
+ globalThis.localStorage?.setItem(key, value);
58
+ }
59
+ catch {
60
+ // Unremembered is still usable this session.
61
+ }
62
+ }
63
+ }
64
+ //# sourceMappingURL=preferenceSync.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preferenceSync.js","sourceRoot":"","sources":["../../../src/react/media-gallery/preferenceSync.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,EAAE,kBAAkB,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AAE1F,OAAO,EAAE,iBAAiB,EAA2B,MAAM,oBAAoB,CAAC;AAEhF;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAAC,MAA+C;IAC7E,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QAClD,IAAI,kBAAkB,CAAC,GAAG,CAAC,EAAE,CAAC;YAC5B,IAAI,KAAK,KAAK,IAAI;gBAAE,SAAS;YAC7B,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAA0C,CAAC;gBAC1E,IAAI,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ,IAAI,OAAO,MAAM,CAAC,KAAK,KAAK,SAAS;oBACxE,0BAA0B,CAAC,GAAG,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YACpF,CAAC;YAAC,MAAM,CAAC;gBACP,iDAAiD;YACnD,CAAC;YACD,SAAS;QACX,CAAC;QACD,IAAI,CAAC;YACH,IAAI,KAAK,KAAK,IAAI;gBAAE,UAAU,CAAC,YAAY,EAAE,UAAU,CAAC,GAAG,CAAC,CAAC;;gBACxD,UAAU,CAAC,YAAY,EAAE,OAAO,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;QACpD,CAAC;QAAC,MAAM,CAAC;YACP,6CAA6C;QAC/C,CAAC;IACH,CAAC;AACH,CAAC"}
@@ -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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.7.2",
3
+ "version": "5.8.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -693,7 +693,7 @@
693
693
  "@vidstack/react": "1.15.6",
694
694
  "@vitejs/plugin-react": "^5.2.0",
695
695
  "cursedbelt-server": "^4.13.0",
696
- "cursedops": "^0.2.7",
696
+ "cursedops": "^0.8.1",
697
697
  "diff": "^9.0.0",
698
698
  "emoji-mart": "^5.6.0",
699
699
  "hls.js": "^1.5.0",
@@ -106,6 +106,25 @@ describe('drawing', () => {
106
106
  expect(img.getAttribute('alt')).toBe('');
107
107
  });
108
108
 
109
+ test('a turned picture is drawn turned — `thumbnailTransform`', () => {
110
+ // Owner, collections, 2026-09-21: a video rotated and saved should show that rotation on
111
+ // its thumbnail too. Absent for every other row, so nothing else changes.
112
+ const { container } = render(
113
+ <FileTree
114
+ nodes={[
115
+ { id: 'v', name: 'sideways.mov', kind: 'file', thumbnailUrl: '/t/v.jpg' },
116
+ { id: 'p', name: 'upright.jpg', kind: 'file', thumbnailUrl: '/t/p.jpg' },
117
+ ]}
118
+ selectedId={null}
119
+ onSelect={() => undefined}
120
+ thumbnailTransform={(node) => (node.id === 'v' ? 'rotate(90deg)' : undefined)}
121
+ />,
122
+ );
123
+ const [turned, plain] = [...container.querySelectorAll('img.cbft-thumb')] as HTMLImageElement[];
124
+ expect(turned?.style.transform).toBe('rotate(90deg)');
125
+ expect(plain?.getAttribute('style')).toBeNull();
126
+ });
127
+
109
128
  test('🔴 a thumbnail that 404s falls back to the type icon, ONCE, and never retries', () => {
110
129
  // A derived thumbnail's absence is the normal "not rendered yet" state, not an error —
111
130
  // so the honest picture is the type icon, never a broken-image glyph. Re-pointing `src`
@@ -543,6 +543,16 @@ export interface FileTreeProps {
543
543
  renderTools?: (node: FileTreeNode) => ReactNode;
544
544
  /** Leading glyph. Defaults to a folder/file mark. */
545
545
  renderIcon?: (node: FileTreeNode) => ReactNode;
546
+ /**
547
+ * A CSS transform for a row's thumbnail — how the app says a picture is stored turned.
548
+ *
549
+ * Owner, collections, 2026-09-21: *"when I rotate and save a video it should use the same
550
+ * rotation choice for the thumbnail"*. The grid tile already turns by `orientation`; a row's
551
+ * glyph showing the same film sideways beside it is one file drawn two ways. A prop rather
552
+ * than a node field because the node shape is `cursedbelt-core`'s and a turn is a rendering
553
+ * fact. The glyph is square and `object-fit: cover`, so a turn needs no rescale.
554
+ */
555
+ thumbnailTransform?: (node: FileTreeNode) => string | undefined;
546
556
  /** Extra content after the name — a lock glyph, a chip. */
547
557
  renderAccessory?: (node: FileTreeNode) => ReactNode;
548
558
 
@@ -660,10 +670,12 @@ function RowGlyph({
660
670
  node,
661
671
  open,
662
672
  renderIcon,
673
+ transform,
663
674
  }: {
664
675
  node: FileTreeNode;
665
676
  open: boolean;
666
677
  renderIcon?: (node: FileTreeNode) => ReactNode;
678
+ transform?: string | undefined;
667
679
  }): ReactNode {
668
680
  const [failed, setFailed] = useState<string | null>(null);
669
681
  const icon = renderIcon ? renderIcon(node) : <DefaultIcon node={node} open={open} />;
@@ -687,6 +699,7 @@ function RowGlyph({
687
699
  decoding='async'
688
700
  draggable={false}
689
701
  onError={() => setFailed(src)}
702
+ {...(transform ? { style: { transform } } : {})}
690
703
  />
691
704
  );
692
705
  }
@@ -729,6 +742,7 @@ export function FileTree({
729
742
  onRowMenu,
730
743
  renderTools,
731
744
  renderIcon,
745
+ thumbnailTransform,
732
746
  renderAccessory,
733
747
  onDropExternal,
734
748
  acceptsExternalDrag,
@@ -2333,7 +2347,12 @@ export function FileTree({
2333
2347
  twisty with `expandable: false`, which every PINNED copy does.
2334
2348
  */
2335
2349
  <span className='cbft-slot' aria-hidden='true'>
2336
- <RowGlyph node={node} open={row.expanded} {...(renderIcon ? { renderIcon } : {})} />
2350
+ <RowGlyph
2351
+ node={node}
2352
+ open={row.expanded}
2353
+ {...(renderIcon ? { renderIcon } : {})}
2354
+ transform={thumbnailTransform?.(node)}
2355
+ />
2337
2356
  </span>
2338
2357
  )}
2339
2358
 
@@ -547,3 +547,48 @@ describe('FilterRail — tree rows', () => {
547
547
  expect(container.querySelector('[data-cb-rail-row-depth]')).toBeNull();
548
548
  });
549
549
  });
550
+
551
+ /**
552
+ * 🔴 Owner, 2026-09-24: *"the cb filetree sidebars should allow grabbing their right edge and
553
+ * dragging to resize them like in vscode."* The drag itself needs layout happy-dom does not
554
+ * have; the keyboard path runs the same clamp and the same write, so it is what proves the
555
+ * width moves, is remembered, and resets.
556
+ */
557
+ describe('FilterRail — resizeKey', () => {
558
+ afterEach(() => globalThis.localStorage?.clear());
559
+ const props = { sections: SECTIONS, selected: null, onSelect: () => {}, label: 'Folders' };
560
+
561
+ test('absent by default — no handle, the fixed width class', () => {
562
+ const { container } = render(<FilterRail {...props} />);
563
+ expect(container.querySelector('[data-cb-rail-resize]')).toBeNull();
564
+ expect(container.querySelector('nav')?.className).toContain('w-56');
565
+ });
566
+
567
+ test('→ widens it and remembers the width; a double-click puts it back', () => {
568
+ globalThis.localStorage?.setItem('test:rail', '300');
569
+ const { container } = render(<FilterRail {...props} resizeKey='test:rail' />);
570
+ const nav = container.querySelector('nav') as HTMLElement;
571
+ expect(nav.style.width).toBe('300px');
572
+ const handle = container.querySelector('[data-cb-rail-resize]') as HTMLElement;
573
+ fireEvent.keyDown(handle, { key: 'ArrowRight' });
574
+ expect(nav.style.width).toBe('316px');
575
+ expect(globalThis.localStorage?.getItem('test:rail')).toBe('316');
576
+ fireEvent.doubleClick(handle);
577
+ expect(nav.style.width).toBe('');
578
+ expect(nav.className).toContain('w-56');
579
+ expect(globalThis.localStorage?.getItem('test:rail')).toBeNull();
580
+ });
581
+
582
+ test('the width is clamped — it cannot be dragged into a sliver', () => {
583
+ globalThis.localStorage?.setItem('test:rail', '20');
584
+ const { container } = render(<FilterRail {...props} resizeKey='test:rail' />);
585
+ expect((container.querySelector('nav') as HTMLElement).style.width).toBe('160px');
586
+ });
587
+
588
+ test('no handle while collapsed', () => {
589
+ const { container } = render(
590
+ <FilterRail {...props} resizeKey='test:rail' collapsed onCollapsedChange={() => {}} />,
591
+ );
592
+ expect(container.querySelector('[data-cb-rail-resize]')).toBeNull();
593
+ });
594
+ });
@@ -1,9 +1,11 @@
1
1
  import { ChevronsLeft, ChevronsRight } from 'lucide-react';
2
- import type { DragEvent, ReactNode } from 'react';
2
+ import type { DragEvent, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
3
+ import { useCallback, useRef, useState } from 'react';
3
4
  import { ScrollArea } from '../components/ScrollArea.js';
4
5
  import { cn } from '../lib/cn.js';
5
6
  import { Tooltip } from '../components/Tooltip.js';
6
7
  import { TAP_TARGET, TAP_TARGET_ALWAYS, TAP_TARGET_HEIGHT } from '../lib/tapTarget.js';
8
+ import { clampRailWidth, MAX_RAIL_PX, MIN_RAIL_PX, readRailWidth, writeRailWidth } from './railWidth.js';
7
9
 
8
10
  export interface FilterRailItem {
9
11
  /** Stable key, and the value handed to `onSelect`. */
@@ -180,6 +182,12 @@ export interface FilterRailProps {
180
182
  footer?: ReactNode;
181
183
  /** Expanded width. Default `w-56` (14rem). */
182
184
  width?: string;
185
+ /**
186
+ * Let the person drag the rail's right edge to resize it, like VS Code's side bar, and
187
+ * remember the width under this key. Absent: a fixed `width`, exactly as before.
188
+ * ←/→ nudge it from the keyboard; a double-click puts it back to `width`. See `railWidth.ts`.
189
+ */
190
+ resizeKey?: string;
183
191
  className?: string;
184
192
  }
185
193
 
@@ -238,18 +246,93 @@ export function FilterRail({
238
246
  header,
239
247
  footer,
240
248
  width = 'w-56',
249
+ resizeKey,
241
250
  className,
242
251
  }: FilterRailProps) {
252
+ const navRef = useRef<HTMLElement | null>(null);
253
+ const [dragged, setDragged] = useState<number | null>(() =>
254
+ resizeKey ? readRailWidth(resizeKey) : null,
255
+ );
256
+ const resizing = Boolean(resizeKey) && !collapsed;
257
+ const onResizePointerDown = useCallback(
258
+ (event: ReactPointerEvent<HTMLDivElement>) => {
259
+ if (!resizeKey || event.button !== 0) return;
260
+ const handle = event.currentTarget;
261
+ const startX = event.clientX;
262
+ const startWidth = navRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX;
263
+ let latest = startWidth;
264
+ handle.setPointerCapture?.(event.pointerId);
265
+ const move = (moveEvent: PointerEvent) => {
266
+ latest = clampRailWidth(startWidth + moveEvent.clientX - startX);
267
+ setDragged(latest);
268
+ };
269
+ const end = () => {
270
+ handle.releasePointerCapture?.(event.pointerId);
271
+ handle.removeEventListener('pointermove', move);
272
+ handle.removeEventListener('pointerup', end);
273
+ handle.removeEventListener('pointercancel', end);
274
+ // Remembered on RELEASE — a drag is one decision, not sixty a second.
275
+ writeRailWidth(resizeKey, latest);
276
+ };
277
+ handle.addEventListener('pointermove', move);
278
+ handle.addEventListener('pointerup', end);
279
+ handle.addEventListener('pointercancel', end);
280
+ event.preventDefault();
281
+ },
282
+ [resizeKey],
283
+ );
284
+ const onResizeKey = useCallback(
285
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
286
+ if (!resizeKey) return;
287
+ const step = event.shiftKey ? 64 : 16;
288
+ const delta = event.key === 'ArrowRight' ? step : event.key === 'ArrowLeft' ? -step : 0;
289
+ if (!delta) return;
290
+ event.preventDefault();
291
+ const next = clampRailWidth(
292
+ (dragged ?? navRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX) + delta,
293
+ );
294
+ setDragged(next);
295
+ writeRailWidth(resizeKey, next);
296
+ },
297
+ [resizeKey, dragged],
298
+ );
243
299
  return (
244
300
  <nav
301
+ ref={navRef}
245
302
  aria-label={label}
246
303
  data-cb-filter-rail={collapsed ? 'collapsed' : 'expanded'}
247
304
  className={cn(
248
305
  'flex h-full min-h-0 shrink-0 flex-col border-r border-border bg-surface',
249
- collapsed ? 'w-14' : width,
306
+ collapsed ? 'w-14' : dragged !== null && resizing ? undefined : width,
307
+ resizing && 'relative',
250
308
  className,
251
309
  )}
310
+ {...(resizing && dragged !== null ? { style: { width: dragged } } : {})}
252
311
  >
312
+ {resizing ? (
313
+ // biome-ignore lint/a11y/useSemanticElements: an <hr> cannot take focus or a drag; a focusable separator is the ARIA splitter pattern.
314
+ <div
315
+ role='separator'
316
+ aria-orientation='vertical'
317
+ aria-label={`Resize ${label}`}
318
+ aria-valuemin={MIN_RAIL_PX}
319
+ aria-valuemax={MAX_RAIL_PX}
320
+ aria-valuenow={Math.round(dragged ?? navRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX)}
321
+ tabIndex={0}
322
+ data-cb-rail-resize=''
323
+ onPointerDown={onResizePointerDown}
324
+ onKeyDown={onResizeKey}
325
+ onDoubleClick={() => {
326
+ setDragged(null);
327
+ if (resizeKey) writeRailWidth(resizeKey, null);
328
+ }}
329
+ // A 1px edge you cannot hit is not a handle: the hotspot is 12px, centred on the
330
+ // border, and only the hairline tints — no layout shift, like `PanelSeparator`.
331
+ // Only utilities the stylesheet already ships, so the handle adds no CSS to any app
332
+ // (`fixtureAppCss.spec.ts` budgets that).
333
+ className='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'
334
+ />
335
+ ) : null}
253
336
  {onCollapsedChange ? (
254
337
  <div className={cn('flex flex-none items-center px-2 pt-2', collapsed && 'justify-center')}>
255
338
  {!collapsed && <span className='flex-1' />}
@@ -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 {