cursedbelt 3.0.2 โ†’ 4.0.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 (250) hide show
  1. package/dist/react/components/ListToolbar.d.ts +94 -0
  2. package/dist/react/components/ListToolbar.d.ts.map +1 -0
  3. package/dist/react/components/ListToolbar.js +66 -0
  4. package/dist/react/components/ListToolbar.js.map +1 -0
  5. package/dist/react/file-tree/FileTree.d.ts +282 -3
  6. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  7. package/dist/react/file-tree/FileTree.js +799 -131
  8. package/dist/react/file-tree/FileTree.js.map +1 -1
  9. package/dist/react/file-tree/fileTree.css +151 -0
  10. package/dist/react/file-tree/fileTreeSelection.d.ts +187 -0
  11. package/dist/react/file-tree/fileTreeSelection.d.ts.map +1 -0
  12. package/dist/react/file-tree/fileTreeSelection.js +264 -0
  13. package/dist/react/file-tree/fileTreeSelection.js.map +1 -0
  14. package/dist/react/file-tree/index.d.ts +1 -0
  15. package/dist/react/file-tree/index.d.ts.map +1 -1
  16. package/dist/react/file-tree/index.js +8 -0
  17. package/dist/react/file-tree/index.js.map +1 -1
  18. package/dist/react/index.d.ts +1 -0
  19. package/dist/react/index.d.ts.map +1 -1
  20. package/dist/react/index.js +1 -0
  21. package/dist/react/index.js.map +1 -1
  22. package/dist/react/lib/form.d.ts +3 -0
  23. package/dist/react/lib/form.d.ts.map +1 -1
  24. package/dist/react/lib/form.js +43 -5
  25. package/dist/react/lib/form.js.map +1 -1
  26. package/dist/react/media/DenseGalleryShell.d.ts +19 -3
  27. package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
  28. package/dist/react/media/DenseGalleryShell.js +55 -15
  29. package/dist/react/media/DenseGalleryShell.js.map +1 -1
  30. package/dist/react/media/FocusViewer.d.ts.map +1 -1
  31. package/dist/react/media/FocusViewer.js +14 -14
  32. package/dist/react/media/FocusViewer.js.map +1 -1
  33. package/dist/react/media/FrameGrabber.d.ts +23 -0
  34. package/dist/react/media/FrameGrabber.d.ts.map +1 -1
  35. package/dist/react/media/FrameGrabber.js +67 -1
  36. package/dist/react/media/FrameGrabber.js.map +1 -1
  37. package/dist/react/media/MediaResize.d.ts +51 -19
  38. package/dist/react/media/MediaResize.d.ts.map +1 -1
  39. package/dist/react/media/MediaResize.js +20 -13
  40. package/dist/react/media/MediaResize.js.map +1 -1
  41. package/dist/react/media/VideoChapterEditor.d.ts +67 -0
  42. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -0
  43. package/dist/react/media/VideoChapterEditor.js +148 -0
  44. package/dist/react/media/VideoChapterEditor.js.map +1 -0
  45. package/dist/react/media/VideoPlayer.d.ts +46 -4
  46. package/dist/react/media/VideoPlayer.d.ts.map +1 -1
  47. package/dist/react/media/VideoPlayer.js +124 -11
  48. package/dist/react/media/VideoPlayer.js.map +1 -1
  49. package/dist/react/media/VideoTurner.d.ts +15 -2
  50. package/dist/react/media/VideoTurner.d.ts.map +1 -1
  51. package/dist/react/media/VideoTurner.js +29 -2
  52. package/dist/react/media/VideoTurner.js.map +1 -1
  53. package/dist/react/media/denseGallery.css +104 -46
  54. package/dist/react/media/hlsSource.d.ts +57 -0
  55. package/dist/react/media/hlsSource.d.ts.map +1 -1
  56. package/dist/react/media/hlsSource.js +177 -1
  57. package/dist/react/media/hlsSource.js.map +1 -1
  58. package/dist/react/media/index.d.ts +2 -0
  59. package/dist/react/media/index.d.ts.map +1 -1
  60. package/dist/react/media/index.js +6 -0
  61. package/dist/react/media/index.js.map +1 -1
  62. package/dist/react/media/mediaResize.css +127 -54
  63. package/dist/react/media/pictureEditor.css +52 -6
  64. package/dist/react/media/videoChapterEditor.css +203 -0
  65. package/dist/react/media/videoChapters.d.ts +117 -0
  66. package/dist/react/media/videoChapters.d.ts.map +1 -0
  67. package/dist/react/media/videoChapters.js +95 -0
  68. package/dist/react/media/videoChapters.js.map +1 -0
  69. package/dist/react/media-gallery/GalleryTable.d.ts +52 -1
  70. package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
  71. package/dist/react/media-gallery/GalleryTable.js +40 -9
  72. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  73. package/dist/react/media-gallery/MediaGallery.d.ts +107 -4
  74. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  75. package/dist/react/media-gallery/MediaGallery.js +484 -104
  76. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  77. package/dist/react/media-gallery/MediaMetaEditor.d.ts +48 -0
  78. package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -0
  79. package/dist/react/media-gallery/MediaMetaEditor.js +66 -0
  80. package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -0
  81. package/dist/react/media-gallery/index.d.ts +2 -0
  82. package/dist/react/media-gallery/index.d.ts.map +1 -1
  83. package/dist/react/media-gallery/index.js +5 -0
  84. package/dist/react/media-gallery/index.js.map +1 -1
  85. package/dist/react/media-gallery/mediaGallery.css +180 -4
  86. package/dist/react/media-gallery/mediaMeta.d.ts +62 -0
  87. package/dist/react/media-gallery/mediaMeta.d.ts.map +1 -0
  88. package/dist/react/media-gallery/mediaMeta.js +56 -0
  89. package/dist/react/media-gallery/mediaMeta.js.map +1 -0
  90. package/dist/react/media-gallery/playbackPreferences.d.ts +77 -0
  91. package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
  92. package/dist/react/media-gallery/playbackPreferences.js +42 -0
  93. package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
  94. package/dist/react/media-gallery/types.d.ts +34 -0
  95. package/dist/react/media-gallery/types.d.ts.map +1 -1
  96. package/dist/scripts/guardrailsEnforce.d.ts +29 -0
  97. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -0
  98. package/dist/styles-areas/activity-bar.css +30 -0
  99. package/dist/styles-areas/analytics.css +672 -0
  100. package/dist/styles-areas/auth.css +154 -0
  101. package/dist/styles-areas/calendar.css +416 -0
  102. package/dist/styles-areas/chart-table.css +41 -0
  103. package/dist/styles-areas/charts.css +89 -0
  104. package/dist/styles-areas/chat.css +364 -0
  105. package/dist/styles-areas/clipboard.css +26 -0
  106. package/dist/styles-areas/code-editor.css +130 -0
  107. package/dist/styles-areas/companion-link.css +70 -0
  108. package/dist/styles-areas/core.css +2503 -0
  109. package/dist/styles-areas/dashboard-grid.css +84 -0
  110. package/dist/styles-areas/data-table.css +657 -0
  111. package/dist/styles-areas/deep-link.css +26 -0
  112. package/dist/styles-areas/diff-viewer.css +30 -0
  113. package/dist/styles-areas/disk-usage.css +262 -0
  114. package/dist/styles-areas/emoji.css +154 -0
  115. package/dist/styles-areas/fields.css +582 -0
  116. package/dist/styles-areas/file-tree.css +33 -0
  117. package/dist/styles-areas/filter-rail.css +235 -0
  118. package/dist/styles-areas/folder-tree.css +219 -0
  119. package/dist/styles-areas/keep-awake.css +26 -0
  120. package/dist/styles-areas/layout-engine.css +239 -0
  121. package/dist/styles-areas/markdown.css +87 -0
  122. package/dist/styles-areas/master-detail.css +239 -0
  123. package/dist/styles-areas/master-lock.css +26 -0
  124. package/dist/styles-areas/media-gallery.css +351 -0
  125. package/dist/styles-areas/media.css +658 -0
  126. package/dist/styles-areas/nav.css +488 -0
  127. package/dist/styles-areas/notifications.css +245 -0
  128. package/dist/styles-areas/overlays.css +133 -0
  129. package/dist/styles-areas/palette.css +140 -0
  130. package/dist/styles-areas/pdf-viewer.css +47 -0
  131. package/dist/styles-areas/problem-detail.css +97 -0
  132. package/dist/styles-areas/rich-text.css +167 -0
  133. package/dist/styles-areas/sharing.css +153 -0
  134. package/dist/styles-areas/spreadsheet.css +37 -0
  135. package/dist/styles-areas/stats.css +281 -0
  136. package/dist/styles-areas/test-report.css +133 -0
  137. package/dist/styles-areas/virtual.css +30 -0
  138. package/dist/styles-areas/wizard.css +381 -0
  139. package/dist/styles-areas/workbench.css +543 -0
  140. package/dist/styles-areas/workbook-viewer.css +44 -0
  141. package/dist/styles-static.css +34 -23
  142. package/dist/styles.css +34 -23
  143. package/package.json +111 -31
  144. package/scripts/checkDistExports.ts +67 -0
  145. package/scripts/cssRules.ts +125 -0
  146. package/scripts/fixtureAppCss.ts +344 -0
  147. package/scripts/generateAreaStyles.ts +253 -0
  148. package/scripts/guardrailsEnforce.spec.ts +62 -0
  149. package/scripts/guardrailsEnforce.ts +67 -3
  150. package/scripts/styleAreas.ts +585 -0
  151. package/scripts/verify.ts +5 -0
  152. package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
  153. package/src/docsMatchTheSplit.spec.ts +98 -0
  154. package/src/fixtureAppCss.spec.ts +177 -0
  155. package/src/namedSubpathsResolve.spec.ts +35 -0
  156. package/src/publishShape.spec.ts +67 -0
  157. package/src/react/components/ListToolbar.spec.tsx +172 -0
  158. package/src/react/components/ListToolbar.tsx +151 -0
  159. package/src/react/file-tree/FileTree.spec.tsx +1009 -0
  160. package/src/react/file-tree/FileTree.tsx +1459 -309
  161. package/src/react/file-tree/fileTree.css +151 -0
  162. package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
  163. package/src/react/file-tree/fileTreeSelection.ts +321 -0
  164. package/src/react/file-tree/index.ts +21 -0
  165. package/src/react/index.ts +1 -0
  166. package/src/react/lib/form.ts +51 -8
  167. package/src/react/media/DenseGalleryShell.tsx +108 -36
  168. package/src/react/media/FocusViewer.tsx +15 -8
  169. package/src/react/media/FrameGrabber.spec.tsx +175 -0
  170. package/src/react/media/FrameGrabber.tsx +67 -0
  171. package/src/react/media/MediaResize.spec.tsx +84 -14
  172. package/src/react/media/MediaResize.tsx +51 -19
  173. package/src/react/media/VideoChapterEditor.tsx +428 -0
  174. package/src/react/media/VideoPlayer.spec.tsx +111 -1
  175. package/src/react/media/VideoPlayer.tsx +158 -11
  176. package/src/react/media/VideoTurner.spec.tsx +67 -0
  177. package/src/react/media/VideoTurner.tsx +70 -15
  178. package/src/react/media/denseGallery.css +104 -46
  179. package/src/react/media/hlsSource.recovery.spec.ts +213 -0
  180. package/src/react/media/hlsSource.spec.ts +8 -0
  181. package/src/react/media/hlsSource.ts +189 -1
  182. package/src/react/media/index.ts +14 -0
  183. package/src/react/media/mediaResize.css +127 -54
  184. package/src/react/media/pictureEditor.css +52 -6
  185. package/src/react/media/videoChapterEditor.css +203 -0
  186. package/src/react/media/videoChapters.spec.ts +176 -0
  187. package/src/react/media/videoChapters.ts +181 -0
  188. package/src/react/media-gallery/GalleryTable.tsx +93 -3
  189. package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
  190. package/src/react/media-gallery/MediaGallery.tsx +813 -95
  191. package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
  192. package/src/react/media-gallery/galleryTable.spec.ts +41 -1
  193. package/src/react/media-gallery/index.ts +7 -0
  194. package/src/react/media-gallery/mediaGallery.css +180 -4
  195. package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
  196. package/src/react/media-gallery/mediaMeta.ts +101 -0
  197. package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
  198. package/src/react/media-gallery/playbackPreferences.ts +100 -0
  199. package/src/react/media-gallery/types.ts +34 -0
  200. package/src/shippedFilesAreTracked.spec.ts +69 -0
  201. package/src/styles-areas/activity-bar.css +30 -0
  202. package/src/styles-areas/analytics.css +672 -0
  203. package/src/styles-areas/auth.css +154 -0
  204. package/src/styles-areas/calendar.css +416 -0
  205. package/src/styles-areas/chart-table.css +41 -0
  206. package/src/styles-areas/charts.css +89 -0
  207. package/src/styles-areas/chat.css +364 -0
  208. package/src/styles-areas/clipboard.css +26 -0
  209. package/src/styles-areas/code-editor.css +130 -0
  210. package/src/styles-areas/companion-link.css +70 -0
  211. package/src/styles-areas/core.css +2503 -0
  212. package/src/styles-areas/dashboard-grid.css +84 -0
  213. package/src/styles-areas/data-table.css +657 -0
  214. package/src/styles-areas/deep-link.css +26 -0
  215. package/src/styles-areas/diff-viewer.css +30 -0
  216. package/src/styles-areas/disk-usage.css +262 -0
  217. package/src/styles-areas/emoji.css +154 -0
  218. package/src/styles-areas/fields.css +582 -0
  219. package/src/styles-areas/file-tree.css +33 -0
  220. package/src/styles-areas/filter-rail.css +235 -0
  221. package/src/styles-areas/folder-tree.css +219 -0
  222. package/src/styles-areas/keep-awake.css +26 -0
  223. package/src/styles-areas/layout-engine.css +239 -0
  224. package/src/styles-areas/markdown.css +87 -0
  225. package/src/styles-areas/master-detail.css +239 -0
  226. package/src/styles-areas/master-lock.css +26 -0
  227. package/src/styles-areas/media-gallery.css +351 -0
  228. package/src/styles-areas/media.css +658 -0
  229. package/src/styles-areas/nav.css +488 -0
  230. package/src/styles-areas/notifications.css +245 -0
  231. package/src/styles-areas/overlays.css +133 -0
  232. package/src/styles-areas/palette.css +140 -0
  233. package/src/styles-areas/pdf-viewer.css +47 -0
  234. package/src/styles-areas/problem-detail.css +97 -0
  235. package/src/styles-areas/rich-text.css +167 -0
  236. package/src/styles-areas/sharing.css +153 -0
  237. package/src/styles-areas/spreadsheet.css +37 -0
  238. package/src/styles-areas/stats.css +281 -0
  239. package/src/styles-areas/test-report.css +133 -0
  240. package/src/styles-areas/virtual.css +30 -0
  241. package/src/styles-areas/wizard.css +381 -0
  242. package/src/styles-areas/workbench.css +543 -0
  243. package/src/styles-areas/workbook-viewer.css +44 -0
  244. package/src/styles-static.css +34 -23
  245. package/src/styles.css +34 -23
  246. package/src/stylesAreas.spec.ts +247 -0
  247. package/src/stylesUtilitiesMatches.spec.ts +6 -77
  248. package/src/testFilesRunInParallel.spec.ts +79 -0
  249. package/src/typecheckCachesAreSeparate.spec.ts +128 -0
  250. package/src/verifyGraph.spec.ts +6 -0
@@ -1,5 +1,38 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import './mediaGallery.css';
3
+ /*
4
+ * โ”€โ”€ ๐Ÿ”ด The toolbar's icons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
5
+ *
6
+ * Owner, `ideas/collections-09-14.md`, 2026-09-14: *"In the cb media galleries like
7
+ * collections uses I want to make the toolbar look more professional. Make it have icons
8
+ * only, instead of words in the strip of tools where have things like pin, stop at the end,
9
+ * and turn video. Hovering over the tooltip can show the words."*
10
+ *
11
+ * `lucide-react`, not the ๐Ÿ“Œ โŸณ ๐Ÿ–ผ โœ‚ these pills carried until 2026-09-16. An emoji is drawn
12
+ * by whichever font the operating system picked, at whatever optical size and weight that
13
+ * font chose, in colour on some platforms and monochrome on others โ€” so the one thing a
14
+ * toolbar of them cannot look is designed, which is the opposite of what was asked for. The
15
+ * package is already a dependency (`DropdownMenu`, `PlayerStrip`, `icons.tsx`), so this adds
16
+ * no weight and no second icon vocabulary.
17
+ *
18
+ * ๐Ÿ”ด Three rules every conversion below keeps, because a visual change is exactly where each
19
+ * is silently dropped:
20
+ *
21
+ * 1. **The accessible name survives.** Every icon-only pill carries an `aria-label`, and
22
+ * the glyph itself is `aria-hidden`. The words did double duty as the name.
23
+ * 2. **The 44px tap floor survives.** The hit target is the pill, not the glyph โ€” see
24
+ * `.cbgd-order-btn.is-icon` in `denseGallery.css`, measured by
25
+ * `e2e/touch-targets.spec.ts` at a real coarse-pointer viewport.
26
+ * 3. **A word that was a VALUE keeps a home.** `๐Ÿ“Œ Pin` vs `๐Ÿ“Œ Pinned` and the five
27
+ * end-behaviour labels were not names, they were readouts; an icon identical in both
28
+ * states deletes information. They move into the pressed state, the accessible name and
29
+ * the tooltip together โ€” `MediaGallery.spec.tsx` asserts each one.
30
+ *
31
+ * What stays a word: the dwell READING (`5s`), the selection count, the sort pills, and the
32
+ * inline rename form's Save/Cancel. Those are values you read or a form's action row
33
+ * (`DESIGN.md` ยง6), not tools in the strip.
34
+ */
35
+ import { ArrowLeft, ArrowRightToLine, Camera, Crop, Download, Image as ImageIcon, LayoutGrid, ListVideo, Pencil, Pin, Play, Repeat, Repeat1, RotateCw, Rows3, Scissors, Shuffle, SkipForward, SquareCheck, Sun, Tags, Timer, X, } from 'lucide-react';
3
36
  import { formatBytes } from 'cwip/format';
4
37
  import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react';
5
38
  import { CATEGORY_LABEL, FileTypeIcon } from '../components/FileTypeIcon';
@@ -11,8 +44,8 @@ import { HoverScrubVideoThumb } from '../media/HoverScrubVideoThumb';
11
44
  import { isPlayPauseSpace } from '../media/playerSpaceKey';
12
45
  import { LIGHT_RANGE, lightFilter, NO_EDIT, NO_LIGHT, isLightAdjusted, previewTransform, swapsAxes, } from '../media/pictureEdit';
13
46
  import { GALLERY_ORDER_OPTIONS, filterGalleryItems, galleryItemCategory, galleryKindOptions, galleryTags, isMarkdownFile, playsWithSpace, snapsToStage, sortGalleryItems, } from './mediaGalleryModel';
14
- import { IMAGE_DWELL_OPTIONS, PLAYBACK_END_LABELS, nextPlaybackEnd, readImageDwell, readPinStage, readPlaybackEnd, readThumbAutoplay, writeImageDwell, writePinStage, writePlaybackEnd, writeThumbAutoplay, readViewMode, writeViewMode, } from './playbackPreferences';
15
- import { GalleryTable } from './GalleryTable';
47
+ import { IMAGE_DWELL_OPTIONS, PLAYBACK_END_LABELS, nextPlaybackEnd, readImageDwell, readPinStage, readPlaybackEnd, readSessionVolume, readThumbAutoplay, STAGE_VOLUME_KEY, writeImageDwell, writeSessionVolume, writePinStage, writePlaybackEnd, writeThumbAutoplay, readViewMode, writeViewMode, } from './playbackPreferences';
48
+ import { GalleryTable, durationLabel } from './GalleryTable';
16
49
  import { nextInRun, runAdvances, runRepeatsOne, stepId } from './playbackRun';
17
50
  import { createdLabel, ThumbImg, useItemMedia } from './galleryItemMedia';
18
51
  import { Tooltip } from '../components/Tooltip';
@@ -36,11 +69,77 @@ const LazyFrameGrabber = React.lazy(() => import('../media/FrameGrabber').then((
36
69
  // The same four gestures for FOOTAGE โ€” a separate dialog from the picture editor because a
37
70
  // video's turn can only ever be stored, never baked. See `VideoTurner`'s header.
38
71
  const LazyVideoTurner = React.lazy(() => import('../media/VideoTurner').then((m) => ({ default: m.VideoTurner })));
72
+ /**
73
+ * The turn dialog, over the item's RESOLVED media โ€” never over the raw row.
74
+ *
75
+ * โ”€โ”€ ๐Ÿ”ด The black box, and where it came from โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
76
+ * Owner, `ideas/collections-09-14.md`: *"When I click the turn the video button I see a black
77
+ * screen. It should show me the video so I can see how the turn impacts it."*
78
+ *
79
+ * The dialog was mounted as `src={item.streamUrl ?? item.url ?? ''}` โ€” the fields ON THE ROW.
80
+ * But a gallery item is allowed to carry no URL at all: {@link MediaGalleryItem.resolveMedia}
81
+ * is the documented path for signed URLs and for bytes that become an object URL, and every
82
+ * other surface in this file reads an item through {@link useItemMedia}, which merges that
83
+ * resolver's answer into the row's own fields and caches it per id. So on any app that signs
84
+ * its URLs โ€” which is the app that reported this โ€” both fields were `undefined`, the dialog
85
+ * mounted a `<video>` with no `src` and no `poster`, and an element with nothing to load never
86
+ * even fires `error`: it paints black, forever, silently. Reproduced in
87
+ * `MediaGallery.spec.tsx`'s "the dialog is handed the RESOLVED source" before this existed.
88
+ *
89
+ * ๐Ÿ”ด It shares the gallery's own cache rather than calling the resolver again. A second cache
90
+ * would re-sign the URL every time the dialog opened, which is the cost `galleryItemMedia.tsx`
91
+ * was extracted to stop.
92
+ */
93
+ function TurnVideoDialog({ item, cache, onClose, onSave, }) {
94
+ const { media, resolving } = useItemMedia(item, cache);
95
+ // `streamUrl ?? url`, the same preference the stage and the frame grabber make: a plain `url`
96
+ // is not always a streamable source, and a turner given the wrong one shows a black box.
97
+ const src = media.streamUrl ?? media.url ?? '';
98
+ const poster = media.posterUrl ?? media.thumbnailUrl;
99
+ return (_jsx(LazyVideoTurner, { src: src, ...(poster ? { posterUrl: poster } : {}),
100
+ // So an unresolved source reads as "still finding it" for the moment it takes, rather
101
+ // than flashing the "cannot show it" reason at somebody whose film is on its way.
102
+ resolving: resolving, name: item.name, orientation: item.orientation, onClose: onClose, onSave: onSave }));
103
+ }
39
104
  // Marking sections of a clip to cut out. Lazy for the usual reason and one extra: an app that
40
105
  // has no pipeline to execute a cut list never passes `onTrimVideo`, so it never loads a byte
41
106
  // of this โ€” which is what makes "enabled for collections only, for now" a fact about the
42
107
  // bundle rather than a feature flag somebody has to remember to check.
43
108
  const LazyVideoCutEditor = React.lazy(() => import('../media/VideoCutEditor').then((m) => ({ default: m.VideoCutEditor })));
109
+ // Naming the parts of a clip. The same bargain as the cut editor one line up: an app that has
110
+ // nowhere to PUT a chapter list never passes `onSaveChapters`, so it never loads a byte of
111
+ // this dialog โ€” the capability and the bundle cost turn on together, with no flag between them.
112
+ const LazyVideoChapterEditor = React.lazy(() => import('../media/VideoChapterEditor').then((m) => ({ default: m.VideoChapterEditor })));
113
+ // Tagging and describing, on one file or on a ticked selection. Same bargain again: an app
114
+ // with nowhere to PUT a tag never passes `onSaveMeta`, so it never loads a byte of this
115
+ // dialog โ€” and the gallery keeps reading whatever tags and descriptions its items already
116
+ // carry either way, because reading was never the capability that was missing.
117
+ const LazyMediaMetaEditor = React.lazy(() => import('./MediaMetaEditor').then((m) => ({ default: m.MediaMetaEditor })));
118
+ /**
119
+ * One glyph per end-behaviour โ€” the five values the cycling pill used to spell out.
120
+ *
121
+ * ๐Ÿ”ด Five DISTINCT icons, not one icon plus a lit state. The button cycles through settings
122
+ * rather than toggling one, so `Stop at the end` and `Shuffle` have to be told apart at a
123
+ * glance or the only way to read the current value is to hover โ€” and the owner's own words
124
+ * for this control ("stop at the end") are a value he was reading off the strip, not a name
125
+ * for the button. `aria-label` still spells the value out in full; this is the sighted half
126
+ * of the same fact.
127
+ *
128
+ * `stop` is `ArrowRightToLine` because it is exactly the `โ†’|` the pill drew before, and it
129
+ * is the one setting that renders UNLIT (`is-active` is withheld for `stop`) โ€” so "nothing
130
+ * happens at the end" reads as an off state, which is what it is.
131
+ *
132
+ * Deliberately NOT in `playbackPreferences.ts` beside the labels: that module is pure data
133
+ * and storage, imported by apps that drive their own player, and adding a React component to
134
+ * it would pull `lucide-react` into every one of them for a table they never render.
135
+ */
136
+ const PLAYBACK_END_ICONS = {
137
+ stop: ArrowRightToLine,
138
+ repeat: Repeat1,
139
+ next: SkipForward,
140
+ all: Repeat,
141
+ shuffle: Shuffle,
142
+ };
44
143
  /**
45
144
  * A stored orientation, as a CSS transform โ€” the same `previewTransform` the editor previews
46
145
  * with, so a saved turn and an unsaved one can never render differently.
@@ -59,12 +158,42 @@ function orientationStyle(item) {
59
158
  function isTurned(item) {
60
159
  return item.orientation ? swapsAxes(item.orientation.turns) : false;
61
160
  }
62
- function GalleryMeta({ item }) {
63
- const line = item.tags && item.tags.length > 0
64
- ? item.tags.slice(0, 3).join(' ยท ')
65
- : item.sizeBytes != null
66
- ? formatBytes(item.sizeBytes)
67
- : null;
161
+ /**
162
+ * The one meta line under a tile's picture โ€” its tags, its measurement, or both.
163
+ *
164
+ * โ”€โ”€ ๐Ÿ”ด What `measure` turns on, and why it is not the default โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
165
+ * Owner, `ideas/collections-09-14.md`: *"in the grid view show the video length instead of
166
+ * the size"*, and `ideas/binary-server-09-15.md`: *"show the video length on thumbnail
167
+ * components โ€ฆ show the file size on thumbnail components (maybe controlled through param on
168
+ * media gallery in cb)"*. He guessed the right home for it: ONE param on this gallery, so an
169
+ * app that asks for nothing renders the line it already had, byte for byte.
170
+ *
171
+ * With it on, the measurement a tile leads with is the one that means something for its kind
172
+ * โ€” a film and a track show their RUNNING TIME, everything else its size. A clip whose
173
+ * duration nobody measured falls back to its size rather than to a blank caption, and a file
174
+ * with neither shows `โ€”`, the same mark the table's columns use for an absent measurement.
175
+ *
176
+ * ๐Ÿ”ด Tags and the measurement share this line rather than taking one each. The caption block
177
+ * is a fixed 65px โ€” title, this line, the date โ€” pinned to `CBGD_META_HEIGHT` because a
178
+ * caption whose height varied per tile is what made rows lap one another across the fleet
179
+ * (see `denseGallery.css`). A fourth line is not available at any price, so a tagged video
180
+ * reads `holiday ยท beach ยท 3:07`.
181
+ *
182
+ * Exported for its own spec: the branch table here is wider than the tile is, and a rendered
183
+ * assertion can only reach the combinations a fixture happens to contain.
184
+ */
185
+ export function galleryTileLine(item, options = {}) {
186
+ const tags = item.tags && item.tags.length > 0 ? item.tags.slice(0, 3).join(' ยท ') : null;
187
+ const size = item.sizeBytes != null ? formatBytes(item.sizeBytes) : null;
188
+ if (!options.measure)
189
+ return tags ?? size;
190
+ const category = galleryItemCategory(item);
191
+ const timeBased = category === 'video' || category === 'audio';
192
+ const measure = timeBased ? (durationLabel(item.durationSeconds) ?? size ?? 'โ€”') : size;
193
+ return tags && measure ? `${tags} ยท ${measure}` : (tags ?? measure);
194
+ }
195
+ function GalleryMeta({ item, measure }) {
196
+ const line = galleryTileLine(item, { measure: measure === true });
68
197
  // The date is its OWN line rather than appended to the one above, because that line is
69
198
  // already whichever of tags/size exists โ€” folding a third fact into it would push the tags
70
199
  // out of a tile caption that is two lines tall by design.
@@ -86,16 +215,16 @@ function extLabel(item) {
86
215
  * the tile's own click meaning "open" โ€” ticking the box must never navigate, which is what
87
216
  * `stopPropagation` in {@link SelectBox} buys.
88
217
  */
89
- function TileShell({ name, selection, drag, children, }) {
218
+ function TileShell({ name, selection, drag, stageMark, children, }) {
90
219
  const selected = selection?.selected === true;
91
220
  const quiet = selection !== undefined && !selection.armed;
92
- return (_jsxs("div", { className: `cbmg-selectable${selected ? ' is-selected' : ''}${quiet ? ' cbmg-selectable--quiet' : ''}`, ...(drag
221
+ return (_jsxs("div", { className: `cbmg-selectable${selected ? ' is-selected' : ''}${stageMark ? ' is-playing' : ''}${quiet ? ' cbmg-selectable--quiet' : ''}`, "data-playing": stageMark ? 'true' : undefined, "aria-current": stageMark ? 'true' : undefined, ...(drag
93
222
  ? {
94
223
  draggable: true,
95
224
  onDragStart: (event) => drag.onDragStart(event),
96
225
  onDragEnd: () => drag.onDragEnd?.(),
97
226
  }
98
- : {}), children: [children, selection ? (_jsx(SelectBox, { name: name, selected: selected, onToggle: selection.onToggle })) : null] }));
227
+ : {}), children: [children, stageMark ? (_jsxs("span", { className: 'cbmg-playing-chip', children: [_jsx("span", { "aria-hidden": 'true', children: "\u25B6" }), " ", stageMark] })) : null, selection ? (_jsx(SelectBox, { name: name, selected: selected, onToggle: selection.onToggle })) : null] }));
99
228
  }
100
229
  function SelectBox({ name, selected, onToggle, }) {
101
230
  return (_jsx("span", { className: 'cbmg-select-box', children: _jsx("input", {
@@ -112,7 +241,7 @@ function SelectBox({ name, selected, onToggle, }) {
112
241
  }, "aria-label": `Select ${name} for bulk actions (shift-click for a range)` }) }));
113
242
  }
114
243
  /** One tile: hover-scrub for videos, a still image for photos, a type-icon card otherwise. */
115
- function GalleryTile({ item, cache, onOpen, selection, drag, autoPreview, }) {
244
+ function GalleryTile({ item, cache, onOpen, selection, drag, autoPreview, measure, stageMark, }) {
116
245
  /*
117
246
  * ๐Ÿ”ด ALWAYS wrapped, even with no selection and no drag.
118
247
  *
@@ -127,7 +256,7 @@ function GalleryTile({ item, cache, onOpen, selection, drag, autoPreview, }) {
127
256
  * With both props absent the shell renders exactly one plain div and no interaction
128
257
  * handlers, so this costs nothing and cannot change behavior anywhere else.
129
258
  */
130
- return (_jsx(TileShell, { name: item.name, selection: selection, drag: drag, children: _jsx(RawGalleryTile, { item: item, cache: cache, onOpen: onOpen, autoPreview: autoPreview }) }));
259
+ return (_jsx(TileShell, { name: item.name, selection: selection, drag: drag, stageMark: stageMark, children: _jsx(RawGalleryTile, { item: item, cache: cache, onOpen: onOpen, autoPreview: autoPreview, measure: measure }) }));
131
260
  }
132
261
  /**
133
262
  * A DOCUMENT's tile picture โ€” page one where a renderer produced it, its FORMAT where not.
@@ -156,7 +285,7 @@ function DocThumb({ src, alt, category, ext, }) {
156
285
  }
157
286
  return (_jsxs(_Fragment, { children: [_jsx("img", { src: src, alt: alt, loading: 'lazy', onError: () => setFailed(true) }, src), _jsx("span", { className: 'cbmg-doc-badge', children: _jsx(FileTypeIcon, { category: category }) })] }));
158
287
  }
159
- function RawGalleryTile({ item, cache, onOpen, autoPreview, }) {
288
+ function RawGalleryTile({ item, cache, onOpen, autoPreview, measure, }) {
160
289
  const category = galleryItemCategory(item);
161
290
  const { media } = useItemMedia(item, cache);
162
291
  if (category === 'video') {
@@ -171,7 +300,7 @@ function RawGalleryTile({ item, cache, onOpen, autoPreview, }) {
171
300
  return (_jsx(HoverScrubVideoThumb, { video: video, onOpen: onOpen, previewMode: 'scrub',
172
301
  // The whole grid previewing at once, on request. The tile suppresses it on
173
302
  // coarse-pointer devices by itself, so a phone never plays ninety clips under a thumb.
174
- autoPlay: autoPreview ?? false, ...(item.orientation ? { orientation: item.orientation } : {}), children: _jsx(GalleryMeta, { item: item }) }));
303
+ autoPlay: autoPreview ?? false, ...(item.orientation ? { orientation: item.orientation } : {}), children: _jsx(GalleryMeta, { item: item, measure: measure }) }));
175
304
  }
176
305
  const full = category === 'image' ? media.url : undefined;
177
306
  const thumb = media.thumbnailUrl ?? full;
@@ -182,7 +311,7 @@ function RawGalleryTile({ item, cache, onOpen, autoPreview, }) {
182
311
  // A stored orientation turns the TILE too. Without this the grid shows a wall of
183
312
  // sideways photographs whose detail view is upright, which reads as the fix not
184
313
  // having worked.
185
- style: orientationStyle(item) }, thumb)) : null })) : (_jsx("div", { className: 'cbmg-doc-media', children: _jsx(DocThumb, { ...(thumb ? { src: thumb } : {}), alt: item.name, category: category, ext: extLabel(item) }) })), _jsx(GalleryMeta, { item: item })] }));
314
+ style: orientationStyle(item) }, thumb)) : null })) : (_jsx("div", { className: 'cbmg-doc-media', children: _jsx(DocThumb, { ...(thumb ? { src: thumb } : {}), alt: item.name, category: category, ext: extLabel(item) }) })), _jsx(GalleryMeta, { item: item, measure: measure })] }));
186
315
  }
187
316
  /** Bytes for the byte-fed viewers (pdf/sheet/text/code/html), fetched once per item. */
188
317
  function useItemBytes(item, url, loadBytes, cache) {
@@ -287,8 +416,51 @@ export function stagePlayableVideo(item, media, streamUrl) {
287
416
  // broken one.
288
417
  ...(media.vttUrl ? { vttUrl: media.vttUrl } : {}),
289
418
  ...(media.processingStatus ? { processingStatus: media.processingStatus } : {}),
419
+ // The named sections, straight off the ITEM rather than out of `media`: chapters are a
420
+ // property of the film, not of a resolved URL, and they are the same list whichever
421
+ // transport the stage ends up playing. Absent when there are none, so a video with no
422
+ // sections hands the player nothing to draw a chapters menu for.
423
+ ...(item.chapters && item.chapters.length > 0 ? { chapters: item.chapters } : {}),
290
424
  };
291
425
  }
426
+ /**
427
+ * ๐Ÿ”ด The stage's session volume, on the NATIVE transport.
428
+ *
429
+ * The Vidstack stage gets this from `VideoPlayer` (`volumeKey`, read off the player's own
430
+ * `volume-change`); this is the same rule for the plain `<video>` an app opts into with
431
+ * `nativeVideoStage`. Same {@link STAGE_VOLUME_KEY}, because it is the same surface โ€” the two
432
+ * transports are never mounted at once, and keying them apart would mean an app that switched
433
+ * transports lost the level mid-session for no reason a person could name.
434
+ *
435
+ * Owner, `ideas/collections-09-14.md`: *"Keep the media player volume where I set it after
436
+ * changing it during a session except if I refresh or first log in then continue starting it
437
+ * at mute. Then if I unmute it, keep that value as I change to other videos."*
438
+ *
439
+ * ๐Ÿ”ด The `else` branch is not a no-op: this element carries `autoPlay` and no `muted`, so the
440
+ * fresh-session case starts it silent โ€” which is both what he asked for and the only state a
441
+ * browser will actually auto-play. The native control bar is right there to unmute.
442
+ */
443
+ function useStageVolume(ref) {
444
+ React.useEffect(() => {
445
+ const el = ref.current;
446
+ if (!el)
447
+ return;
448
+ const stored = readSessionVolume(STAGE_VOLUME_KEY);
449
+ el.volume = stored?.volume ?? 1;
450
+ el.muted = stored?.muted ?? true;
451
+ // What we just applied โ€” the element fires `volumechange` for it, and writing that back
452
+ // would mint a preference nobody set. Only what he does after this is his.
453
+ let applied = { volume: el.volume, muted: el.muted };
454
+ const onChange = () => {
455
+ if (el.volume === applied.volume && el.muted === applied.muted)
456
+ return;
457
+ applied = { volume: el.volume, muted: el.muted };
458
+ writeSessionVolume(STAGE_VOLUME_KEY, applied);
459
+ };
460
+ el.addEventListener('volumechange', onChange);
461
+ return () => el.removeEventListener('volumechange', onChange);
462
+ }, [ref]);
463
+ }
292
464
  /**
293
465
  * The `nativeVideoStage` element โ€” a plain `<video>` with the ladder attached to it.
294
466
  *
@@ -304,6 +476,7 @@ export function stagePlayableVideo(item, media, streamUrl) {
304
476
  function NativeVideoStage({ hlsUrl, streamUrl, poster, repeat, onEnded, }) {
305
477
  const ref = React.useRef(null);
306
478
  useHlsSource(ref, { hlsUrl, streamUrl });
479
+ useStageVolume(ref);
307
480
  return (_jsx("video", { ref: ref, className: 'cbmg-stage-video', poster: poster, controls: true, autoPlay: true, playsInline: true, loop: repeat,
308
481
  // A looping element never fires `ended`, so passing both would silently disable
309
482
  // auto-advance rather than combining the two. See `VideoPlayerProps.loop`.
@@ -341,7 +514,11 @@ function GalleryStage({ item, mediaCache, bytesCache, loadBytes, nativeVideoStag
341
514
  if (nativeVideoStage) {
342
515
  return frame(_jsx(NativeVideoStage, { hlsUrl: media.hlsUrl, streamUrl: src, poster: media.posterUrl, repeat: repeat, ...(onEnded ? { onEnded } : {}) }, media.hlsUrl ?? src));
343
516
  }
344
- return frame(_jsx(React.Suspense, { fallback: _jsx(StageLoading, { label: 'Loading player\u2026' }), children: _jsx(LazyVideoPlayer, { video: stagePlayableVideo(item, media, src), autoPlay: true, loop: repeat, ...(repeat ? {} : { onEnded }), ...(onStep ? { onStep, canStep: canStep !== false } : {}), ...(fullscreenOnDoubleClick === false ? { fullscreenOnDoubleClick: false } : {}) }) }));
517
+ return frame(_jsx(React.Suspense, { fallback: _jsx(StageLoading, { label: 'Loading player\u2026' }), children: _jsx(LazyVideoPlayer, { video: stagePlayableVideo(item, media, src), autoPlay: true,
518
+ // ๐Ÿ”ด The STAGE's own volume memory, not the shared primitive's default key โ€” see
519
+ // `playbackPreferences.ts`. It is what makes the level he set on this clip apply to
520
+ // the next one, and it is per session, so a refresh is silent again.
521
+ volumeKey: STAGE_VOLUME_KEY, loop: repeat, ...(repeat ? {} : { onEnded }), ...(onStep ? { onStep, canStep: canStep !== false } : {}), ...(fullscreenOnDoubleClick === false ? { fullscreenOnDoubleClick: false } : {}) }) }));
345
522
  }
346
523
  if (category === 'image') {
347
524
  if (!media.url)
@@ -381,7 +558,7 @@ function KindChips({ options, value, onChange, tags, activeTag, onTagChange, })
381
558
  * The fleet's shared media gallery. Feed it `MediaGalleryItem`s (any binary kind); it
382
559
  * renders the dense browse grid and the in-page detail view with the right viewer.
383
560
  */
384
- export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initialOrder = 'recent', detailActions, onDelete, headerExtra, chrome = 'header', previewAll, onPreviewAllChange, onPreviewableChange, viewMode: controlledViewMode, onViewModeChange, query: controlledQuery, onQueryChange, onSelectedChange, selectedId: controlledSelectedId, loadBytes, empty, emptyAction, minColumnWidth, nativeVideoStage, searchPlaceholder = 'Search filesโ€ฆ', selectable = false, selectionReveal = 'always', onBulkDelete, bulkActions, onSelectionChange, onItemDragStart, onItemDragEnd, onRename, onSaveThumbnail, onSaveSnapshot, onSaveEdit, onSaveOrientation, allowPictureReplace = false, pictureControls = true, onTrimVideo, trimNote, }) {
561
+ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initialOrder = 'recent', detailActions, onDelete, headerExtra, chrome = 'header', previewAll, onPreviewAllChange, onPreviewableChange, viewMode: controlledViewMode, onViewModeChange, query: controlledQuery, onQueryChange, onSelectedChange, selectedId: controlledSelectedId, loadBytes, empty, emptyAction, minColumnWidth, nativeVideoStage, searchPlaceholder = 'Search filesโ€ฆ', selectable = false, selectionReveal = 'always', onBulkDelete, bulkActions, onSelectionChange, onItemDragStart, onItemDragEnd, onRename, onSaveThumbnail, onSaveSnapshot, onSaveEdit, onSaveOrientation, allowPictureReplace = false, pictureControls = true, onTrimVideo, onSaveChapters, onSaveMeta, trimNote, meta, }) {
385
562
  const [uncontrolledQuery, setUncontrolledQuery] = useState('');
386
563
  /** Controlled when the consumer passes `query`, uncontrolled otherwise โ€” see the prop. */
387
564
  const query = controlledQuery !== undefined ? controlledQuery : uncontrolledQuery;
@@ -579,6 +756,17 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
579
756
  const [turningVideo, setTurningVideo] = useState(null);
580
757
  /** The clip whose cut marks are being made, or null. See `onTrimVideo`. */
581
758
  const [trimmingVideo, setTrimmingVideo] = useState(null);
759
+ /** The clip whose sections are being named, or null. See `onSaveChapters`. */
760
+ const [chapteringVideo, setChapteringVideo] = useState(null);
761
+ /**
762
+ * What the tag/description editor is open over, or null. See `onSaveMeta`.
763
+ *
764
+ * ๐Ÿ”ด A LIST rather than one item, because this is the one capability dialog reachable from
765
+ * BOTH screens: the open file's strip passes `[selected]`, and the bulk bar passes the whole
766
+ * ticked set. One piece of state for both is what keeps the two paths from becoming two
767
+ * dialogs that drift โ€” and it is why `items` is the editor's prop rather than `item`.
768
+ */
769
+ const [taggingItems, setTaggingItems] = useState(null);
582
770
  const [grabbing, setGrabbing] = useState(null);
583
771
  const [showPicture, setShowPicture] = useState(false);
584
772
  const [light, setLight] = useState(NO_LIGHT);
@@ -589,8 +777,26 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
589
777
  // biome-ignore lint/correctness/useExhaustiveDependencies: explained immediately above
590
778
  useEffect(() => {
591
779
  setRenaming(null);
592
- setEditingPicture(null);
593
780
  setLight(NO_LIGHT);
781
+ /*
782
+ * ๐Ÿ”ด EVERY capability dialog, not just the picture editor โ€” the owner, 2026-09-14, on the
783
+ * thumbnail chooser in `apps/collections`: *"Clicking on other folders also did not move
784
+ * to view them and the thumbnail selection screen for the first video stayed on the
785
+ * screen. Selecting other sidebar files and icons should also leave that screen."*
786
+ *
787
+ * Only `editingPicture` was cleared here, so a dialog opened against the file you WERE
788
+ * looking at outlived the selection and then described the wrong file: its title, its
789
+ * `src`, its `startAt` and โ€” the part that loses work โ€” the item every save handler above
790
+ * is closed over. The fix is one line per dialog rather than a rule in a doc comment,
791
+ * because "remember to clear yours" is exactly the instruction the four dialogs added
792
+ * after this one would not have read.
793
+ */
794
+ setEditingPicture(null);
795
+ setTurningVideo(null);
796
+ setTrimmingVideo(null);
797
+ setChapteringVideo(null);
798
+ setTaggingItems(null);
799
+ setGrabbing(null);
594
800
  }, [selectedId]);
595
801
  // ๐Ÿ”ด Enabled whenever there is somewhere else to go, NOT only away from the edges โ€” the
596
802
  // arrows wrap now. A one-item album is the only set with no other item, so it is the only
@@ -728,6 +934,76 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
728
934
  const reduced = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true;
729
935
  el.scrollIntoView({ behavior: reduced || snapsToStage(selected) ? 'auto' : 'smooth', block: 'start' });
730
936
  }, [selectedId, selected]);
937
+ /**
938
+ * โ”€โ”€ Bring the tile the stage is showing back into view โ€” the strip's REVEAL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
939
+ *
940
+ * Owner, `ideas/collections-09-14.md`: *"I want to โ€ฆ still see the playing video in the
941
+ * list."* Keeping it IN the list (the strip above) is only half of that: on a folder of
942
+ * ninety files the marked tile is forty rows away, and a mark nobody can find is a mark
943
+ * nobody has. This is `file-tree/FileTree.tsx`'s reveal, applied to the same problem, and
944
+ * it keeps that one's limits:
945
+ *
946
+ * 1. **Only when the tile is off screen.** A strip that jumps a few pixels every time you
947
+ * click a tile you can already see reads as a bug.
948
+ * 2. **Never mid-drag** โ€” vacuous here rather than skipped: the strip's tiles are handed no
949
+ * `drag` prop at all (see `renderTile` below), so there is no drag of ours to be in the
950
+ * middle of. `MediaGallery.spec.tsx` asserts that, so a future strip that DOES become a
951
+ * drag source fails a test pointing at this note instead of silently yanking the list
952
+ * out from under a pointer.
953
+ *
954
+ * ๐Ÿ”ด โ€ฆand one limit the tree has no need of: it runs only while the stage is PINNED.
955
+ *
956
+ * The watch view is ONE scroller โ€” stage on top, strip underneath โ€” so scrolling the strip
957
+ * scrolls the stage away. The owner has already ruled on that collision, from the other
958
+ * side: *"when I scroll down through thumbnails and click one to play it then I want to
959
+ * navigate automatically back to the top to see the movie I am playing"* (2026-08-14, the
960
+ * effect directly above). Revealing the tile in that mode would undo, every single time,
961
+ * the thing that effect exists to do โ€” the film would be playing off the top of the screen
962
+ * again, which is the older report re-opened.
963
+ *
964
+ * Pinning is his own answer to it: *"a button to lock the video in place on top so I can
965
+ * scroll thumbnails and still see the full video."* With the top region sticky the stage
966
+ * cannot scroll away, the effect above finds it already in place and moves nothing, and the
967
+ * strip's scroll is free โ€” so that is exactly the mode where this reveal is not taking
968
+ * something away to give something else.
969
+ *
970
+ * ๐Ÿ”ด It scrolls by a computed delta rather than by `scrollIntoView`, and the sticky region
971
+ * is why: `block: 'nearest'` on a tile ABOVE the fold aligns it to the top of the scroller,
972
+ * which is the seventy percent of it the pinned stage is sitting on โ€” a "reveal" that puts
973
+ * the tile behind the video. The visible band starts where the pinned region ends.
974
+ */
975
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `selectedId` is the TRIGGER and is deliberately not read in the body โ€” the tile is found through the DOM by the mark that identifies it. `pinStage` is a GUARD and not a trigger: pressing the pin would otherwise scroll the strip on its own, which is movement nobody asked for.
976
+ useEffect(() => {
977
+ const stage = stageRef.current;
978
+ if (!selectedId || !pinStage || !stage)
979
+ return;
980
+ const scroller = stage.closest('.cbgd-watch');
981
+ // The strip's own marked element, in whichever view is on: a tile shell in the grid, a
982
+ // row in the table. Both carry `data-playing` for exactly this.
983
+ const mark = scroller?.querySelector('[data-playing]');
984
+ if (!scroller || !mark)
985
+ return;
986
+ // The table scrolls inside its own frame (`.cbgt-root`), where the stage is not even a
987
+ // sibling; the tile grid windows against the watch scroller itself.
988
+ const frame = mark.closest('.cbgt-root') ?? scroller;
989
+ const view = frame.getBoundingClientRect();
990
+ // Where the pinned region stops covering the strip. Absent (or not pinned) โ†’ the frame's
991
+ // own top edge, which is what the table's separate scroller always uses.
992
+ const pinned = frame === scroller ? scroller.querySelector('.cbgd-watch-top') : null;
993
+ const top = pinned ? Math.max(view.top, pinned.getBoundingClientRect().bottom) : view.top;
994
+ const box = mark.getBoundingClientRect();
995
+ const delta = box.top < top ? box.top - top : box.bottom > view.bottom ? box.bottom - view.bottom : 0;
996
+ // Limit 1: already in the band, so nothing moves.
997
+ if (delta === 0)
998
+ return;
999
+ frame.scrollTop += delta;
1000
+ }, [selectedId]);
1001
+ /**
1002
+ * Is one of the capability dialogs on screen? Every one of them is a focus-trapped modal
1003
+ * over the gallery, so for as long as one is up the window-level shortcuts below are not
1004
+ * the gallery's to take โ€” see the guard in the effect that follows.
1005
+ */
1006
+ const capabilityDialogOpen = Boolean(editingPicture || turningVideo || trimmingVideo || chapteringVideo || grabbing);
731
1007
  /**
732
1008
  * Arrow keys step, Escape closes.
733
1009
  *
@@ -735,9 +1011,22 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
735
1011
  * the thing the owner just clicked and it owns focus โ€” a handler on an ancestor div only
736
1012
  * fires when focus happens to be outside the player, which is the state a person watching a
737
1013
  * video is never in. The typing guard below is what makes a window binding safe.
1014
+ *
1015
+ * โ”€โ”€ ๐Ÿ”ด โ€ฆand it stands DOWN while a capability dialog is open โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1016
+ * The owner, 2026-09-14, on the thumbnail chooser: *"the space bar should toggle playing
1017
+ * the video no matter what is focused but isn't working on the thumbnail selection page."*
1018
+ *
1019
+ * It was firing โ€” on the wrong film. This handler binds at CAPTURE on `window`, so a press
1020
+ * inside an open dialog reached it first and toggled the STAGE player behind the dialog,
1021
+ * which is a video the reader cannot see. `FrameGrabber` now binds the same fleet rule
1022
+ * (`usePlayPauseSpace`) to its own `<video>`, and two capture listeners on one window both
1023
+ * "working" is how a single press pauses two different films โ€” `stopPropagation` does not
1024
+ * help, because it does not stop other listeners on the same target. So the gallery yields
1025
+ * the key instead: whoever is on top owns it. The same guard is what stops Escape closing
1026
+ * the gallery's selection out from under a dialog that Radix was about to close on its own.
738
1027
  */
739
1028
  useEffect(() => {
740
- if (!selectedId)
1029
+ if (!selectedId || capabilityDialogOpen)
741
1030
  return;
742
1031
  const onKey = (event) => {
743
1032
  /*
@@ -875,10 +1164,18 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
875
1164
  */
876
1165
  const capabilityDialogs = (_jsxs(React.Suspense, { fallback: null, children: [editingPicture ? (_jsx(LazyPictureEditor, { src: editingPicture.url ?? '', fileName: editingPicture.name, allowReplace: allowPictureReplace, onClose: () => setEditingPicture(null), onSave: async (result) => {
877
1166
  await onSaveEdit?.(editingPicture, result);
878
- } })) : null, turningVideo ? (_jsx(LazyVideoTurner, { src: turningVideo.streamUrl ?? turningVideo.url ?? '', ...(turningVideo.posterUrl ? { posterUrl: turningVideo.posterUrl } : {}), name: turningVideo.name, orientation: turningVideo.orientation, onClose: () => setTurningVideo(null), onSave: async (next) => {
1167
+ } })) : null, turningVideo ? (_jsx(TurnVideoDialog, { item: turningVideo, cache: mediaCache, onClose: () => setTurningVideo(null), onSave: async (next) => {
879
1168
  await onSaveOrientation?.(turningVideo, next);
880
1169
  } })) : null, trimmingVideo && onTrimVideo ? (_jsx(LazyVideoCutEditor, { src: trimmingVideo.streamUrl ?? trimmingVideo.url ?? '', ...(trimmingVideo.posterUrl ? { posterUrl: trimmingVideo.posterUrl } : {}), name: trimmingVideo.name, durationSeconds: trimmingVideo.durationSeconds ?? null, ...(trimNote ? { note: trimNote } : {}), onClose: () => setTrimmingVideo(null), onSubmit: async (cuts) => {
881
1170
  await onTrimVideo(trimmingVideo, cuts);
1171
+ } })) : null, chapteringVideo && onSaveChapters ? (_jsx(LazyVideoChapterEditor, { src: chapteringVideo.streamUrl ?? chapteringVideo.url ?? '', ...(chapteringVideo.posterUrl ? { posterUrl: chapteringVideo.posterUrl } : {}), name: chapteringVideo.name, durationSeconds: chapteringVideo.durationSeconds ?? null, ...(chapteringVideo.chapters ? { chapters: chapteringVideo.chapters } : {}), onClose: () => setChapteringVideo(null), onSubmit: async (chapters) => {
1172
+ await onSaveChapters(chapteringVideo, chapters);
1173
+ } })) : null, taggingItems && taggingItems.length > 0 && onSaveMeta ? (_jsx(LazyMediaMetaEditor, { items: taggingItems,
1174
+ // The album's own vocabulary, which is already computed for the filter chips โ€”
1175
+ // offering it is what stops `holiday`, `Holiday` and `holidays` being minted as
1176
+ // three chips that each find a third of the set.
1177
+ suggestions: tags, onClose: () => setTaggingItems(null), onSubmit: async (change) => {
1178
+ await onSaveMeta(taggingItems, change);
882
1179
  } })) : null, grabbing ? (_jsx(LazyFrameGrabber, { src: grabbing.item.streamUrl ?? grabbing.item.url ?? '', name: grabbing.item.name, startAt: grabbing.at,
883
1180
  // So the dialog can SAY that saving replaces the picture this film shows now, rather
884
1181
  // than overwriting somebody's chosen poster silently โ€” the owner's copy-not-overwrite
@@ -892,8 +1189,36 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
892
1189
  : {}) })) : null] }));
893
1190
  if (selected) {
894
1191
  const category = galleryItemCategory(selected);
895
- const others = displayItems.filter((i) => i.id !== selected.id);
1192
+ /*
1193
+ * โ”€โ”€ ๐Ÿ”ด The strip under the stage is EVERY displayed item, the open one included โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1194
+ *
1195
+ * Owner, `ideas/collections-09-14.md`: *"sometimes I click on a video and while it's
1196
+ * playing I click on a different video in the thumbnails under the playing video and the
1197
+ * video I click on gets taken out of the list. I want to change this cb media gallery
1198
+ * behavior so that I can still see the playing video in the list and nothing about the
1199
+ * list changes as I pick other videos."*
1200
+ *
1201
+ * This line was `displayItems.filter((i) => i.id !== selected.id)` โ€” the strip was
1202
+ * everything EXCEPT what was playing. So every pick removed one item and put the previous
1203
+ * one back, and because the two sit at different places in the sort, every tile between
1204
+ * them shifted by one: the list re-sorted itself under his pointer on every click, which
1205
+ * is what *"the video I click on gets taken out of the list"* is describing. His
1206
+ * requirement is stated twice and is the exact opposite, so there is no filter here at
1207
+ * all now โ€” the strip is `displayItems`, in `displayItems`' order, and the open one is
1208
+ * MARKED (`stageMark` below) instead of removed.
1209
+ *
1210
+ * ๐Ÿ”ด Nothing downstream ever depended on the exclusion. `step`/โ‡ง-arrows and `advance`
1211
+ * have always run over `displayIds` โ€” the unfiltered displayed order โ€” so their
1212
+ * arithmetic is untouched by this, and `nextInRun` already refuses to draw the current
1213
+ * item as the next one (`playbackRun.ts`, and `MediaGallery.spec.tsx` asserts it from
1214
+ * this edge). What DID depend on it was the heading, which said *"More files"*; with the
1215
+ * open file in the list that word is no longer true, so it is gone too.
1216
+ */
1217
+ const stageMark = category === 'video' || category === 'audio' ? 'Now playing' : 'On screen';
896
1218
  const downloadHref = selected.downloadUrl ?? selected.url;
1219
+ // Capitalised so JSX reads it as a component rather than an HTML tag โ€” see
1220
+ // `PLAYBACK_END_ICONS` for why the setting gets five glyphs and not one.
1221
+ const PlaybackEndIcon = PLAYBACK_END_ICONS[playbackEnd];
897
1222
  return (_jsxs(_Fragment, { children: [capabilityDialogs, _jsx(DenseGalleryDetail, { fullStage: fullStage, onFullStageChange: setFullStage,
898
1223
  /* ๐Ÿ”ด A VIDEO enters browser fullscreen from its own player, so the frame must not also
899
1224
  claim the gesture; every other kind of file has no player, so the frame's
@@ -903,7 +1228,7 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
903
1228
  // The date joins the line rather than replacing what was there โ€” beside the title is
904
1229
  // exactly where the owner asked for it, and the titlebar has the room a tile caption
905
1230
  // does not.
906
- [
1231
+ stageMeta([
907
1232
  selected.tags && selected.tags.length > 0
908
1233
  ? selected.tags.join(' ยท ')
909
1234
  : selected.sizeBytes != null
@@ -912,7 +1237,7 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
912
1237
  createdLabel(selected.createdAt),
913
1238
  ]
914
1239
  .filter(Boolean)
915
- .join(' ยท ') || undefined, sticky: pinStage, stage: _jsx("div", { ref: stageRef, className: 'cbmg-stage-anchor',
1240
+ .join(' ยท ') || undefined, selected.description), sticky: pinStage, stage: _jsx("div", { ref: stageRef, className: 'cbmg-stage-anchor',
916
1241
  /*
917
1242
  * ๐Ÿ”ด The picture adjustment reaches the stage through a CSS custom property, not
918
1243
  * through a prop on the player. The video stage is Vidstack's and is several
@@ -944,17 +1269,17 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
944
1269
  ? 'Unpin โ€” let the stage scroll away with the page'
945
1270
  : 'Pin the stage to the top while the thumbnails scroll under it', children: _jsx("button", {
946
1271
  // guardrails-ignore no-raw-action-button: see the previous button.
947
- type: 'button', className: 'cbgd-order-btn', "aria-pressed": pinStage, onClick: () => {
1272
+ type: 'button', className: `cbgd-order-btn is-icon${pinStage ? ' is-active' : ''}`, "aria-pressed": pinStage, "aria-label": pinStage ? 'Pinned โ€” unpin the stage' : 'Pin the stage', onClick: () => {
948
1273
  const next = !pinStage;
949
1274
  setPinStage(next);
950
1275
  writePinStage(next);
951
- }, children: pinStage ? '๐Ÿ“Œ Pinned' : '๐Ÿ“Œ Pin' }) }), _jsx(Tooltip, { content: PLAYBACK_END_LABELS[playbackEnd].hint, children: _jsxs("button", {
1276
+ }, children: _jsx(Pin, { "aria-hidden": true }) }) }), _jsx(Tooltip, { content: `${PLAYBACK_END_LABELS[playbackEnd].label} โ€” ${PLAYBACK_END_LABELS[playbackEnd].hint}`, children: _jsx("button", {
952
1277
  // guardrails-ignore no-raw-action-button: see the previous button.
953
- type: 'button', className: `cbgd-order-btn${playbackEnd === 'stop' ? '' : ' is-active'}`, onClick: () => {
1278
+ type: 'button', className: `cbgd-order-btn is-icon${playbackEnd === 'stop' ? '' : ' is-active'}`, onClick: () => {
954
1279
  const next = nextPlaybackEnd(playbackEnd);
955
1280
  setPlaybackEnd(next);
956
1281
  writePlaybackEnd(next);
957
- }, "aria-label": `When an item ends: ${PLAYBACK_END_LABELS[playbackEnd].label}`, children: [PLAYBACK_END_LABELS[playbackEnd].glyph, " ", PLAYBACK_END_LABELS[playbackEnd].label] }) }), runAdvances(playbackEnd) ? (_jsx(Tooltip, { content: 'How long each picture holds the screen before the next item', children: _jsxs("label", { className: 'cbgd-dwell', children: [_jsx("span", { className: 'cbgd-dwell-label', children: "Hold" }), _jsx("select", {
1282
+ }, "aria-label": `When an item ends: ${PLAYBACK_END_LABELS[playbackEnd].label}`, children: _jsx(PlaybackEndIcon, { "aria-hidden": true }) }) }), runAdvances(playbackEnd) ? (_jsx(Tooltip, { content: 'How long each picture holds the screen before the next item', children: _jsxs("label", { className: 'cbgd-dwell', children: [_jsx("span", { className: 'cbgd-dwell-label', children: _jsx(Timer, { "aria-hidden": true }) }), _jsx("select", {
958
1283
  // guardrails-ignore no-raw-inputs: `.cbgd-dwell` is this module's own
959
1284
  // self-contained plain-CSS control, for the same reason `.cbgd-search`
960
1285
  // states โ€” cursedbelt's Tailwind field layer would fight it on cascade.
@@ -987,14 +1312,22 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
987
1312
  type: 'button', className: 'cbgd-order-btn', onClick: () => setRenaming(null), children: "Cancel" })] })) : (_jsx(Tooltip, { content: "Change this file's name", children: _jsx("button", {
988
1313
  // guardrails-ignore no-raw-action-button: action pill styled by the dense
989
1314
  // gallery's own plain CSS, matching its neighbors.
990
- type: 'button', className: 'cbgd-order-btn', onClick: () => {
1315
+ type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": 'Rename', onClick: () => {
991
1316
  setRenameDraft(selected.name);
992
1317
  setRenaming(selected.id);
993
- }, children: "Rename" }) }))) : null, (onSaveThumbnail || onSaveSnapshot) && category === 'video' ? (_jsx(Tooltip, { content: onSaveThumbnail
1318
+ }, children: _jsx(Pencil, { "aria-hidden": true }) }) }))) : null, onSaveMeta ? (_jsx(Tooltip, { content: 'Tag this file and say what it is', children: _jsx("button", {
1319
+ // guardrails-ignore no-raw-action-button: see above.
1320
+ type: 'button', className: `cbgd-order-btn is-icon${(selected.tags && selected.tags.length > 0) || selected.description
1321
+ ? ' is-active'
1322
+ : ''}`, "aria-label": 'Tags and description', onClick: () => setTaggingItems([selected]), children: _jsx(Tags, { "aria-hidden": true }) }) })) : null, (onSaveThumbnail || onSaveSnapshot) && category === 'video' ? (_jsx(Tooltip, { content: onSaveThumbnail
994
1323
  ? 'Pick which moment the thumbnail comes from, and keep stills'
995
1324
  : 'Keep a full-size picture of a moment in this film', children: _jsx("button", {
996
1325
  // guardrails-ignore no-raw-action-button: see above.
997
- type: 'button', className: 'cbgd-order-btn', onClick: () => {
1326
+ type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": onSaveThumbnail
1327
+ ? selected.posterUrl
1328
+ ? 'Change thumbnail'
1329
+ : 'Choose thumbnail'
1330
+ : 'Take a picture', onClick: () => {
998
1331
  /*
999
1332
  * ๐Ÿ”ด Carry the PLAYHEAD across, read off the DOM rather than plumbed through.
1000
1333
  * The stage player is Vidstack's and owns its own time; this is one number at
@@ -1006,19 +1339,17 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
1006
1339
  const stage = stageRef.current?.querySelector('.cbmg-stage-video, video');
1007
1340
  const at = stage && Number.isFinite(stage.currentTime) ? stage.currentTime : 0;
1008
1341
  setGrabbing({ item: selected, at });
1009
- }, children: onSaveThumbnail
1010
- ? selected.posterUrl
1011
- ? '๐Ÿ–ผ Change thumbnail'
1012
- : '๐Ÿ–ผ Choose thumbnail'
1013
- : '๐Ÿ“ท Take a picture' }) })) : null, onSaveEdit && category === 'image' ? (_jsx(Tooltip, { content: 'Turn, mirror, crop or re-light this picture', children: _jsx("button", {
1342
+ }, children: onSaveThumbnail ? _jsx(ImageIcon, { "aria-hidden": true }) : _jsx(Camera, { "aria-hidden": true }) }) })) : null, onSaveEdit && category === 'image' ? (_jsx(Tooltip, { content: 'Turn, mirror, crop or re-light this picture', children: _jsx("button", {
1014
1343
  // guardrails-ignore no-raw-action-button: see above.
1015
- type: 'button', className: 'cbgd-order-btn', onClick: () => setEditingPicture(selected), children: "\u27F3 Edit picture" }) })) : null, onSaveOrientation && category === 'video' ? (_jsx(Tooltip, { content: 'Fix a clip filmed sideways \u2014 stored, never re-encoded', children: _jsx("button", {
1344
+ type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": 'Edit picture', onClick: () => setEditingPicture(selected), children: _jsx(Crop, { "aria-hidden": true }) }) })) : null, onSaveOrientation && category === 'video' ? (_jsx(Tooltip, { content: 'Fix a clip filmed sideways \u2014 stored, never re-encoded', children: _jsx("button", {
1016
1345
  // guardrails-ignore no-raw-action-button: see above.
1017
- type: 'button', className: 'cbgd-order-btn', onClick: () => setTurningVideo(selected), children: "\u27F3 Turn video" }) })) : null, onTrimVideo && category === 'video' ? (_jsx(Tooltip, { content: 'Mark sections to cut out \u2014 a trimmed copy is made beside the original', children: _jsx("button", {
1346
+ type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": 'Turn video', onClick: () => setTurningVideo(selected), children: _jsx(RotateCw, { "aria-hidden": true }) }) })) : null, onTrimVideo && category === 'video' ? (_jsx(Tooltip, { content: 'Mark sections to cut out \u2014 a trimmed copy is made beside the original', children: _jsx("button", {
1018
1347
  // guardrails-ignore no-raw-action-button: see above.
1019
- type: 'button', className: 'cbgd-order-btn', onClick: () => setTrimmingVideo(selected), children: "\u2702 Trim" }) })) : null, pictureControls && (category === 'image' || category === 'video') ? (_jsx(Tooltip, { content: 'Brightness, contrast and colour for what you are looking at', children: _jsxs("button", {
1348
+ type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": 'Trim', onClick: () => setTrimmingVideo(selected), children: _jsx(Scissors, { "aria-hidden": true }) }) })) : null, onSaveChapters && category === 'video' ? (_jsx(Tooltip, { content: 'Name the parts of this film \u2014 the player shows them on the scrub bar', children: _jsx("button", {
1020
1349
  // guardrails-ignore no-raw-action-button: see above.
1021
- type: 'button', className: `cbgd-order-btn${showPicture || isLightAdjusted(light) ? ' is-active' : ''}`, "aria-pressed": showPicture, onClick: () => setShowPicture((on) => !on), children: ["\u2600 Picture", isLightAdjusted(light) ? ' ยท' : ''] }) })) : null, showPicture ? (_jsxs("span", { className: 'cbmg-picture-sliders', children: [[
1350
+ type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": 'Sections', onClick: () => setChapteringVideo(selected), children: _jsx(ListVideo, { "aria-hidden": true }) }) })) : null, pictureControls && (category === 'image' || category === 'video') ? (_jsx(Tooltip, { content: 'Brightness, contrast and colour for what you are looking at', children: _jsx("button", {
1351
+ // guardrails-ignore no-raw-action-button: see above.
1352
+ type: 'button', className: `cbgd-order-btn is-icon${showPicture || isLightAdjusted(light) ? ' is-active' : ''}`, "aria-pressed": showPicture, "aria-label": isLightAdjusted(light) ? 'Picture โ€” adjusted' : 'Picture', onClick: () => setShowPicture((on) => !on), children: _jsx(Sun, { "aria-hidden": true }) }) })) : null, showPicture ? (_jsxs("span", { className: 'cbmg-picture-sliders', children: [[
1022
1353
  ['brightness', 'Brightness'],
1023
1354
  ['contrast', 'Contrast'],
1024
1355
  ['saturation', 'Colour'],
@@ -1026,101 +1357,150 @@ export function MediaGallery({ items, title = 'Media', loading, onNearEnd, initi
1026
1357
  // guardrails-ignore no-raw-inputs: a plain control inside a Tailwind-free, plain-CSS row. The Field layer has no slider, and its label chrome would duplicate the label already beside this one.
1027
1358
  type: 'range', min: LIGHT_RANGE.min, max: LIGHT_RANGE.max, step: LIGHT_RANGE.step, value: light[key], onChange: (event) => setLight((l) => ({ ...l, [key]: Number(event.target.value) })) })] }, key))), _jsx("button", {
1028
1359
  // guardrails-ignore no-raw-action-button: see above.
1029
- type: 'button', className: 'cbgd-order-btn', disabled: !isLightAdjusted(light), onClick: () => setLight(NO_LIGHT), children: "Reset" })] })) : null, detailActions?.(selected), downloadHref ? (_jsx(Tooltip, { content: `Download ${selected.name}`, children: _jsx("a", { className: 'cbgd-order-btn', href: downloadHref, download: selected.name, children: "\u2B07 Download" }) })) : null, onDelete ? (_jsx(DeleteButton, { itemLabel: CATEGORY_LABEL[category].toLowerCase(), tooltip: 'Delete this item', onDelete: async () => {
1360
+ type: 'button', className: 'cbgd-order-btn', disabled: !isLightAdjusted(light), onClick: () => setLight(NO_LIGHT), children: "Reset" })] })) : null, detailActions?.(selected), downloadHref ? (_jsx(Tooltip, { content: `Download ${selected.name}`, children: _jsx("a", { className: 'cbgd-order-btn is-icon', href: downloadHref, download: selected.name, "aria-label": `Download ${selected.name}`, children: _jsx(Download, { "aria-hidden": true }) }) })) : null, onDelete ? (_jsx(DeleteButton, { iconOnly: true, "aria-label": 'Delete this item', itemLabel: CATEGORY_LABEL[category].toLowerCase(), tooltip: 'Delete this item', onDelete: async () => {
1030
1361
  await onDelete(selected);
1031
1362
  select(null);
1032
- }, children: "\u2715 Delete" })) : null, _jsx("button", {
1033
- // guardrails-ignore no-raw-action-button: back-to-grid navigation, styled by
1034
- // the dense gallery's own plain CSS (plugin-media-gallery precedent).
1035
- type: 'button', className: 'cbgd-order-btn', onClick: () => select(null), children: "Back to grid" })] }), relatedLabel: 'More files', itemCount: others.length, loading: loading, onNearEnd: onNearEnd, minColumnWidth: minColumnWidth, ...(viewMode === 'table'
1363
+ } })) : null, _jsx(Tooltip, { content: 'Close this file and go back to the grid', children: _jsx("button", {
1364
+ // guardrails-ignore no-raw-action-button: back-to-grid navigation, styled by
1365
+ // the dense gallery's own plain CSS (plugin-media-gallery precedent).
1366
+ type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": 'Back to grid', onClick: () => select(null), children: _jsx(ArrowLeft, { "aria-hidden": true }) }) })] }),
1367
+ /* Not "More files" โ€” the strip holds the open file too now, so "more" would be the
1368
+ heading contradicting the list under it. */
1369
+ relatedLabel: 'Files', itemCount: displayItems.length, loading: loading, onNearEnd: onNearEnd, minColumnWidth: minColumnWidth, ...(viewMode === 'table'
1036
1370
  ? {
1037
- relatedBody: (_jsx(GalleryTable, { items: others, cache: mediaCache, order: order, onOrderChange: setOrder, onOpen: select, ...(onNearEnd ? { onNearEnd } : {}), ...(loading === undefined ? {} : { loading }),
1371
+ relatedBody: (_jsx(GalleryTable, { items: displayItems,
1372
+ // The open file is in this list exactly as it is in the tile strip, so it is
1373
+ // marked here exactly as it is there.
1374
+ playing: { id: selected.id, label: stageMark }, cache: mediaCache, order: order, onOrderChange: setOrder, onOpen: select, ...(onNearEnd ? { onNearEnd } : {}), ...(loading === undefined ? {} : { loading }),
1038
1375
  /* ๐Ÿ”ด The same `thumbAutoplay` the tiles take. Owner, 2026-09-11: *"in table
1039
1376
  view the play all thumbnails isn't working but if I go back to grid view it
1040
1377
  works."* This is the RELATED list under an open file, and it is the same
1041
1378
  table as the browse view's โ€” so leaving it out here would fix the toggle on
1042
1379
  one of the two screens it is wrong on. */
1043
- autoPreview: thumbAutoplay })),
1380
+ autoPreview: thumbAutoplay,
1381
+ /* Same argument for the exact-bytes column: this IS the browse view's
1382
+ table, so a column it offered and this one withheld would be the
1383
+ feature vanishing the moment you opened a file. */
1384
+ exactBytes: meta?.exactBytes === true })),
1044
1385
  }
1045
1386
  : {}), renderTile: (index) => {
1046
- const item = others[index];
1387
+ const item = displayItems[index];
1047
1388
  return item ? (_jsx(GalleryTile, { item: item, cache: mediaCache, onOpen: () => select(item),
1048
1389
  // The toggle means what it says on both screens โ€” "Preview all" that stopped
1049
1390
  // applying the moment you opened something would be a different feature.
1050
- autoPreview: thumbAutoplay }, item.id)) : null;
1391
+ autoPreview: thumbAutoplay,
1392
+ // โ€ฆand so does the running time on a film's caption: the strip under an open
1393
+ // file is the same grid, and a tile that told you its length on one screen and
1394
+ // its size on the other would be two tiles.
1395
+ measure: meta?.tileMeasure === true, ...(item.id === selected.id ? { stageMark } : {}) }, item.id)) : null;
1051
1396
  } })] }));
1052
1397
  }
1053
- return (_jsx(DenseGalleryShell, { title: title, order: {
1054
- value: order,
1055
- options: GALLERY_ORDER_OPTIONS,
1056
- onChange: setOrder,
1057
- ariaLabel: 'Sort files',
1058
- }, query: query, onQueryChange: setQuery, searchPlaceholder: searchPlaceholder, chrome: chrome, headerExtra: _jsxs(_Fragment, { children: [_jsx("fieldset", { className: 'cbgd-order', "aria-label": 'How files are shown', children: [
1059
- { mode: 'grid', glyph: 'โ–ฆ', label: 'Grid', hint: 'Show files as thumbnails' },
1060
- {
1061
- mode: 'table',
1062
- glyph: 'โ˜ฐ',
1063
- label: 'Table',
1064
- hint: 'Show files as rows with size, length and date',
1065
- },
1066
- ].map((option) => (_jsx(Tooltip, { content: option.hint, children: _jsxs("button", {
1067
- // guardrails-ignore no-raw-action-button: header pill styled by the dense
1068
- // gallery's own plain CSS, matching its sort neighbours.
1069
- type: 'button', className: `cbgd-order-btn${viewMode === option.mode ? ' is-active' : ''}`, "aria-pressed": viewMode === option.mode, onClick: () => setViewMode(option.mode), children: [_jsx("span", { "aria-hidden": 'true', children: option.glyph }), " ", option.label] }) }, option.mode))) }), hasPreviewable ? (_jsx(Tooltip, { content: thumbAutoplay
1070
- ? 'Stop previewing every clip โ€” hover one to preview it'
1071
- : 'Preview every clip at once, without hovering', children: _jsx("button", {
1072
- // guardrails-ignore no-raw-action-button: header pill styled by the dense
1073
- // gallery's own plain CSS, matching its sort neighbours.
1074
- type: 'button', className: `cbgd-order-btn${thumbAutoplay ? ' is-active' : ''}`, "aria-pressed": thumbAutoplay, onClick: () => setThumbAutoplay(!thumbAutoplay), children: "\u25B6 Preview all" }) })) : null, headerExtra] }), filterStrip: _jsx(KindChips, { options: kindOptions, value: kind, onChange: setKind, tags: tags, activeTag: tag, onTagChange: setTag }), bulkBar: selectable ? (_jsx(BulkBar, { count: checks.count, total: displayItems.length, headerState: checks.headerState, onToggleAll: checks.toggleAll, onClear: () => {
1075
- checks.clear();
1076
- setSelectArmed(false);
1077
- }, ...(selectionReveal === 'on-demand'
1078
- ? { armed: selectArmed, onToggleArmed: () => setSelectArmed((on) => !on) }
1079
- : {}), onDelete: onBulkDelete
1080
- ? async () => {
1081
- await onBulkDelete(checkedItems);
1082
- checks.clear();
1083
- }
1084
- : undefined, extra: bulkActions?.(checkedItems, checks.clear) })) : null, itemCount: displayItems.length, loading: loading, onNearEnd: onNearEnd, empty: empty ?? _jsx("div", { className: 'cbgd-empty', children: "No files yet." }), emptyAction: emptyAction, minColumnWidth: minColumnWidth, body: viewMode === 'table' ? (_jsx(GalleryTable, { items: displayItems, cache: mediaCache, order: order,
1085
- // ๐Ÿ”ด The table drives the gallery's OWN sort. Two views with two sorts is a
1086
- // question nobody should have to answer; see GalleryTable's header.
1087
- onOrderChange: setOrder, onOpen: select, ...(onNearEnd ? { onNearEnd } : {}), ...(loading === undefined ? {} : { loading }), ...(empty === undefined ? {} : { empty }),
1088
- /* See the related table in the detail view above โ€” "Preview all" has to mean the
1089
- same thing in both views or it is two features wearing one label. */
1090
- autoPreview: thumbAutoplay, ...(selectable
1091
- ? {
1092
- selection: (item) => ({
1093
- selected: checks.isSelected(item.id),
1094
- onToggle: (shiftKey) => checks.toggle(item.id, { shiftKey }),
1095
- }),
1096
- }
1097
- : {}), ...(onItemDragStart ? { drag: tileDrag } : {}) })) : null, renderTile: (index) => {
1098
- const item = displayItems[index];
1099
- return item ? (_jsx(GalleryTile, { item: item, cache: mediaCache, onOpen: () => select(item), selection: tileSelection(item), drag: tileDrag(item), autoPreview: thumbAutoplay }, item.id)) : null;
1100
- } }));
1398
+ return (_jsxs(_Fragment, { children: [capabilityDialogs, _jsx(DenseGalleryShell, { title: title, order: {
1399
+ value: order,
1400
+ options: GALLERY_ORDER_OPTIONS,
1401
+ onChange: setOrder,
1402
+ ariaLabel: 'Sort files',
1403
+ }, query: query, onQueryChange: setQuery, searchPlaceholder: searchPlaceholder, chrome: chrome, headerExtra: _jsxs(_Fragment, { children: [_jsx("fieldset", { className: 'cbgd-order', "aria-label": 'How files are shown', children: [
1404
+ { mode: 'grid', Icon: LayoutGrid, label: 'Grid', hint: 'Show files as thumbnails' },
1405
+ {
1406
+ mode: 'table',
1407
+ Icon: Rows3,
1408
+ label: 'Table',
1409
+ hint: 'Show files as rows with size, length and date',
1410
+ },
1411
+ ].map((option) => (_jsx(Tooltip, { content: option.hint, children: _jsx("button", {
1412
+ // guardrails-ignore no-raw-action-button: header pill styled by the dense
1413
+ // gallery's own plain CSS, matching its sort neighbours.
1414
+ type: 'button', className: `cbgd-order-btn is-icon${viewMode === option.mode ? ' is-active' : ''}`, "aria-pressed": viewMode === option.mode, "aria-label": option.label, onClick: () => setViewMode(option.mode), children: _jsx(option.Icon, { "aria-hidden": true }) }) }, option.mode))) }), hasPreviewable ? (_jsx(Tooltip, { content: thumbAutoplay
1415
+ ? 'Stop previewing every clip โ€” hover one to preview it'
1416
+ : 'Preview every clip at once, without hovering', children: _jsx("button", {
1417
+ // guardrails-ignore no-raw-action-button: header pill styled by the dense
1418
+ // gallery's own plain CSS, matching its sort neighbours.
1419
+ type: 'button', className: `cbgd-order-btn is-icon${thumbAutoplay ? ' is-active' : ''}`, "aria-pressed": thumbAutoplay, "aria-label": 'Preview all', onClick: () => setThumbAutoplay(!thumbAutoplay), children: _jsx(Play, { "aria-hidden": true }) }) })) : null, headerExtra] }), filterStrip: _jsx(KindChips, { options: kindOptions, value: kind, onChange: setKind, tags: tags, activeTag: tag, onTagChange: setTag }), bulkBar: selectable ? (_jsx(BulkBar, { count: checks.count, total: displayItems.length, headerState: checks.headerState, onToggleAll: checks.toggleAll, onClear: () => {
1420
+ checks.clear();
1421
+ setSelectArmed(false);
1422
+ }, ...(selectionReveal === 'on-demand'
1423
+ ? { armed: selectArmed, onToggleArmed: () => setSelectArmed((on) => !on) }
1424
+ : {}), onDelete: onBulkDelete
1425
+ ? async () => {
1426
+ await onBulkDelete(checkedItems);
1427
+ checks.clear();
1428
+ }
1429
+ : undefined,
1430
+ /* ๐Ÿ”ด The SAME handler and the same dialog the open file's strip uses โ€” it is
1431
+ handed a list either way, so there is no singular path for a plural gesture to
1432
+ drift away from. Tagging forty holiday photographs one at a time is the case
1433
+ the owner will actually hit, and it is one call carrying forty ids. */
1434
+ onTag: onSaveMeta ? () => setTaggingItems(checkedItems) : undefined, extra: bulkActions?.(checkedItems, checks.clear) })) : null, itemCount: displayItems.length, loading: loading, onNearEnd: onNearEnd, empty: empty ?? _jsx("div", { className: 'cbgd-empty', children: "No files yet." }), emptyAction: emptyAction, minColumnWidth: minColumnWidth, body: viewMode === 'table' ? (_jsx(GalleryTable, { items: displayItems, cache: mediaCache, order: order,
1435
+ // ๐Ÿ”ด The table drives the gallery's OWN sort. Two views with two sorts is a
1436
+ // question nobody should have to answer; see GalleryTable's header.
1437
+ onOrderChange: setOrder, onOpen: select, ...(onNearEnd ? { onNearEnd } : {}), ...(loading === undefined ? {} : { loading }), ...(empty === undefined ? {} : { empty }),
1438
+ /* See the related table in the detail view above โ€” "Preview all" has to mean the
1439
+ same thing in both views or it is two features wearing one label. */
1440
+ autoPreview: thumbAutoplay, exactBytes: meta?.exactBytes === true, ...(selectable
1441
+ ? {
1442
+ selection: (item) => ({
1443
+ selected: checks.isSelected(item.id),
1444
+ onToggle: (shiftKey) => checks.toggle(item.id, { shiftKey }),
1445
+ }),
1446
+ }
1447
+ : {}), ...(onItemDragStart ? { drag: tileDrag } : {}) })) : null, renderTile: (index) => {
1448
+ const item = displayItems[index];
1449
+ return item ? (_jsx(GalleryTile, { item: item, cache: mediaCache, onOpen: () => select(item), selection: tileSelection(item), drag: tileDrag(item), autoPreview: thumbAutoplay, measure: meta?.tileMeasure === true }, item.id)) : null;
1450
+ } })] }));
1101
1451
  }
1102
1452
  const isItem = (i) => i !== undefined;
1453
+ /**
1454
+ * The titlebar's meta slot โ€” the one-line facts, plus the DESCRIPTION under them.
1455
+ *
1456
+ * ๐Ÿ”ด The description gets its OWN element rather than joining the ` ยท ` line, and that is a
1457
+ * layout fact rather than a preference: `.cbgd-meta-line` is `white-space: nowrap` with an
1458
+ * ellipsis, because the same class captions every tile in the grid. A sentence appended to
1459
+ * that line is a sentence nobody can read past its first few words โ€” the exact failure the
1460
+ * task warns about for the TILE caption, one element further up.
1461
+ *
1462
+ * So the slot becomes two children of the titlebar's own wrapping flex row: the facts stay on
1463
+ * the nowrap line they were always on, and `.cbmg-stage-description` is a block that wraps and
1464
+ * is width-capped for readability (`mediaGallery.css`). An item with no description renders
1465
+ * byte-for-byte what it rendered before โ€” `undefined` in, `undefined` out โ€” which is what
1466
+ * keeps this from restyling every gallery in the fleet.
1467
+ */
1468
+ function stageMeta(line, description) {
1469
+ const words = description?.trim();
1470
+ if (!words)
1471
+ return line;
1472
+ return (_jsxs(_Fragment, { children: [line ? _jsx("span", { className: 'cbmg-stage-facts', children: line }) : null, _jsx("span", { className: 'cbmg-stage-description', children: words })] }));
1473
+ }
1103
1474
  /**
1104
1475
  * The multi-select bar. Always mounted while `selectable` (never conditionally, so the grid
1105
1476
  * does not jump the moment the first box is ticked) โ€” the destructive half only appears
1106
1477
  * once something is actually selected.
1107
1478
  */
1108
- function BulkBar({ count, total, headerState, armed, onToggleArmed, onToggleAll, onClear, onDelete, extra, }) {
1479
+ function BulkBar({ count, total, headerState, armed, onToggleArmed, onToggleAll, onClear, onDelete, onTag, extra, }) {
1109
1480
  return (_jsxs("div", { className: 'cbmg-bulkbar', children: [onToggleArmed ? (_jsx(Tooltip, { content: armed ? 'Hide the tick boxes again' : 'Show a tick box on every tile', children: _jsx("button", {
1110
1481
  // guardrails-ignore no-raw-action-button: action pill styled by the dense
1111
1482
  // gallery's own plain CSS, matching its neighbors.
1112
- type: 'button', className: `cbgd-order-btn${armed ? ' is-active' : ''}`, "aria-pressed": armed === true, onClick: onToggleArmed, children: armed ? 'Done selecting' : 'Select' }) })) : null, _jsxs("label", { className: 'cbmg-bulkbar-all', children: [_jsx("input", {
1483
+ type: 'button', className: `cbgd-order-btn is-icon${armed ? ' is-active' : ''}`, "aria-pressed": armed === true, "aria-label": armed ? 'Done selecting' : 'Select', onClick: onToggleArmed, children: _jsx(SquareCheck, { "aria-hidden": true }) }) })) : null, _jsxs("label", { className: 'cbmg-bulkbar-all', children: [_jsx("input", {
1113
1484
  // guardrails-ignore no-raw-inputs: a plain control inside a Tailwind-free, plain-CSS row. The Field layer has no slider, and its label chrome would duplicate the label already beside this one.
1114
1485
  // guardrails-ignore no-raw-inputs: plain-CSS module โ€” see TileSelect.
1115
1486
  type: 'checkbox', checked: headerState === true, ref: (el) => {
1116
1487
  if (el)
1117
1488
  el.indeterminate = headerState === 'indeterminate';
1118
- }, readOnly: true, onClick: onToggleAll, disabled: total === 0 }), _jsx("span", { children: count > 0 ? `${count} selected` : 'Select all' })] }), count > 0 ? (_jsxs(_Fragment, { children: [_jsx("span", { className: 'cbmg-bulkbar-spacer' }), extra, onDelete ? (_jsxs(DeleteButton, { size: 'sm', "aria-label": `Delete the ${count} selected item${count === 1 ? '' : 's'}`, tooltip: `Delete the ${count} selected item${count === 1 ? '' : 's'}`, confirmOptions: {
1489
+ }, readOnly: true, onClick: onToggleAll, disabled: total === 0 }), _jsx("span", { children: count > 0 ? `${count} selected` : 'Select all' })] }), count > 0 ? (_jsxs(_Fragment, { children: [_jsx("span", { className: 'cbmg-bulkbar-spacer' }), onTag ? (_jsx(Tooltip, { content: `Tag the ${count} selected item${count === 1 ? '' : 's'} in one go`, children: _jsx("button", {
1490
+ // guardrails-ignore no-raw-action-button: action pill styled by the dense
1491
+ // gallery's own plain CSS, matching its neighbors.
1492
+ type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": `Tag the ${count} selected item${count === 1 ? '' : 's'}`, onClick: onTag, children: _jsx(Tags, { "aria-hidden": true }) }) })) : null, extra, onDelete ? (_jsx(DeleteButton, { size: 'sm', "aria-label": `Delete the ${count} selected item${count === 1 ? '' : 's'}`, tooltip: `Delete the ${count} selected item${count === 1 ? '' : 's'}`, confirmOptions: {
1119
1493
  title: `Delete ${count} item${count === 1 ? '' : 's'}?`,
1120
1494
  message: 'This cannot be undone.',
1121
- }, onDelete: onDelete, children: ["\u2715 Delete ", count] })) : null, _jsx("button", {
1122
- // guardrails-ignore no-raw-action-button: action pill styled by the dense
1123
- // gallery's own plain CSS, matching its neighbors.
1124
- type: 'button', className: 'cbgd-order-btn', onClick: onClear, children: "Clear" })] })) : null] }));
1495
+ }, onDelete: onDelete,
1496
+ /* ๐Ÿ”ด The COUNT drops off the pill and loses nothing, because this bar prints
1497
+ "{count} selected" three controls to the left โ€” it was the one number in
1498
+ the strip that was already stated twice. Both the name and the tooltip
1499
+ above still carry it, so a reader hovering the trash still learns how many
1500
+ it takes. */
1501
+ iconOnly: true })) : null, _jsx(Tooltip, { content: 'Untick everything and leave the tiles alone', children: _jsx("button", {
1502
+ // guardrails-ignore no-raw-action-button: action pill styled by the dense
1503
+ // gallery's own plain CSS, matching its neighbors.
1504
+ type: 'button', className: 'cbgd-order-btn is-icon', "aria-label": 'Clear the selection', onClick: onClear, children: _jsx(X, { "aria-hidden": true }) }) })] })) : null] }));
1125
1505
  }
1126
1506
  //# sourceMappingURL=MediaGallery.js.map