cursedbelt 5.74.0 → 5.75.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 (52) hide show
  1. package/dist/react/components/Page.d.ts.map +1 -1
  2. package/dist/react/components/Page.js +10 -1
  3. package/dist/react/components/Page.js.map +1 -1
  4. package/dist/react/components/PropertyPicker.d.ts +75 -0
  5. package/dist/react/components/PropertyPicker.d.ts.map +1 -0
  6. package/dist/react/components/PropertyPicker.js +108 -0
  7. package/dist/react/components/PropertyPicker.js.map +1 -0
  8. package/dist/react/components/TooltipImpl.d.ts.map +1 -1
  9. package/dist/react/components/TooltipImpl.js +22 -3
  10. package/dist/react/components/TooltipImpl.js.map +1 -1
  11. package/dist/react/index.d.ts +1 -0
  12. package/dist/react/index.d.ts.map +1 -1
  13. package/dist/react/index.js +1 -0
  14. package/dist/react/index.js.map +1 -1
  15. package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
  16. package/dist/react/media/DenseGalleryShell.js +73 -9
  17. package/dist/react/media/DenseGalleryShell.js.map +1 -1
  18. package/dist/react/media/HoverScrubVideoThumb.d.ts +11 -9
  19. package/dist/react/media/HoverScrubVideoThumb.d.ts.map +1 -1
  20. package/dist/react/media/HoverScrubVideoThumb.js +98 -25
  21. package/dist/react/media/HoverScrubVideoThumb.js.map +1 -1
  22. package/dist/react/media/denseGallery.css +103 -7
  23. package/dist/react/media-gallery/GalleryTable.js +7 -9
  24. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  25. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  26. package/dist/react/media-gallery/MediaGallery.js +19 -7
  27. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  28. package/dist/react/media-gallery/playbackPreferences.d.ts +2 -2
  29. package/dist/react/media-gallery/playbackPreferences.js +2 -2
  30. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  31. package/dist/styles-areas/core.css +8 -6
  32. package/dist/styles-areas/palette.css +8 -7
  33. package/dist/styles-utilities.css +17 -0
  34. package/package.json +1 -1
  35. package/scripts/guardrailsEnforce.spec.ts +21 -0
  36. package/scripts/guardrailsEnforce.ts +41 -0
  37. package/scripts/styleAreas.ts +1 -1
  38. package/src/react/components/Page.tsx +11 -1
  39. package/src/react/components/PropertyPicker.spec.tsx +105 -0
  40. package/src/react/components/PropertyPicker.tsx +254 -0
  41. package/src/react/components/TooltipImpl.tsx +21 -1
  42. package/src/react/index.ts +8 -0
  43. package/src/react/media/DenseGalleryShell.tsx +90 -6
  44. package/src/react/media/HoverScrubVideoThumb.spec.tsx +82 -0
  45. package/src/react/media/HoverScrubVideoThumb.tsx +116 -28
  46. package/src/react/media/denseGallery.css +103 -7
  47. package/src/react/media-gallery/GalleryTable.tsx +7 -9
  48. package/src/react/media-gallery/MediaGallery.tsx +18 -7
  49. package/src/react/media-gallery/playbackPreferences.ts +2 -2
  50. package/src/styles-areas/core.css +8 -6
  51. package/src/styles-areas/palette.css +8 -7
  52. package/src/styles-utilities.css +17 -0
@@ -1,5 +1,5 @@
1
1
  import './denseGallery.css';
2
- import { ChevronLeft, ChevronRight, Maximize2, Minimize2 } from 'lucide-react';
2
+ import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Maximize2, Minimize2 } from 'lucide-react';
3
3
  import {
4
4
  type ButtonHTMLAttributes,
5
5
  type CSSProperties,
@@ -8,6 +8,8 @@ import {
8
8
  type Ref,
9
9
  useCallback,
10
10
  useEffect,
11
+ useId,
12
+ useLayoutEffect,
11
13
  useState,
12
14
  } from 'react';
13
15
  import { VirtualMediaGrid } from './VirtualMediaGrid.js';
@@ -558,8 +560,23 @@ export function DenseGalleryDetail({
558
560
  */
559
561
  const top = watchEl.querySelector('.cbgd-watch-top');
560
562
  const frame = watchEl.querySelector('.cbgd-watch-player-box');
561
- const chromePx =
562
- top && frame
563
+ /*
564
+ * 🔴 A phone on its SIDE puts the titlebar BESIDE the frame (`--cbgd-stage-beside`, set by
565
+ * `denseGallery.css`'s short-landscape block), so nothing of the chrome is under the
566
+ * picture: only the region's own padding is. Measuring the region there would be a loop —
567
+ * the titlebar's column is whatever width the frame leaves it, so its height depends on
568
+ * the frame's — and the stylesheet's share (`--cbgd-stage-share`) is the whole height.
569
+ */
570
+ const style = getComputedStyle(watchEl);
571
+ const beside = style.getPropertyValue('--cbgd-stage-beside').trim() === '1';
572
+ const shareVar = Number.parseFloat(style.getPropertyValue('--cbgd-stage-share'));
573
+ const share = Number.isFinite(shareVar) && shareVar > 0 ? shareVar : STAGE_SHARE;
574
+ const topStyle = top ? getComputedStyle(top) : null;
575
+ const chromePx = beside
576
+ ? Math.round(
577
+ Number.parseFloat(topStyle?.paddingTop ?? '0') + Number.parseFloat(topStyle?.paddingBottom ?? '0'),
578
+ )
579
+ : top && frame
563
580
  ? Math.round(
564
581
  top.getBoundingClientRect().height - frame.getBoundingClientRect().height,
565
582
  )
@@ -569,8 +586,12 @@ export function DenseGalleryDetail({
569
586
  // `max()`, because a rule that can GROW the stage tracks the content it is measuring
570
587
  // and chases itself. That guard reads the source text, so the JS clamp is written in a
571
588
  // form that cannot be mistaken for the thing it forbids.
572
- const usable = available > chromePx ? available - chromePx : 0;
573
- const fit = `min(${Math.round(usable * STAGE_SHARE)}px, var(--cbgd-stage-default))`;
589
+ // Beside, the frame is measured against the SCROLLER itself: its parent also holds the
590
+ // app's page header, and the whole point is that the picture fits the scroller's height.
591
+ // Loop-free — the scroller's box is its parent's layout, not its content's.
592
+ const room = beside ? watchEl.clientHeight : available;
593
+ const usable = room > chromePx ? room - chromePx : 0;
594
+ const fit = `min(${Math.round(usable * share)}px, var(--cbgd-stage-default))`;
574
595
  if (fit === last) return;
575
596
  last = fit;
576
597
  watchEl.style.setProperty('--cbgd-stage-fit', fit);
@@ -689,6 +710,51 @@ export function DenseGalleryDetail({
689
710
 
690
711
  const effectiveStageHeight = dragHeight !== null ? `${dragHeight}px` : stageHeight;
691
712
 
713
+ /*
714
+ * 🔴 On a phone the action row is ONE line, and what does not fit FOLDS behind a toggle.
715
+ *
716
+ * Owner, 2026-10-10, on an iPhone: *"the icons over the media player go out of the right side
717
+ * of the screen."* The row scrolled sideways with its scrollbar hidden (task 2424 chose one
718
+ * row over three wrapped ones), so it read as icons cut off by the screen's edge with no sign
719
+ * there were more. Now the row wraps but is clipped to its first line, and a toggle at its end
720
+ * — drawn only when something is actually clipped — opens the rest in place, the way iOS
721
+ * Photos and Linear's mobile issue bar do. One line stays the default, so the files are still
722
+ * right under the picture.
723
+ *
724
+ * The CSS decides WHERE the fold applies (`.cbgd-actions-more` is `display: none` outside the
725
+ * phone block); this only measures whether anything is past the first line. Measured on every
726
+ * render because the row's contents change (an inline rename swaps buttons for a form) without
727
+ * its clipped box changing size, and on resize for the width.
728
+ */
729
+ const actionsId = useId();
730
+ const [actionsEl, setActionsEl] = useState<HTMLElement | null>(null);
731
+ const [actionsOpen, setActionsOpen] = useState(false);
732
+ const [actionsFold, setActionsFold] = useState(false);
733
+ const measureFold = useCallback(() => {
734
+ if (!actionsEl) return;
735
+ const kids = [...actionsEl.children] as HTMLElement[];
736
+ const first = Math.min(...kids.map((kid) => kid.offsetTop));
737
+ let folds = false;
738
+ for (const kid of kids) {
739
+ const folded = kid.offsetTop > first + 4;
740
+ folds ||= folded;
741
+ // A control on a folded line is out of reach, so it is out of the tab order and the
742
+ // accessibility tree too (`visibility: hidden` in the phone block), not merely clipped.
743
+ kid.toggleAttribute('data-cbgd-folded', folded && !actionsOpen);
744
+ }
745
+ setActionsFold((current) => (current === folds ? current : folds));
746
+ }, [actionsEl, actionsOpen]);
747
+ // No dependency list: every render, on purpose — see above.
748
+ useLayoutEffect(() => {
749
+ measureFold();
750
+ });
751
+ useEffect(() => {
752
+ if (!actionsEl || typeof ResizeObserver === 'undefined') return;
753
+ const observer = new ResizeObserver(measureFold);
754
+ observer.observe(actionsEl);
755
+ return () => observer.disconnect();
756
+ }, [actionsEl, measureFold]);
757
+
692
758
  return (
693
759
  <div
694
760
  ref={setWatchEl}
@@ -743,7 +809,11 @@ export function DenseGalleryDetail({
743
809
  <div className='cbgd-watch-titlebar'>
744
810
  <span className='cbgd-watch-title'>{title}</span>
745
811
  {meta ? <span className='cbgd-meta-line'>{meta}</span> : null}
746
- <span className='cbgd-watch-actions'>
812
+ <span
813
+ ref={setActionsEl}
814
+ className={`cbgd-watch-actions${actionsOpen ? ' is-open' : ''}`}
815
+ id={actionsId}
816
+ >
747
817
  {/*
748
818
  * 🔴 Stepping lives in the TITLEBAR now, not on the picture.
749
819
  *
@@ -811,6 +881,20 @@ export function DenseGalleryDetail({
811
881
  </button>
812
882
  </Tooltip>
813
883
  </span>
884
+ {actionsFold || actionsOpen ? (
885
+ <span className='cbgd-actions-more'>
886
+ <GalleryActionButton
887
+ iconOnly
888
+ tooltip={actionsOpen ? 'Fold the actions back to one line' : 'Show every action'}
889
+ aria-label={actionsOpen ? 'Fewer actions' : 'More actions'}
890
+ aria-expanded={actionsOpen}
891
+ aria-controls={actionsId}
892
+ onClick={() => setActionsOpen(!actionsOpen)}
893
+ >
894
+ {actionsOpen ? <ChevronUp aria-hidden /> : <ChevronDown aria-hidden />}
895
+ </GalleryActionButton>
896
+ </span>
897
+ ) : null}
814
898
  </div>
815
899
  </div>
816
900
  {rail ? (
@@ -3,6 +3,10 @@ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
3
3
  import type { PlayableVideo } from 'cursedbelt-core/media';
4
4
  import { HoverScrubVideoThumb } from './HoverScrubVideoThumb.js';
5
5
 
6
+ /** `HoverScrubVideoThumb`'s hold threshold (300ms), spelled here so this file still loads against
7
+ * a tile that predates the hold — the proof that these specs fail without it. */
8
+ const TOUCH_HOLD_MS = 300;
9
+
6
10
  afterEach(cleanup);
7
11
 
8
12
  const video: PlayableVideo = {
@@ -91,4 +95,82 @@ describe('HoverScrubVideoThumb', () => {
91
95
  expect(opened).toBe(1);
92
96
  expect(getByText('meta line')).toBeDefined();
93
97
  });
98
+
99
+ /**
100
+ * 🔴 On a PHONE. Owner, 2026-10-10, on an iPhone: *"The play icon above the player clicked but
101
+ * it did not make all the video thumbnails start playing like I thought it would."* The tile
102
+ * dropped `autoPlay` under a coarse pointer, so "Preview all" lit up and nothing moved.
103
+ */
104
+ test('🔴 autoPlay previews on a touch screen too', async () => {
105
+ const real = window.matchMedia;
106
+ window.matchMedia = ((query: string) => ({ ...real.call(window, query), matches: false })) as typeof window.matchMedia;
107
+ try {
108
+ const { container } = render(<HoverScrubVideoThumb video={video} onOpen={() => {}} autoPlay />);
109
+ await waitFor(() => expect(container.querySelector('video')).not.toBeNull());
110
+ } finally {
111
+ window.matchMedia = real;
112
+ }
113
+ });
114
+
115
+ describe('🔴 touch-and-hold previews, and the preview stays after the finger lifts', () => {
116
+ const hold = async (tile: HTMLElement, opts: { moveBy?: number } = {}) => {
117
+ fireEvent.pointerDown(tile, { pointerType: 'touch', clientX: 50, clientY: 50 });
118
+ if (opts.moveBy) fireEvent.pointerMove(tile, { pointerType: 'touch', clientX: 50, clientY: 50 + opts.moveBy });
119
+ await new Promise((resolve) => setTimeout(resolve, TOUCH_HOLD_MS + 60));
120
+ fireEvent.pointerUp(tile, { pointerType: 'touch' });
121
+ };
122
+
123
+ test('a hold starts the preview, lifting keeps it, and the lift does not open the file', async () => {
124
+ let opened = 0;
125
+ const { container, getByRole } = render(
126
+ <HoverScrubVideoThumb video={video} onOpen={() => (opened += 1)} />,
127
+ );
128
+ const tile = getByRole('button', { name: 'Open Clip one' });
129
+ await hold(tile);
130
+ expect(container.querySelector('video')).not.toBeNull();
131
+ // The click the lift produces is the hold's, not a tap.
132
+ fireEvent.click(tile);
133
+ expect(opened).toBe(0);
134
+ expect(container.querySelector('video')).not.toBeNull();
135
+ // A real tap afterwards opens as always.
136
+ fireEvent.click(tile);
137
+ expect(opened).toBe(1);
138
+ });
139
+
140
+ test('a quick tap is a tap — no preview, it opens', () => {
141
+ let opened = 0;
142
+ const { container, getByRole } = render(
143
+ <HoverScrubVideoThumb video={video} onOpen={() => (opened += 1)} />,
144
+ );
145
+ const tile = getByRole('button', { name: 'Open Clip one' });
146
+ fireEvent.pointerDown(tile, { pointerType: 'touch', clientX: 50, clientY: 50 });
147
+ fireEvent.pointerUp(tile, { pointerType: 'touch' });
148
+ fireEvent.click(tile);
149
+ expect(opened).toBe(1);
150
+ expect(container.querySelector('video')).toBeNull();
151
+ });
152
+
153
+ test('a finger that moves is a scroll, not a hold', async () => {
154
+ const { container, getByRole } = render(<HoverScrubVideoThumb video={video} onOpen={() => {}} />);
155
+ await hold(getByRole('button', { name: 'Open Clip one' }), { moveBy: 40 });
156
+ expect(container.querySelector('video')).toBeNull();
157
+ });
158
+
159
+ test('holding a second tile stops the first', async () => {
160
+ const other: PlayableVideo = { ...video, id: 'v2', name: 'Clip two' };
161
+ const { container, getByRole } = render(
162
+ <>
163
+ <HoverScrubVideoThumb video={video} onOpen={() => {}} />
164
+ <HoverScrubVideoThumb video={other} onOpen={() => {}} />
165
+ </>,
166
+ );
167
+ await hold(getByRole('button', { name: 'Open Clip one' }));
168
+ expect(container.querySelectorAll('video').length).toBe(1);
169
+ await hold(getByRole('button', { name: 'Open Clip two' }));
170
+ const playing = container.querySelectorAll('[data-held-preview="true"]');
171
+ expect(playing.length).toBe(1);
172
+ expect(playing[0]?.getAttribute('aria-label')).toBe('Open Clip two');
173
+ expect(container.querySelectorAll('video').length).toBe(1);
174
+ });
175
+ });
94
176
  });
@@ -1,5 +1,13 @@
1
1
  import { Play } from 'lucide-react';
2
- import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ type CSSProperties,
4
+ type MouseEvent,
5
+ type PointerEvent,
6
+ type ReactNode,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
3
11
  import type { PlayableVideo } from 'cursedbelt-core/media';
4
12
  import { formatTimecode } from 'cursedbelt-core/media';
5
13
  import { cn } from '../lib/cn.js';
@@ -31,7 +39,17 @@ function turnStyle(orientation: MediaOrientation | undefined): CSSProperties | u
31
39
  /** Source offsets (fractions of duration) a scrub preview steps through. */
32
40
  const SCRUB_POINTS = [0.1, 0.3, 0.5, 0.7, 0.9];
33
41
  const SCRUB_STEP_MS = 900;
42
+ /** How long a finger rests on a tile before it counts as a hold rather than a tap. */
34
43
  const TOUCH_HOLD_MS = 300;
44
+ /** How far a finger may wander during a hold before it is a scroll instead — a resting thumb is
45
+ * never perfectly still, and a hold that any jitter cancelled would read as one that never works. */
46
+ const TOUCH_HOLD_SLOP_PX = 10;
47
+
48
+ /**
49
+ * The one tile a touch-and-hold is previewing — so holding a SECOND tile stops the first, and a
50
+ * phone never accumulates a preview per tile the reader has ever held.
51
+ */
52
+ let stopHeldPreview: (() => void) | null = null;
35
53
 
36
54
  /**
37
55
  * Step a muted preview through spaced source offsets, so a preview tours the WHOLE video
@@ -74,14 +92,10 @@ export function useScrubPreview(
74
92
  /**
75
93
  * True only on devices that can actually hover with a fine pointer (a mouse).
76
94
  *
77
- * Touch phones/tablets report `(hover: none)`; there an `autoPlay` preview is suppressed so a
78
- * thumbnail never silently plays under a finger, and a preview still starts on an explicit
79
- * touch-and-hold. Starts `false` so the very first paint never autoplays, then resolves after
80
- * mount.
81
- *
82
- * 🔴 Exported alongside {@link useScrubPreview} for the same reason — the table view has to
83
- * make the SAME decision about coarse pointers as the grid, and two copies of a media query is
84
- * how one view ends up playing ninety clips on a phone.
95
+ * Touch phones/tablets report `(hover: none)`. It no longer gates `autoPlay` (see that prop's
96
+ * note); it decides what a press-and-hold MEANS — a drag to re-file the tile
97
+ * with a mouse, a preview under a finger — so the gallery's tile wrapper is draggable only where
98
+ * this is true. Starts `false`, then resolves after mount.
85
99
  */
86
100
  export function useCanHover(): boolean {
87
101
  const [canHover, setCanHover] = useState(false);
@@ -107,11 +121,17 @@ export interface HoverScrubVideoThumbProps {
107
121
  */
108
122
  previewMode?: 'scrub' | 'loop';
109
123
  /**
110
- * Preview continuously without hover — the watch page's silent side tiles.
124
+ * Preview continuously without hover — the gallery's "Preview all".
111
125
  *
112
126
  * 🔴 Gated: the preview starts only once the tile is ON SCREEN and has stayed there for
113
127
  * `PREVIEW_SETTLE_MS` (`previewGate.ts`), so a grid scrolled past fast never opens a stream
114
128
  * per row. Without an `IntersectionObserver` the tile counts as visible.
129
+ *
130
+ * 🔴 Honoured on a TOUCH screen too. It was suppressed under a coarse pointer until
131
+ * 2026-10-10, so the "Preview all" button on a phone lit up and nothing moved — owner, on an
132
+ * iPhone: *"The play icon above the player clicked but it did not make all the video
133
+ * thumbnails start playing like I thought it would."* A control the reader pressed is the
134
+ * request; the gate above is what keeps it to the tiles actually on screen.
115
135
  */
116
136
  autoPlay?: boolean;
117
137
  /** Small overlay chips (top-left slot) — e.g. a quality badge. */
@@ -153,27 +173,83 @@ export function HoverScrubVideoThumb({
153
173
  * poster that appears later arrives on the next load, which is the state the
154
174
  * derived-poster contract already describes. */
155
175
  const [posterFailed, setPosterFailed] = useState(false);
156
- const canHover = useCanHover();
157
- // On touch devices ignore `autoPlay` — an unrequested video playing under the
158
- // finger reads as a bug (and iOS shows a stack of them below the watch player).
159
- // Touch-and-hold still triggers `hovering` → a preview on demand.
176
+ /**
177
+ * A touch-and-hold's preview — and it STAYS on after the finger lifts.
178
+ *
179
+ * Owner, 2026-10-10, on an iPhone: *"holding down on a thumbnail should make it start
180
+ * previewing the video."* It used to preview only while held: under the thumb that was
181
+ * holding it, and stopped by the very lift that then fired the tile's click and opened the
182
+ * file. So now a hold LATCHES the preview, the lift after a hold is not a click, a plain tap
183
+ * still opens, and the preview ends when the tile leaves the screen or another tile is held.
184
+ */
185
+ const [held, setHeld] = useState(false);
160
186
  const boxRef = useRef<HTMLDivElement>(null);
161
- const wantsAuto = autoPlay && canHover;
162
187
  // A hover is not gated: the pointer on the tile IS the intent the settle exists to infer.
163
- const autoReady = useSettled(useInView(boxRef, wantsAuto));
164
- const previewing = autoReady || hovering;
165
- const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
188
+ const autoReady = useSettled(useInView(boxRef, autoPlay));
189
+ const heldInView = useInView(boxRef, held);
190
+ const previewing = autoReady || hovering || held;
191
+ const hold = useRef<{ timer: ReturnType<typeof setTimeout>; x: number; y: number } | null>(null);
192
+ /** Set by a hold that latched, so the click the lift produces opens nothing. */
193
+ const swallowClick = useRef(false);
166
194
  const videoRef = useRef<HTMLVideoElement>(null);
167
195
 
168
- const startHold = () => {
169
- holdTimer.current = setTimeout(() => setHovering(true), TOUCH_HOLD_MS);
196
+ const stopHeld = useRef(() => setHeld(false)).current;
197
+ const clearHold = () => {
198
+ if (hold.current) clearTimeout(hold.current.timer);
199
+ hold.current = null;
170
200
  };
171
- const cancelHold = () => {
172
- if (holdTimer.current) clearTimeout(holdTimer.current);
173
- holdTimer.current = null;
174
- setHovering(false);
201
+ const onPointerDown = (event: PointerEvent<HTMLElement>) => {
202
+ if (event.pointerType !== 'touch') return;
203
+ clearHold();
204
+ const timer = setTimeout(() => {
205
+ hold.current = null;
206
+ if (stopHeldPreview && stopHeldPreview !== stopHeld) stopHeldPreview();
207
+ stopHeldPreview = stopHeld;
208
+ swallowClick.current = true;
209
+ setHeld(true);
210
+ }, TOUCH_HOLD_MS);
211
+ hold.current = { timer, x: event.clientX, y: event.clientY };
212
+ };
213
+ const onPointerMove = (event: PointerEvent<HTMLElement>) => {
214
+ const start = hold.current;
215
+ if (!start) return;
216
+ if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > TOUCH_HOLD_SLOP_PX) clearHold();
217
+ };
218
+ const onClickCapture = (event: MouseEvent<HTMLElement>) => {
219
+ if (!swallowClick.current) return;
220
+ swallowClick.current = false;
221
+ event.preventDefault();
222
+ event.stopPropagation();
175
223
  };
176
224
 
225
+ // A held preview ends when its tile scrolls away (and a hold that never got a click — the lift
226
+ // landed off the tile — must not eat the next real tap).
227
+ useEffect(() => {
228
+ if (!held) return;
229
+ const reset = setTimeout(() => {
230
+ swallowClick.current = false;
231
+ }, 1000);
232
+ return () => clearTimeout(reset);
233
+ }, [held]);
234
+ const seenInView = useRef(false);
235
+ useEffect(() => {
236
+ if (!held) {
237
+ seenInView.current = false;
238
+ return;
239
+ }
240
+ if (heldInView) seenInView.current = true;
241
+ else if (seenInView.current) setHeld(false);
242
+ }, [held, heldInView]);
243
+ // Unmount only: a pending hold must not fire into an unmounted tile.
244
+ // biome-ignore lint/correctness/useExhaustiveDependencies: unmount-only cleanup; both read refs
245
+ useEffect(
246
+ () => () => {
247
+ if (hold.current) clearTimeout(hold.current.timer);
248
+ if (stopHeldPreview === stopHeld) stopHeldPreview = null;
249
+ },
250
+ [],
251
+ );
252
+
177
253
  useScrubPreview(videoRef, {
178
254
  active: previewing && previewMode === 'scrub',
179
255
  durationSeconds: video.durationSeconds,
@@ -335,12 +411,24 @@ export function HoverScrubVideoThumb({
335
411
  // GalleryApp "opens a video — navigation" precedent).
336
412
  type='button'
337
413
  onClick={onOpen}
414
+ onClickCapture={onClickCapture}
338
415
  onMouseEnter={() => setHovering(true)}
339
416
  onMouseLeave={() => setHovering(false)}
340
- onTouchStart={startHold}
341
- onTouchEnd={cancelHold}
342
- onTouchMove={cancelHold}
343
- className={cn(RAW_BUTTON, cardClassName, 'hover:border-accent-solid focus-visible:ring-2 focus-visible:ring-accent-solid')}
417
+ onPointerDown={onPointerDown}
418
+ onPointerMove={onPointerMove}
419
+ onPointerUp={clearHold}
420
+ onPointerCancel={clearHold}
421
+ // A hold is a preview, not the browser's own long-press: no "Save image" callout, no text
422
+ // selection, no context menu (Android's long-press) over the tile.
423
+ onContextMenu={(event) => {
424
+ if (hold.current || swallowClick.current) event.preventDefault();
425
+ }}
426
+ data-held-preview={held ? 'true' : undefined}
427
+ className={cn(
428
+ RAW_BUTTON,
429
+ cardClassName,
430
+ '[-webkit-touch-callout:none] select-none hover:border-accent-solid focus-visible:ring-2 focus-visible:ring-accent-solid',
431
+ )}
344
432
  aria-label={`Open ${video.name}`}
345
433
  >
346
434
  {media}
@@ -767,6 +767,11 @@
767
767
  gap: 6px;
768
768
  flex-wrap: wrap;
769
769
  }
770
+ /* The phone's fold toggle (`DenseGalleryDetail`'s `actionsFold`) — only the phone block below
771
+ folds the row, so everywhere else the row wraps in full and the toggle is never drawn. */
772
+ .cbgd-actions-more {
773
+ display: none;
774
+ }
770
775
  .cbgd-watch-rail {
771
776
  display: flex;
772
777
  flex-direction: column;
@@ -864,20 +869,111 @@
864
869
  .cbgd-watch-player > .cbmr-rail {
865
870
  max-width: 100%;
866
871
  }
867
- /* The titlebar's actions are ONE row that scrolls sideways, not three rows that wrap — a
868
- dozen 44px icon buttons wrapped put the related files a whole screen below the picture. */
872
+ /*
873
+ * 🔴 The titlebar's actions are ONE line, and the rest FOLD behind the toggle at its end.
874
+ *
875
+ * Three wrapped rows of 44px icons put the related files a whole screen below the picture
876
+ * (task 2424), so it became one row that scrolled sideways — with its scrollbar hidden, which
877
+ * the owner read as a bug on 2026-10-10: *"the icons over the media player go out of the right
878
+ * side of the screen."* Now the row wraps but shows only its first line (`overflow: clip`, so
879
+ * a focused button on the folded lines cannot scroll the row either), and `.cbgd-actions-more`
880
+ * beside it opens the rest in place. collections' `e2e/layout.browser.test.ts` measures it on a phone.
881
+ */
869
882
  .cbgd-watch-actions {
870
883
  margin-left: 0;
871
- flex-basis: 100%;
872
- flex-wrap: nowrap;
873
- /* guardrails-ignore no-raw-overflow: task 2424 — a one-row tool strip in the Tailwind-free `.cbgd-` shell. */
874
- overflow-x: auto;
875
- scrollbar-width: none;
884
+ flex: 1 1 calc(100% - 60px);
885
+ min-width: 0;
886
+ flex-wrap: wrap;
887
+ /* One line of 44px pills, and the 2px either side. */
888
+ max-height: 48px;
889
+ /* guardrails-ignore no-raw-overflow: task 2424 — a CLIP, not a scroller; the folded lines wait behind the toggle above. */
890
+ overflow: clip;
876
891
  padding-block: 2px;
877
892
  }
893
+ .cbgd-watch-actions.is-open {
894
+ max-height: none;
895
+ }
878
896
  .cbgd-watch-actions > * {
879
897
  flex: none;
880
898
  }
899
+ .cbgd-watch-actions > [data-cbgd-folded] {
900
+ visibility: hidden;
901
+ }
902
+ /* The way OUT leads the row, where a phone's back button always is — never on a folded line. */
903
+ .cbgd-watch-actions > .cbgd-action-lead {
904
+ order: -1;
905
+ }
906
+ .cbgd-actions-more {
907
+ display: inline-flex;
908
+ flex: none;
909
+ align-self: flex-start;
910
+ padding-block: 2px;
911
+ }
912
+ }
913
+
914
+ /*
915
+ * 🔴 A phone on its SIDE: the picture BESIDE its titlebar, as tall as the gallery's scroller.
916
+ *
917
+ * Owner, 2026-10-10, collections on an iPhone: *"landscape view has the scrollable content area
918
+ * too short."* Landscape is the width of a laptop and the height of a postcard (~340px under
919
+ * Safari's bars), and the stacked layout spent it on chrome: the resize rail, a titlebar that
920
+ * wrapped to three rows, and a share of what was left — measured, a 53×30 frame over a band of
921
+ * buttons, with the files below the fold. Beside each other, the frame takes the scroller's full
922
+ * height at 16:9, the actions wrap freely in the column it leaves, and the files are one scroll
923
+ * down in the same scroller. The resize rail goes: there is no height left to drag it into.
924
+ *
925
+ * `--cbgd-stage-beside` tells `DenseGalleryDetail`'s fit to measure only the region's padding
926
+ * (the titlebar is not under the frame here), and `--cbgd-stage-share` is how much of the
927
+ * scroller the frame may take — all of it, or a little over half when PINNED, so the pinned
928
+ * stage leaves a band of files to scroll under it.
929
+ */
930
+ @media (orientation: landscape) and (max-height: 500px) {
931
+ .cbgd-watch {
932
+ --cbgd-stage-beside: 1;
933
+ --cbgd-stage-share: 1;
934
+ --cbgd-stage-default: 760px;
935
+ }
936
+ .cbgd-watch.cbgd-watch-sticky {
937
+ --cbgd-stage-share: 0.55;
938
+ }
939
+ .cbgd-watch:not(.cbgd-watch-full) .cbgd-watch-top {
940
+ grid-template-columns: 1fr;
941
+ padding: 6px 10px;
942
+ }
943
+ .cbgd-watch:not(.cbgd-watch-full) .cbgd-watch-rail {
944
+ display: none;
945
+ }
946
+ .cbgd-watch:not(.cbgd-watch-full) .cbgd-watch-main {
947
+ display: grid;
948
+ grid-template-columns:
949
+ minmax(0, calc(var(--cbgd-stage-h, var(--cbgd-stage-fit, var(--cbgd-stage-default))) * 16 / 9))
950
+ minmax(200px, 1fr);
951
+ gap: 12px;
952
+ align-items: start;
953
+ }
954
+ .cbgd-watch:not(.cbgd-watch-full) .cbgd-watch-player-box {
955
+ height: var(--cbgd-stage-h, var(--cbgd-stage-fit, var(--cbgd-stage-default)));
956
+ aspect-ratio: auto;
957
+ max-width: 100%;
958
+ max-height: none;
959
+ }
960
+ .cbgd-watch:not(.cbgd-watch-full) .cbgd-watch-player > .cbmr-rail {
961
+ display: none;
962
+ }
963
+ .cbgd-watch:not(.cbgd-watch-full) .cbgd-watch-titlebar {
964
+ align-content: flex-start;
965
+ padding-top: 0;
966
+ }
967
+ .cbgd-watch:not(.cbgd-watch-full) .cbgd-watch-actions {
968
+ margin-left: 0;
969
+ flex: 1 1 100%;
970
+ flex-wrap: wrap;
971
+ max-height: none;
972
+ overflow: visible;
973
+ }
974
+ .cbgd-watch:not(.cbgd-watch-full) .cbgd-actions-more {
975
+ display: none;
976
+ }
881
977
  }
882
978
 
883
979
  /* ≥44px touch target on coarse pointers only — mirrors `TAP_TARGET_HEIGHT` from
@@ -36,7 +36,7 @@ import type { DragEvent } from 'react';
36
36
  import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
37
37
  import { CATEGORY_LABEL } from '../components/FileTypeIcon.js';
38
38
  import { useResizeObserver } from '../hooks/useResizeObserver.js';
39
- import { useCanHover, useScrubPreview } from '../media/HoverScrubVideoThumb.js';
39
+ import { useScrubPreview } from '../media/HoverScrubVideoThumb.js';
40
40
  import { releaseVideo, useInView, useSettled } from '../media/previewGate.js';
41
41
  import { createdLabel, type MediaCache, ThumbImg, useItemMedia } from './galleryItemMedia.js';
42
42
  import { galleryItemCategory } from './mediaGalleryModel.js';
@@ -393,8 +393,7 @@ function SortHeader({
393
393
  * tile and is written in Tailwind utilities; this module is deliberately Tailwind-free (see
394
394
  * `mediaGallery.css`'s opening paragraph — binary-server's inspector has no Tailwind build, so
395
395
  * a Tailwind component here renders unstyled in half the fleet). What the two DO share is the
396
- * arithmetic: `useScrubPreview` steps the same five offsets and `useCanHover` makes the same
397
- * coarse-pointer decision, both imported rather than re-derived.
396
+ * arithmetic: `useScrubPreview` steps the same five offsets, imported rather than re-derived.
398
397
  *
399
398
  * 🔴 It replaces the poster rather than sitting on top of it, because at 56×40 there is no room
400
399
  * for a stack — and it attaches the element only while previewing, so a table of four hundred
@@ -481,14 +480,13 @@ function GalleryTableRow({
481
480
  * to preview it."* Before this, neither half was true in table view.
482
481
  */
483
482
  const [hovering, setHovering] = useState(false);
484
- const canHover = useCanHover();
485
483
  const previewSrc = category === 'video' ? (media.streamUrl ?? media.url) : undefined;
486
- // `autoPreview` is suppressed on a coarse pointer exactly as the grid tile suppresses it, so
487
- // a phone never opens forty video connections under a finger. A deliberate hover still does.
488
- // 🔴 And it waits until the row is on screen and has SETTLED there (`previewGate.ts`), the
489
- // same gate the grid tile uses — a table scrolled past fast must not open a stream per row.
484
+ // `autoPreview` runs on a touch screen too, exactly as the grid tile's does (2026-10-10: the
485
+ // phone's "Preview all" lit up and nothing moved — `HoverScrubVideoThumb`'s `autoPlay` note).
486
+ // 🔴 It waits until the row is on screen and has SETTLED there (`previewGate.ts`), the same
487
+ // gate the grid tile uses — a table scrolled past fast must not open a stream per row.
490
488
  const thumbRef = useRef<HTMLButtonElement | null>(null);
491
- const autoReady = useSettled(useInView(thumbRef, previewSrc != null && autoPreview && canHover));
489
+ const autoReady = useSettled(useInView(thumbRef, previewSrc != null && autoPreview));
492
490
  const previewing = previewSrc != null && (autoReady || hovering);
493
491
 
494
492
  return (
@@ -86,7 +86,7 @@ import {
86
86
  type SourceFault,
87
87
  sourceFaultMessage,
88
88
  } from '../media/sourceFault.js';
89
- import { HoverScrubVideoThumb } from '../media/HoverScrubVideoThumb.js';
89
+ import { HoverScrubVideoThumb, useCanHover } from '../media/HoverScrubVideoThumb.js';
90
90
  import { isPlayPauseSpace } from '../media/playerSpaceKey.js';
91
91
  import {
92
92
  LIGHT_RANGE,
@@ -582,6 +582,15 @@ function TileShell({
582
582
  }) {
583
583
  const selected = selection?.selected === true;
584
584
  const quiet = selection !== undefined && !selection.armed;
585
+ /*
586
+ * 🔴 Draggable only where a pointer can HOVER. On iOS a press-and-hold on a draggable
587
+ * element lifts it for drag-and-drop, and a press-and-hold on a tile is the video PREVIEW
588
+ * there (owner, 2026-10-10: *"holding down on a thumbnail should make it start previewing the
589
+ * video"* — `HoverScrubVideoThumb`'s `held`). A phone has nowhere to drop a tile anyway: the
590
+ * folder tree is a closed drawer while the grid is on screen.
591
+ */
592
+ const canHover = useCanHover();
593
+ const dragHere = canHover ? drag : undefined;
585
594
  return (
586
595
  <div
587
596
  className={`cbmg-selectable${selected ? ' is-selected' : ''}${stageMark ? ' is-playing' : ''}${quiet ? ' cbmg-selectable--quiet' : ''}`}
@@ -595,11 +604,11 @@ function TileShell({
595
604
  // a drag, some treat the pointer-down as a press and never fire dragstart at all.
596
605
  // Dragging the wrapper also means the drag image is the whole tile, which is what a
597
606
  // person is aiming at.
598
- {...(drag
607
+ {...(dragHere
599
608
  ? {
600
609
  draggable: true,
601
- onDragStart: (event: DragEvent<HTMLDivElement>) => drag.onDragStart(event),
602
- onDragEnd: () => drag.onDragEnd?.(),
610
+ onDragStart: (event: DragEvent<HTMLDivElement>) => dragHere.onDragStart(event),
611
+ onDragEnd: () => dragHere.onDragEnd?.(),
603
612
  }
604
613
  : {})}
605
614
  >
@@ -796,8 +805,8 @@ function RawGalleryTile({
796
805
  video={video}
797
806
  onOpen={onOpen}
798
807
  previewMode='scrub'
799
- // The whole grid previewing at once, on request. The tile suppresses it on
800
- // coarse-pointer devices by itself, so a phone never plays ninety clips under a thumb.
808
+ // The whole grid previewing at once, on request — on a phone too; the tile's gate keeps
809
+ // it to the tiles on screen.
801
810
  autoPlay={autoPreview ?? false}
802
811
  // A stored turn turns the TILE too — otherwise the grid is a wall of sideways films
803
812
  // whose detail view is upright, which reads as the fix not having worked.
@@ -3224,7 +3233,9 @@ export function MediaGallery({
3224
3233
  // guardrails-ignore no-raw-action-button: back-to-grid navigation, styled by
3225
3234
  // the dense gallery's own plain CSS (plugin-media-gallery precedent).
3226
3235
  type='button'
3227
- className={cn(RAW_BUTTON, 'cbgd-order-btn is-icon')}
3236
+ // `cbgd-action-lead`: on a phone it leads the row (`denseGallery.css`), so the way
3237
+ // out is never on a folded line.
3238
+ className={cn(RAW_BUTTON, 'cbgd-order-btn is-icon cbgd-action-lead')}
3228
3239
  aria-label='Back to grid'
3229
3240
  onClick={() => select(null)}
3230
3241
  >