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,4 +1,61 @@
1
1
  import './mediaGallery.css';
2
+ /*
3
+ * โ”€โ”€ ๐Ÿ”ด The toolbar's icons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
4
+ *
5
+ * Owner, `ideas/collections-09-14.md`, 2026-09-14: *"In the cb media galleries like
6
+ * collections uses I want to make the toolbar look more professional. Make it have icons
7
+ * only, instead of words in the strip of tools where have things like pin, stop at the end,
8
+ * and turn video. Hovering over the tooltip can show the words."*
9
+ *
10
+ * `lucide-react`, not the ๐Ÿ“Œ โŸณ ๐Ÿ–ผ โœ‚ these pills carried until 2026-09-16. An emoji is drawn
11
+ * by whichever font the operating system picked, at whatever optical size and weight that
12
+ * font chose, in colour on some platforms and monochrome on others โ€” so the one thing a
13
+ * toolbar of them cannot look is designed, which is the opposite of what was asked for. The
14
+ * package is already a dependency (`DropdownMenu`, `PlayerStrip`, `icons.tsx`), so this adds
15
+ * no weight and no second icon vocabulary.
16
+ *
17
+ * ๐Ÿ”ด Three rules every conversion below keeps, because a visual change is exactly where each
18
+ * is silently dropped:
19
+ *
20
+ * 1. **The accessible name survives.** Every icon-only pill carries an `aria-label`, and
21
+ * the glyph itself is `aria-hidden`. The words did double duty as the name.
22
+ * 2. **The 44px tap floor survives.** The hit target is the pill, not the glyph โ€” see
23
+ * `.cbgd-order-btn.is-icon` in `denseGallery.css`, measured by
24
+ * `e2e/touch-targets.spec.ts` at a real coarse-pointer viewport.
25
+ * 3. **A word that was a VALUE keeps a home.** `๐Ÿ“Œ Pin` vs `๐Ÿ“Œ Pinned` and the five
26
+ * end-behaviour labels were not names, they were readouts; an icon identical in both
27
+ * states deletes information. They move into the pressed state, the accessible name and
28
+ * the tooltip together โ€” `MediaGallery.spec.tsx` asserts each one.
29
+ *
30
+ * What stays a word: the dwell READING (`5s`), the selection count, the sort pills, and the
31
+ * inline rename form's Save/Cancel. Those are values you read or a form's action row
32
+ * (`DESIGN.md` ยง6), not tools in the strip.
33
+ */
34
+ import {
35
+ ArrowLeft,
36
+ ArrowRightToLine,
37
+ Camera,
38
+ Crop,
39
+ Download,
40
+ Image as ImageIcon,
41
+ LayoutGrid,
42
+ ListVideo,
43
+ Pencil,
44
+ Pin,
45
+ Play,
46
+ Repeat,
47
+ Repeat1,
48
+ RotateCw,
49
+ Rows3,
50
+ Scissors,
51
+ Shuffle,
52
+ SkipForward,
53
+ SquareCheck,
54
+ Sun,
55
+ Tags,
56
+ Timer,
57
+ X,
58
+ } from 'lucide-react';
2
59
  import { formatBytes } from 'cwip/format';
3
60
  import type { FileCategory } from 'cwip/file-kind';
4
61
  import React, {
@@ -34,6 +91,7 @@ import {
34
91
  } from '../media/pictureEdit';
35
92
  import type { PictureEditResult } from '../media/PictureEditor';
36
93
  import type { MediaOrientation } from '../media/VideoTurner';
94
+ import type { ChapteredVideo, VideoChapter } from '../media/videoChapters';
37
95
  import type { VideoCut } from 'cursedbelt-core/media/video-cuts';
38
96
  import {
39
97
  GALLERY_ORDER_OPTIONS,
@@ -54,8 +112,11 @@ import {
54
112
  readImageDwell,
55
113
  readPinStage,
56
114
  readPlaybackEnd,
115
+ readSessionVolume,
57
116
  readThumbAutoplay,
117
+ STAGE_VOLUME_KEY,
58
118
  writeImageDwell,
119
+ writeSessionVolume,
59
120
  writePinStage,
60
121
  writePlaybackEnd,
61
122
  writeThumbAutoplay,
@@ -63,7 +124,8 @@ import {
63
124
  readViewMode,
64
125
  writeViewMode,
65
126
  } from './playbackPreferences';
66
- import { GalleryTable } from './GalleryTable';
127
+ import type { MediaMetaChange } from './mediaMeta';
128
+ import { GalleryTable, durationLabel } from './GalleryTable';
67
129
  import { nextInRun, runAdvances, runRepeatsOne, stepId } from './playbackRun';
68
130
  import type { MediaGalleryItem, MediaGalleryItemMedia, MediaGalleryOrder } from './types';
69
131
  import { createdLabel, type MediaCache, ThumbImg, useItemMedia } from './galleryItemMedia';
@@ -104,6 +166,58 @@ const LazyFrameGrabber = React.lazy(() =>
104
166
  const LazyVideoTurner = React.lazy(() =>
105
167
  import('../media/VideoTurner').then((m) => ({ default: m.VideoTurner })),
106
168
  );
169
+ /**
170
+ * The turn dialog, over the item's RESOLVED media โ€” never over the raw row.
171
+ *
172
+ * โ”€โ”€ ๐Ÿ”ด The black box, and where it came from โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
173
+ * Owner, `ideas/collections-09-14.md`: *"When I click the turn the video button I see a black
174
+ * screen. It should show me the video so I can see how the turn impacts it."*
175
+ *
176
+ * The dialog was mounted as `src={item.streamUrl ?? item.url ?? ''}` โ€” the fields ON THE ROW.
177
+ * But a gallery item is allowed to carry no URL at all: {@link MediaGalleryItem.resolveMedia}
178
+ * is the documented path for signed URLs and for bytes that become an object URL, and every
179
+ * other surface in this file reads an item through {@link useItemMedia}, which merges that
180
+ * resolver's answer into the row's own fields and caches it per id. So on any app that signs
181
+ * its URLs โ€” which is the app that reported this โ€” both fields were `undefined`, the dialog
182
+ * mounted a `<video>` with no `src` and no `poster`, and an element with nothing to load never
183
+ * even fires `error`: it paints black, forever, silently. Reproduced in
184
+ * `MediaGallery.spec.tsx`'s "the dialog is handed the RESOLVED source" before this existed.
185
+ *
186
+ * ๐Ÿ”ด It shares the gallery's own cache rather than calling the resolver again. A second cache
187
+ * would re-sign the URL every time the dialog opened, which is the cost `galleryItemMedia.tsx`
188
+ * was extracted to stop.
189
+ */
190
+ function TurnVideoDialog({
191
+ item,
192
+ cache,
193
+ onClose,
194
+ onSave,
195
+ }: {
196
+ item: MediaGalleryItem;
197
+ cache: MediaCache;
198
+ onClose: () => void;
199
+ onSave: (next: MediaOrientation | undefined) => void | Promise<void>;
200
+ }) {
201
+ const { media, resolving } = useItemMedia(item, cache);
202
+ // `streamUrl ?? url`, the same preference the stage and the frame grabber make: a plain `url`
203
+ // is not always a streamable source, and a turner given the wrong one shows a black box.
204
+ const src = media.streamUrl ?? media.url ?? '';
205
+ const poster = media.posterUrl ?? media.thumbnailUrl;
206
+ return (
207
+ <LazyVideoTurner
208
+ src={src}
209
+ {...(poster ? { posterUrl: poster } : {})}
210
+ // So an unresolved source reads as "still finding it" for the moment it takes, rather
211
+ // than flashing the "cannot show it" reason at somebody whose film is on its way.
212
+ resolving={resolving}
213
+ name={item.name}
214
+ orientation={item.orientation}
215
+ onClose={onClose}
216
+ onSave={onSave}
217
+ />
218
+ );
219
+ }
220
+
107
221
  // Marking sections of a clip to cut out. Lazy for the usual reason and one extra: an app that
108
222
  // has no pipeline to execute a cut list never passes `onTrimVideo`, so it never loads a byte
109
223
  // of this โ€” which is what makes "enabled for collections only, for now" a fact about the
@@ -111,6 +225,45 @@ const LazyVideoTurner = React.lazy(() =>
111
225
  const LazyVideoCutEditor = React.lazy(() =>
112
226
  import('../media/VideoCutEditor').then((m) => ({ default: m.VideoCutEditor })),
113
227
  );
228
+ // Naming the parts of a clip. The same bargain as the cut editor one line up: an app that has
229
+ // nowhere to PUT a chapter list never passes `onSaveChapters`, so it never loads a byte of
230
+ // this dialog โ€” the capability and the bundle cost turn on together, with no flag between them.
231
+ const LazyVideoChapterEditor = React.lazy(() =>
232
+ import('../media/VideoChapterEditor').then((m) => ({ default: m.VideoChapterEditor })),
233
+ );
234
+ // Tagging and describing, on one file or on a ticked selection. Same bargain again: an app
235
+ // with nowhere to PUT a tag never passes `onSaveMeta`, so it never loads a byte of this
236
+ // dialog โ€” and the gallery keeps reading whatever tags and descriptions its items already
237
+ // carry either way, because reading was never the capability that was missing.
238
+ const LazyMediaMetaEditor = React.lazy(() =>
239
+ import('./MediaMetaEditor').then((m) => ({ default: m.MediaMetaEditor })),
240
+ );
241
+
242
+ /**
243
+ * One glyph per end-behaviour โ€” the five values the cycling pill used to spell out.
244
+ *
245
+ * ๐Ÿ”ด Five DISTINCT icons, not one icon plus a lit state. The button cycles through settings
246
+ * rather than toggling one, so `Stop at the end` and `Shuffle` have to be told apart at a
247
+ * glance or the only way to read the current value is to hover โ€” and the owner's own words
248
+ * for this control ("stop at the end") are a value he was reading off the strip, not a name
249
+ * for the button. `aria-label` still spells the value out in full; this is the sighted half
250
+ * of the same fact.
251
+ *
252
+ * `stop` is `ArrowRightToLine` because it is exactly the `โ†’|` the pill drew before, and it
253
+ * is the one setting that renders UNLIT (`is-active` is withheld for `stop`) โ€” so "nothing
254
+ * happens at the end" reads as an off state, which is what it is.
255
+ *
256
+ * Deliberately NOT in `playbackPreferences.ts` beside the labels: that module is pure data
257
+ * and storage, imported by apps that drive their own player, and adding a React component to
258
+ * it would pull `lucide-react` into every one of them for a table they never render.
259
+ */
260
+ const PLAYBACK_END_ICONS: Record<PlaybackEndBehavior, typeof Repeat> = {
261
+ stop: ArrowRightToLine,
262
+ repeat: Repeat1,
263
+ next: SkipForward,
264
+ all: Repeat,
265
+ shuffle: Shuffle,
266
+ };
114
267
 
115
268
  /**
116
269
  * A stored orientation, as a CSS transform โ€” the same `previewTransform` the editor previews
@@ -133,13 +286,45 @@ function isTurned(item: MediaGalleryItem): boolean {
133
286
 
134
287
  type BytesCache = MutableRefObject<Map<string, Promise<ArrayBuffer>>>;
135
288
 
136
- function GalleryMeta({ item }: { item: MediaGalleryItem }) {
137
- const line =
138
- item.tags && item.tags.length > 0
139
- ? item.tags.slice(0, 3).join(' ยท ')
140
- : item.sizeBytes != null
141
- ? formatBytes(item.sizeBytes)
142
- : null;
289
+ /**
290
+ * The one meta line under a tile's picture โ€” its tags, its measurement, or both.
291
+ *
292
+ * โ”€โ”€ ๐Ÿ”ด What `measure` turns on, and why it is not the default โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
293
+ * Owner, `ideas/collections-09-14.md`: *"in the grid view show the video length instead of
294
+ * the size"*, and `ideas/binary-server-09-15.md`: *"show the video length on thumbnail
295
+ * components โ€ฆ show the file size on thumbnail components (maybe controlled through param on
296
+ * media gallery in cb)"*. He guessed the right home for it: ONE param on this gallery, so an
297
+ * app that asks for nothing renders the line it already had, byte for byte.
298
+ *
299
+ * With it on, the measurement a tile leads with is the one that means something for its kind
300
+ * โ€” a film and a track show their RUNNING TIME, everything else its size. A clip whose
301
+ * duration nobody measured falls back to its size rather than to a blank caption, and a file
302
+ * with neither shows `โ€”`, the same mark the table's columns use for an absent measurement.
303
+ *
304
+ * ๐Ÿ”ด Tags and the measurement share this line rather than taking one each. The caption block
305
+ * is a fixed 65px โ€” title, this line, the date โ€” pinned to `CBGD_META_HEIGHT` because a
306
+ * caption whose height varied per tile is what made rows lap one another across the fleet
307
+ * (see `denseGallery.css`). A fourth line is not available at any price, so a tagged video
308
+ * reads `holiday ยท beach ยท 3:07`.
309
+ *
310
+ * Exported for its own spec: the branch table here is wider than the tile is, and a rendered
311
+ * assertion can only reach the combinations a fixture happens to contain.
312
+ */
313
+ export function galleryTileLine(
314
+ item: MediaGalleryItem,
315
+ options: { measure?: boolean } = {},
316
+ ): string | null {
317
+ const tags = item.tags && item.tags.length > 0 ? item.tags.slice(0, 3).join(' ยท ') : null;
318
+ const size = item.sizeBytes != null ? formatBytes(item.sizeBytes) : null;
319
+ if (!options.measure) return tags ?? size;
320
+ const category = galleryItemCategory(item);
321
+ const timeBased = category === 'video' || category === 'audio';
322
+ const measure = timeBased ? (durationLabel(item.durationSeconds) ?? size ?? 'โ€”') : size;
323
+ return tags && measure ? `${tags} ยท ${measure}` : (tags ?? measure);
324
+ }
325
+
326
+ function GalleryMeta({ item, measure }: { item: MediaGalleryItem; measure?: boolean }) {
327
+ const line = galleryTileLine(item, { measure: measure === true });
143
328
  // The date is its OWN line rather than appended to the one above, because that line is
144
329
  // already whichever of tags/size exists โ€” folding a third fact into it would push the tags
145
330
  // out of a tile caption that is two lines tall by design.
@@ -194,18 +379,34 @@ function TileShell({
194
379
  name,
195
380
  selection,
196
381
  drag,
382
+ stageMark,
197
383
  children,
198
384
  }: {
199
385
  name: string;
200
386
  selection?: TileSelection;
201
387
  drag?: TileDrag;
388
+ /**
389
+ * The mark for the tile the STAGE is showing โ€” `'Now playing'` on a film, `'On screen'` on
390
+ * a still. Absent on every other tile, and absent entirely in the browse grid, where
391
+ * nothing is on a stage.
392
+ *
393
+ * ๐Ÿ”ด A STATE on the tile, not a second control. The tile that is playing stays exactly the
394
+ * tile it was โ€” same position, same size, same click โ€” and gains a ring and one capsule
395
+ * (DESIGN.md ยง3). `aria-current` is what carries the same fact to a screen reader, because
396
+ * a ring drawn by a stylesheet says nothing to one.
397
+ */
398
+ stageMark?: string;
202
399
  children: ReactNode;
203
400
  }) {
204
401
  const selected = selection?.selected === true;
205
402
  const quiet = selection !== undefined && !selection.armed;
206
403
  return (
207
404
  <div
208
- className={`cbmg-selectable${selected ? ' is-selected' : ''}${quiet ? ' cbmg-selectable--quiet' : ''}`}
405
+ className={`cbmg-selectable${selected ? ' is-selected' : ''}${stageMark ? ' is-playing' : ''}${quiet ? ' cbmg-selectable--quiet' : ''}`}
406
+ // The hook the reveal below finds the tile by โ€” see `MediaGallery`'s `revealPlaying`.
407
+ // An attribute rather than the class, so the query cannot be broken by a styling change.
408
+ data-playing={stageMark ? 'true' : undefined}
409
+ aria-current={stageMark ? 'true' : undefined}
209
410
  // ๐Ÿ”ด The DRAGGABLE element is this wrapper, not the tile inside it. Every tile branch
210
411
  // is a <button>, and a draggable button is a browser-by-browser coin toss: some start
211
412
  // a drag, some treat the pointer-down as a press and never fire dragstart at all.
@@ -220,6 +421,14 @@ function TileShell({
220
421
  : {})}
221
422
  >
222
423
  {children}
424
+ {/* Real text, never `aria-hidden`: "the one that is playing" is the whole point of the
425
+ mark, so it has to be readable rather than only visible. The glyph beside it is the
426
+ decoration, and it is the only part hidden. */}
427
+ {stageMark ? (
428
+ <span className='cbmg-playing-chip'>
429
+ <span aria-hidden='true'>โ–ถ</span> {stageMark}
430
+ </span>
431
+ ) : null}
223
432
  {selection ? (
224
433
  <SelectBox name={name} selected={selected} onToggle={selection.onToggle} />
225
434
  ) : null}
@@ -266,6 +475,8 @@ function GalleryTile({
266
475
  selection,
267
476
  drag,
268
477
  autoPreview,
478
+ measure,
479
+ stageMark,
269
480
  }: {
270
481
  item: MediaGalleryItem;
271
482
  cache: MediaCache;
@@ -274,6 +485,10 @@ function GalleryTile({
274
485
  drag?: TileDrag;
275
486
  /** Preview every video tile without hovering it โ€” see `readThumbAutoplay`. */
276
487
  autoPreview?: boolean;
488
+ /** Lead the caption with a running time / a size โ€” see {@link galleryTileLine}. */
489
+ measure?: boolean;
490
+ /** See {@link TileShell}'s `stageMark` โ€” the label for the tile that is ON the stage. */
491
+ stageMark?: string;
277
492
  }) {
278
493
  /*
279
494
  * ๐Ÿ”ด ALWAYS wrapped, even with no selection and no drag.
@@ -290,8 +505,14 @@ function GalleryTile({
290
505
  * handlers, so this costs nothing and cannot change behavior anywhere else.
291
506
  */
292
507
  return (
293
- <TileShell name={item.name} selection={selection} drag={drag}>
294
- <RawGalleryTile item={item} cache={cache} onOpen={onOpen} autoPreview={autoPreview} />
508
+ <TileShell name={item.name} selection={selection} drag={drag} stageMark={stageMark}>
509
+ <RawGalleryTile
510
+ item={item}
511
+ cache={cache}
512
+ onOpen={onOpen}
513
+ autoPreview={autoPreview}
514
+ measure={measure}
515
+ />
295
516
  </TileShell>
296
517
  );
297
518
  }
@@ -353,11 +574,13 @@ function RawGalleryTile({
353
574
  cache,
354
575
  onOpen,
355
576
  autoPreview,
577
+ measure,
356
578
  }: {
357
579
  item: MediaGalleryItem;
358
580
  cache: MediaCache;
359
581
  onOpen: () => void;
360
582
  autoPreview?: boolean;
583
+ measure?: boolean;
361
584
  }) {
362
585
  const category = galleryItemCategory(item);
363
586
  const { media } = useItemMedia(item, cache);
@@ -383,7 +606,7 @@ function RawGalleryTile({
383
606
  // whose detail view is upright, which reads as the fix not having worked.
384
607
  {...(item.orientation ? { orientation: item.orientation } : {})}
385
608
  >
386
- <GalleryMeta item={item} />
609
+ <GalleryMeta item={item} measure={measure} />
387
610
  </HoverScrubVideoThumb>
388
611
  );
389
612
  }
@@ -430,7 +653,7 @@ function RawGalleryTile({
430
653
  />
431
654
  </div>
432
655
  )}
433
- <GalleryMeta item={item} />
656
+ <GalleryMeta item={item} measure={measure} />
434
657
  </button>
435
658
  );
436
659
  }
@@ -582,7 +805,7 @@ export function stagePlayableVideo(
582
805
  item: MediaGalleryItem,
583
806
  media: MediaGalleryItemMedia,
584
807
  streamUrl: string,
585
- ): PlayableVideo {
808
+ ): ChapteredVideo {
586
809
  return {
587
810
  id: item.id,
588
811
  name: item.name,
@@ -602,9 +825,51 @@ export function stagePlayableVideo(
602
825
  // broken one.
603
826
  ...(media.vttUrl ? { vttUrl: media.vttUrl } : {}),
604
827
  ...(media.processingStatus ? { processingStatus: media.processingStatus } : {}),
828
+ // The named sections, straight off the ITEM rather than out of `media`: chapters are a
829
+ // property of the film, not of a resolved URL, and they are the same list whichever
830
+ // transport the stage ends up playing. Absent when there are none, so a video with no
831
+ // sections hands the player nothing to draw a chapters menu for.
832
+ ...(item.chapters && item.chapters.length > 0 ? { chapters: item.chapters } : {}),
605
833
  };
606
834
  }
607
835
 
836
+ /**
837
+ * ๐Ÿ”ด The stage's session volume, on the NATIVE transport.
838
+ *
839
+ * The Vidstack stage gets this from `VideoPlayer` (`volumeKey`, read off the player's own
840
+ * `volume-change`); this is the same rule for the plain `<video>` an app opts into with
841
+ * `nativeVideoStage`. Same {@link STAGE_VOLUME_KEY}, because it is the same surface โ€” the two
842
+ * transports are never mounted at once, and keying them apart would mean an app that switched
843
+ * transports lost the level mid-session for no reason a person could name.
844
+ *
845
+ * Owner, `ideas/collections-09-14.md`: *"Keep the media player volume where I set it after
846
+ * changing it during a session except if I refresh or first log in then continue starting it
847
+ * at mute. Then if I unmute it, keep that value as I change to other videos."*
848
+ *
849
+ * ๐Ÿ”ด The `else` branch is not a no-op: this element carries `autoPlay` and no `muted`, so the
850
+ * fresh-session case starts it silent โ€” which is both what he asked for and the only state a
851
+ * browser will actually auto-play. The native control bar is right there to unmute.
852
+ */
853
+ function useStageVolume(ref: React.RefObject<HTMLVideoElement | null>): void {
854
+ React.useEffect(() => {
855
+ const el = ref.current;
856
+ if (!el) return;
857
+ const stored = readSessionVolume(STAGE_VOLUME_KEY);
858
+ el.volume = stored?.volume ?? 1;
859
+ el.muted = stored?.muted ?? true;
860
+ // What we just applied โ€” the element fires `volumechange` for it, and writing that back
861
+ // would mint a preference nobody set. Only what he does after this is his.
862
+ let applied = { volume: el.volume, muted: el.muted };
863
+ const onChange = () => {
864
+ if (el.volume === applied.volume && el.muted === applied.muted) return;
865
+ applied = { volume: el.volume, muted: el.muted };
866
+ writeSessionVolume(STAGE_VOLUME_KEY, applied);
867
+ };
868
+ el.addEventListener('volumechange', onChange);
869
+ return () => el.removeEventListener('volumechange', onChange);
870
+ }, [ref]);
871
+ }
872
+
608
873
  /**
609
874
  * The `nativeVideoStage` element โ€” a plain `<video>` with the ladder attached to it.
610
875
  *
@@ -632,6 +897,7 @@ function NativeVideoStage({
632
897
  }) {
633
898
  const ref = React.useRef<HTMLVideoElement>(null);
634
899
  useHlsSource(ref, { hlsUrl, streamUrl });
900
+ useStageVolume(ref);
635
901
  return (
636
902
  // biome-ignore lint/a11y/useMediaCaption: arbitrary user-stored files carry no captions
637
903
  <video
@@ -740,6 +1006,10 @@ function GalleryStage({
740
1006
  <LazyVideoPlayer
741
1007
  video={stagePlayableVideo(item, media, src)}
742
1008
  autoPlay
1009
+ // ๐Ÿ”ด The STAGE's own volume memory, not the shared primitive's default key โ€” see
1010
+ // `playbackPreferences.ts`. It is what makes the level he set on this clip apply to
1011
+ // the next one, and it is per session, so a refresh is silent again.
1012
+ volumeKey={STAGE_VOLUME_KEY}
743
1013
  loop={repeat}
744
1014
  {...(repeat ? {} : { onEnded })}
745
1015
  {...(onStep ? { onStep, canStep: canStep !== false } : {})}
@@ -1068,6 +1338,57 @@ export interface MediaGalleryProps {
1068
1338
  * somebody the minutes of scrubbing that produced the list.
1069
1339
  */
1070
1340
  onTrimVideo?: (item: MediaGalleryItem, cuts: VideoCut[]) => void | Promise<void>;
1341
+ /**
1342
+ * Store the named sections of a video โ€” YouTube chapters.
1343
+ *
1344
+ * The owner's ask, 2026-09-15: *"allow putting markers on videos at points in time and giving
1345
+ * them a title for the section for knowing what that part of the video is about, like youtube
1346
+ * chapters."* So this is a SAVE, not a request: the list applies the moment it comes back on
1347
+ * {@link MediaGalleryItem.chapters}, costs no encode, and touches no byte of the file.
1348
+ *
1349
+ * ๐Ÿ”ด Passing this is what turns authoring on, per app โ€” the same rule as `onTrimVideo`, and
1350
+ * for a different reason worth stating: chapters have somewhere to GO only in an app that
1351
+ * keeps a row per file it can write to. An app that merely lists someone else's videos must
1352
+ * not offer a control that saves into nothing, and because the editor is lazily imported, an
1353
+ * app that does not pass this does not ship the dialog either.
1354
+ *
1355
+ * ๐Ÿ”ด READING chapters needs nothing: an item that carries `chapters` shows them on the scrub
1356
+ * bar whether or not this is passed. The capability being gated is writing them.
1357
+ *
1358
+ * An EMPTY list is how every section is removed. Rejecting keeps the dialog open with the
1359
+ * names intact, because a failed request must not cost somebody the minutes of scrubbing and
1360
+ * naming that produced them.
1361
+ */
1362
+ onSaveChapters?: (item: MediaGalleryItem, chapters: VideoChapter[]) => void | Promise<void>;
1363
+ /**
1364
+ * Store TAGS and a DESCRIPTION โ€” on the open file, or on everything that is ticked.
1365
+ *
1366
+ * The owner's ask, `ideas/collections-09-14.md`, 2026-09-14: *"In media galleries have an
1367
+ * option for apps to tag media and aadd descriptions to media. In collections allow these
1368
+ * features so I can tag and add descriptions to its media."* The gallery could already READ
1369
+ * `tags` โ€” the tile prints the first three and the chips filter on them โ€” and had no way to
1370
+ * write one and no `description` field at all, so an app could display a vocabulary it
1371
+ * already had and nothing in here could create a word of it.
1372
+ *
1373
+ * ๐Ÿ”ด Passing this is what turns authoring on, per app โ€” the same rule as `onTrimVideo`, and
1374
+ * for the same two reasons: an app that cannot persist a tag must not offer a control that
1375
+ * saves into nothing, and because the editor is lazily imported, an app that does not pass
1376
+ * this does not ship the dialog either. READING needs nothing, exactly as chapters do.
1377
+ *
1378
+ * ๐Ÿ”ด The first argument is a LIST, always โ€” one item is `[item]`. Tagging forty holiday
1379
+ * photographs at once is the case he will actually hit, and a plural verb that a consumer
1380
+ * can only implement by looping the singular is the shape `tasks/Completed/240-*.md`
1381
+ * refuses in as many words: *"One request for N ids, not N requests."* The change arrives
1382
+ * as `addTags`/`removeTags` rather than as a new list for exactly this reason โ€” forty items
1383
+ * do not share one tag list, so "the new tags" would wipe thirty-nine nobody looked at.
1384
+ * `applyMetaChange` (exported from this subpath) is the merge, so no app writes it twice.
1385
+ *
1386
+ * Rejecting keeps the dialog open with the draft intact.
1387
+ */
1388
+ onSaveMeta?: (
1389
+ items: MediaGalleryItem[],
1390
+ change: MediaMetaChange,
1391
+ ) => void | Promise<void>;
1071
1392
  /**
1072
1393
  * What happens after a trim is queued, in the app's own words โ€” rendered under the strip.
1073
1394
  * Apps differ: one runs it on the spot, another drains a queue on a schedule, and "when
@@ -1081,7 +1402,7 @@ export interface MediaGalleryProps {
1081
1402
  */
1082
1403
  allowPictureReplace?: boolean;
1083
1404
  /**
1084
- * The โ˜€ Picture control โ€” brightness, contrast and colour for what is on the stage. On by
1405
+ * The Picture control โ€” brightness, contrast and colour for what is on the stage. On by
1085
1406
  * default, because it needs nothing from the app: it is a CSS filter over the stage and
1086
1407
  * nothing is saved, so there is no capability to be missing.
1087
1408
  *
@@ -1090,6 +1411,33 @@ export interface MediaGalleryProps {
1090
1411
  * deep. Pass `false` in an app where adjusting the picture is meaningless.
1091
1412
  */
1092
1413
  pictureControls?: boolean;
1414
+ /**
1415
+ * What the tiles and the table say about SIZE and LENGTH โ€” the one param he guessed at.
1416
+ *
1417
+ * ๐Ÿ”ด Two asks, one prop, because they are one surface. `ideas/collections-09-14.md`, twice:
1418
+ * *"In collections I want to see the exact file size in bytes as a column option so I can
1419
+ * easily tell if two files are the exact same byte size to help me know if they are
1420
+ * duplicates or not"* and *"in the grid view show the video length instead of the size."*
1421
+ * Then `ideas/binary-server-09-15.md` asked for both facts on the same tiles and named the
1422
+ * right home: *"maybe controlled through param on media gallery in cb."*
1423
+ *
1424
+ * ๐Ÿ”ด Everything here is OFF by default, so a consumer that passes nothing renders exactly
1425
+ * what it renders today โ€” the compatibility rule every other prop on this component holds.
1426
+ */
1427
+ meta?: {
1428
+ /**
1429
+ * A tile's caption leads with the measurement that means something for its kind: the
1430
+ * RUNNING TIME on a film or a track, the size on everything else. Tags keep the line
1431
+ * they had and the measurement joins them on it โ€” see {@link galleryTileLine}.
1432
+ */
1433
+ tileMeasure?: boolean;
1434
+ /**
1435
+ * The table offers the exact byte count as its own column, beside the rounded Size one
1436
+ * rather than instead of it โ€” see `exactBytesLabel` in `GalleryTable.tsx`. A rounded size cannot answer
1437
+ * *"are these two the same file"*, which is the job he named for it.
1438
+ */
1439
+ exactBytes?: boolean;
1440
+ };
1093
1441
  }
1094
1442
 
1095
1443
  /**
@@ -1136,7 +1484,10 @@ export function MediaGallery({
1136
1484
  allowPictureReplace = false,
1137
1485
  pictureControls = true,
1138
1486
  onTrimVideo,
1487
+ onSaveChapters,
1488
+ onSaveMeta,
1139
1489
  trimNote,
1490
+ meta,
1140
1491
  }: MediaGalleryProps) {
1141
1492
  const [uncontrolledQuery, setUncontrolledQuery] = useState('');
1142
1493
  /** Controlled when the consumer passes `query`, uncontrolled otherwise โ€” see the prop. */
@@ -1350,6 +1701,17 @@ export function MediaGallery({
1350
1701
  const [turningVideo, setTurningVideo] = useState<MediaGalleryItem | null>(null);
1351
1702
  /** The clip whose cut marks are being made, or null. See `onTrimVideo`. */
1352
1703
  const [trimmingVideo, setTrimmingVideo] = useState<MediaGalleryItem | null>(null);
1704
+ /** The clip whose sections are being named, or null. See `onSaveChapters`. */
1705
+ const [chapteringVideo, setChapteringVideo] = useState<MediaGalleryItem | null>(null);
1706
+ /**
1707
+ * What the tag/description editor is open over, or null. See `onSaveMeta`.
1708
+ *
1709
+ * ๐Ÿ”ด A LIST rather than one item, because this is the one capability dialog reachable from
1710
+ * BOTH screens: the open file's strip passes `[selected]`, and the bulk bar passes the whole
1711
+ * ticked set. One piece of state for both is what keeps the two paths from becoming two
1712
+ * dialogs that drift โ€” and it is why `items` is the editor's prop rather than `item`.
1713
+ */
1714
+ const [taggingItems, setTaggingItems] = useState<MediaGalleryItem[] | null>(null);
1353
1715
  const [grabbing, setGrabbing] = useState<{ item: MediaGalleryItem; at: number } | null>(null);
1354
1716
  const [showPicture, setShowPicture] = useState(false);
1355
1717
  const [light, setLight] = useState<PictureLight>(NO_LIGHT);
@@ -1361,8 +1723,26 @@ export function MediaGallery({
1361
1723
  // biome-ignore lint/correctness/useExhaustiveDependencies: explained immediately above
1362
1724
  useEffect(() => {
1363
1725
  setRenaming(null);
1364
- setEditingPicture(null);
1365
1726
  setLight(NO_LIGHT);
1727
+ /*
1728
+ * ๐Ÿ”ด EVERY capability dialog, not just the picture editor โ€” the owner, 2026-09-14, on the
1729
+ * thumbnail chooser in `apps/collections`: *"Clicking on other folders also did not move
1730
+ * to view them and the thumbnail selection screen for the first video stayed on the
1731
+ * screen. Selecting other sidebar files and icons should also leave that screen."*
1732
+ *
1733
+ * Only `editingPicture` was cleared here, so a dialog opened against the file you WERE
1734
+ * looking at outlived the selection and then described the wrong file: its title, its
1735
+ * `src`, its `startAt` and โ€” the part that loses work โ€” the item every save handler above
1736
+ * is closed over. The fix is one line per dialog rather than a rule in a doc comment,
1737
+ * because "remember to clear yours" is exactly the instruction the four dialogs added
1738
+ * after this one would not have read.
1739
+ */
1740
+ setEditingPicture(null);
1741
+ setTurningVideo(null);
1742
+ setTrimmingVideo(null);
1743
+ setChapteringVideo(null);
1744
+ setTaggingItems(null);
1745
+ setGrabbing(null);
1366
1746
  }, [selectedId]);
1367
1747
 
1368
1748
  // ๐Ÿ”ด Enabled whenever there is somewhere else to go, NOT only away from the edges โ€” the
@@ -1504,6 +1884,78 @@ export function MediaGallery({
1504
1884
  el.scrollIntoView({ behavior: reduced || snapsToStage(selected) ? 'auto' : 'smooth', block: 'start' });
1505
1885
  }, [selectedId, selected]);
1506
1886
 
1887
+ /**
1888
+ * โ”€โ”€ Bring the tile the stage is showing back into view โ€” the strip's REVEAL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1889
+ *
1890
+ * Owner, `ideas/collections-09-14.md`: *"I want to โ€ฆ still see the playing video in the
1891
+ * list."* Keeping it IN the list (the strip above) is only half of that: on a folder of
1892
+ * ninety files the marked tile is forty rows away, and a mark nobody can find is a mark
1893
+ * nobody has. This is `file-tree/FileTree.tsx`'s reveal, applied to the same problem, and
1894
+ * it keeps that one's limits:
1895
+ *
1896
+ * 1. **Only when the tile is off screen.** A strip that jumps a few pixels every time you
1897
+ * click a tile you can already see reads as a bug.
1898
+ * 2. **Never mid-drag** โ€” vacuous here rather than skipped: the strip's tiles are handed no
1899
+ * `drag` prop at all (see `renderTile` below), so there is no drag of ours to be in the
1900
+ * middle of. `MediaGallery.spec.tsx` asserts that, so a future strip that DOES become a
1901
+ * drag source fails a test pointing at this note instead of silently yanking the list
1902
+ * out from under a pointer.
1903
+ *
1904
+ * ๐Ÿ”ด โ€ฆand one limit the tree has no need of: it runs only while the stage is PINNED.
1905
+ *
1906
+ * The watch view is ONE scroller โ€” stage on top, strip underneath โ€” so scrolling the strip
1907
+ * scrolls the stage away. The owner has already ruled on that collision, from the other
1908
+ * side: *"when I scroll down through thumbnails and click one to play it then I want to
1909
+ * navigate automatically back to the top to see the movie I am playing"* (2026-08-14, the
1910
+ * effect directly above). Revealing the tile in that mode would undo, every single time,
1911
+ * the thing that effect exists to do โ€” the film would be playing off the top of the screen
1912
+ * again, which is the older report re-opened.
1913
+ *
1914
+ * Pinning is his own answer to it: *"a button to lock the video in place on top so I can
1915
+ * scroll thumbnails and still see the full video."* With the top region sticky the stage
1916
+ * cannot scroll away, the effect above finds it already in place and moves nothing, and the
1917
+ * strip's scroll is free โ€” so that is exactly the mode where this reveal is not taking
1918
+ * something away to give something else.
1919
+ *
1920
+ * ๐Ÿ”ด It scrolls by a computed delta rather than by `scrollIntoView`, and the sticky region
1921
+ * is why: `block: 'nearest'` on a tile ABOVE the fold aligns it to the top of the scroller,
1922
+ * which is the seventy percent of it the pinned stage is sitting on โ€” a "reveal" that puts
1923
+ * the tile behind the video. The visible band starts where the pinned region ends.
1924
+ */
1925
+ // 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.
1926
+ useEffect(() => {
1927
+ const stage = stageRef.current;
1928
+ if (!selectedId || !pinStage || !stage) return;
1929
+ const scroller = stage.closest<HTMLElement>('.cbgd-watch');
1930
+ // The strip's own marked element, in whichever view is on: a tile shell in the grid, a
1931
+ // row in the table. Both carry `data-playing` for exactly this.
1932
+ const mark = scroller?.querySelector<HTMLElement>('[data-playing]');
1933
+ if (!scroller || !mark) return;
1934
+ // The table scrolls inside its own frame (`.cbgt-root`), where the stage is not even a
1935
+ // sibling; the tile grid windows against the watch scroller itself.
1936
+ const frame = mark.closest<HTMLElement>('.cbgt-root') ?? scroller;
1937
+ const view = frame.getBoundingClientRect();
1938
+ // Where the pinned region stops covering the strip. Absent (or not pinned) โ†’ the frame's
1939
+ // own top edge, which is what the table's separate scroller always uses.
1940
+ const pinned = frame === scroller ? scroller.querySelector('.cbgd-watch-top') : null;
1941
+ const top = pinned ? Math.max(view.top, pinned.getBoundingClientRect().bottom) : view.top;
1942
+ const box = mark.getBoundingClientRect();
1943
+ const delta =
1944
+ box.top < top ? box.top - top : box.bottom > view.bottom ? box.bottom - view.bottom : 0;
1945
+ // Limit 1: already in the band, so nothing moves.
1946
+ if (delta === 0) return;
1947
+ frame.scrollTop += delta;
1948
+ }, [selectedId]);
1949
+
1950
+ /**
1951
+ * Is one of the capability dialogs on screen? Every one of them is a focus-trapped modal
1952
+ * over the gallery, so for as long as one is up the window-level shortcuts below are not
1953
+ * the gallery's to take โ€” see the guard in the effect that follows.
1954
+ */
1955
+ const capabilityDialogOpen = Boolean(
1956
+ editingPicture || turningVideo || trimmingVideo || chapteringVideo || grabbing,
1957
+ );
1958
+
1507
1959
  /**
1508
1960
  * Arrow keys step, Escape closes.
1509
1961
  *
@@ -1511,9 +1963,22 @@ export function MediaGallery({
1511
1963
  * the thing the owner just clicked and it owns focus โ€” a handler on an ancestor div only
1512
1964
  * fires when focus happens to be outside the player, which is the state a person watching a
1513
1965
  * video is never in. The typing guard below is what makes a window binding safe.
1966
+ *
1967
+ * โ”€โ”€ ๐Ÿ”ด โ€ฆand it stands DOWN while a capability dialog is open โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1968
+ * The owner, 2026-09-14, on the thumbnail chooser: *"the space bar should toggle playing
1969
+ * the video no matter what is focused but isn't working on the thumbnail selection page."*
1970
+ *
1971
+ * It was firing โ€” on the wrong film. This handler binds at CAPTURE on `window`, so a press
1972
+ * inside an open dialog reached it first and toggled the STAGE player behind the dialog,
1973
+ * which is a video the reader cannot see. `FrameGrabber` now binds the same fleet rule
1974
+ * (`usePlayPauseSpace`) to its own `<video>`, and two capture listeners on one window both
1975
+ * "working" is how a single press pauses two different films โ€” `stopPropagation` does not
1976
+ * help, because it does not stop other listeners on the same target. So the gallery yields
1977
+ * the key instead: whoever is on top owns it. The same guard is what stops Escape closing
1978
+ * the gallery's selection out from under a dialog that Radix was about to close on its own.
1514
1979
  */
1515
1980
  useEffect(() => {
1516
- if (!selectedId) return;
1981
+ if (!selectedId || capabilityDialogOpen) return;
1517
1982
  const onKey = (event: KeyboardEvent) => {
1518
1983
  /*
1519
1984
  * ๐Ÿ”ด Space is play/pause, and it is decided BEFORE the typing guard below โ€” which is
@@ -1668,11 +2133,9 @@ export function MediaGallery({
1668
2133
  />
1669
2134
  ) : null}
1670
2135
  {turningVideo ? (
1671
- <LazyVideoTurner
1672
- src={turningVideo.streamUrl ?? turningVideo.url ?? ''}
1673
- {...(turningVideo.posterUrl ? { posterUrl: turningVideo.posterUrl } : {})}
1674
- name={turningVideo.name}
1675
- orientation={turningVideo.orientation}
2136
+ <TurnVideoDialog
2137
+ item={turningVideo}
2138
+ cache={mediaCache}
1676
2139
  onClose={() => setTurningVideo(null)}
1677
2140
  onSave={async (next: MediaOrientation | undefined) => {
1678
2141
  await onSaveOrientation?.(turningVideo, next);
@@ -1692,6 +2155,32 @@ export function MediaGallery({
1692
2155
  }}
1693
2156
  />
1694
2157
  ) : null}
2158
+ {chapteringVideo && onSaveChapters ? (
2159
+ <LazyVideoChapterEditor
2160
+ src={chapteringVideo.streamUrl ?? chapteringVideo.url ?? ''}
2161
+ {...(chapteringVideo.posterUrl ? { posterUrl: chapteringVideo.posterUrl } : {})}
2162
+ name={chapteringVideo.name}
2163
+ durationSeconds={chapteringVideo.durationSeconds ?? null}
2164
+ {...(chapteringVideo.chapters ? { chapters: chapteringVideo.chapters } : {})}
2165
+ onClose={() => setChapteringVideo(null)}
2166
+ onSubmit={async (chapters: VideoChapter[]) => {
2167
+ await onSaveChapters(chapteringVideo, chapters);
2168
+ }}
2169
+ />
2170
+ ) : null}
2171
+ {taggingItems && taggingItems.length > 0 && onSaveMeta ? (
2172
+ <LazyMediaMetaEditor
2173
+ items={taggingItems}
2174
+ // The album's own vocabulary, which is already computed for the filter chips โ€”
2175
+ // offering it is what stops `holiday`, `Holiday` and `holidays` being minted as
2176
+ // three chips that each find a third of the set.
2177
+ suggestions={tags}
2178
+ onClose={() => setTaggingItems(null)}
2179
+ onSubmit={async (change: MediaMetaChange) => {
2180
+ await onSaveMeta(taggingItems, change);
2181
+ }}
2182
+ />
2183
+ ) : null}
1695
2184
  {grabbing ? (
1696
2185
  <LazyFrameGrabber
1697
2186
  src={grabbing.item.streamUrl ?? grabbing.item.url ?? ''}
@@ -1718,8 +2207,37 @@ export function MediaGallery({
1718
2207
 
1719
2208
  if (selected) {
1720
2209
  const category = galleryItemCategory(selected);
1721
- const others = displayItems.filter((i) => i.id !== selected.id);
2210
+ /*
2211
+ * โ”€โ”€ ๐Ÿ”ด The strip under the stage is EVERY displayed item, the open one included โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2212
+ *
2213
+ * Owner, `ideas/collections-09-14.md`: *"sometimes I click on a video and while it's
2214
+ * playing I click on a different video in the thumbnails under the playing video and the
2215
+ * video I click on gets taken out of the list. I want to change this cb media gallery
2216
+ * behavior so that I can still see the playing video in the list and nothing about the
2217
+ * list changes as I pick other videos."*
2218
+ *
2219
+ * This line was `displayItems.filter((i) => i.id !== selected.id)` โ€” the strip was
2220
+ * everything EXCEPT what was playing. So every pick removed one item and put the previous
2221
+ * one back, and because the two sit at different places in the sort, every tile between
2222
+ * them shifted by one: the list re-sorted itself under his pointer on every click, which
2223
+ * is what *"the video I click on gets taken out of the list"* is describing. His
2224
+ * requirement is stated twice and is the exact opposite, so there is no filter here at
2225
+ * all now โ€” the strip is `displayItems`, in `displayItems`' order, and the open one is
2226
+ * MARKED (`stageMark` below) instead of removed.
2227
+ *
2228
+ * ๐Ÿ”ด Nothing downstream ever depended on the exclusion. `step`/โ‡ง-arrows and `advance`
2229
+ * have always run over `displayIds` โ€” the unfiltered displayed order โ€” so their
2230
+ * arithmetic is untouched by this, and `nextInRun` already refuses to draw the current
2231
+ * item as the next one (`playbackRun.ts`, and `MediaGallery.spec.tsx` asserts it from
2232
+ * this edge). What DID depend on it was the heading, which said *"More files"*; with the
2233
+ * open file in the list that word is no longer true, so it is gone too.
2234
+ */
2235
+ const stageMark =
2236
+ category === 'video' || category === 'audio' ? 'Now playing' : 'On screen';
1722
2237
  const downloadHref = selected.downloadUrl ?? selected.url;
2238
+ // Capitalised so JSX reads it as a component rather than an HTML tag โ€” see
2239
+ // `PLAYBACK_END_ICONS` for why the setting gets five glyphs and not one.
2240
+ const PlaybackEndIcon = PLAYBACK_END_ICONS[playbackEnd];
1723
2241
  return (
1724
2242
  <>
1725
2243
  {capabilityDialogs}
@@ -1736,16 +2254,19 @@ export function MediaGallery({
1736
2254
  // The date joins the line rather than replacing what was there โ€” beside the title is
1737
2255
  // exactly where the owner asked for it, and the titlebar has the room a tile caption
1738
2256
  // does not.
1739
- [
1740
- selected.tags && selected.tags.length > 0
1741
- ? selected.tags.join(' ยท ')
1742
- : selected.sizeBytes != null
1743
- ? formatBytes(selected.sizeBytes)
1744
- : null,
1745
- createdLabel(selected.createdAt),
1746
- ]
1747
- .filter(Boolean)
1748
- .join(' ยท ') || undefined
2257
+ stageMeta(
2258
+ [
2259
+ selected.tags && selected.tags.length > 0
2260
+ ? selected.tags.join(' ยท ')
2261
+ : selected.sizeBytes != null
2262
+ ? formatBytes(selected.sizeBytes)
2263
+ : null,
2264
+ createdLabel(selected.createdAt),
2265
+ ]
2266
+ .filter(Boolean)
2267
+ .join(' ยท ') || undefined,
2268
+ selected.description,
2269
+ )
1749
2270
  }
1750
2271
  sticky={pinStage}
1751
2272
  stage={
@@ -1802,6 +2323,11 @@ export function MediaGallery({
1802
2323
  Owner, 2026-08-14: "a button to lock the video in place on top so I can scroll
1803
2324
  thumbnails and still see the full video." Shown for every kind โ€” browsing a
1804
2325
  folder while keeping a photograph on screen is the same need. */}
2326
+ {/* ๐Ÿ”ด `Pin` vs `Pinned` was a READOUT, and one glyph cannot be both. It moves into
2327
+ three places at once: the lit pill (`is-active`, which this control never had
2328
+ โ€” the word was carrying the whole state on its own), `aria-pressed`, and the
2329
+ accessible name, which says which way the press goes. The tooltip below
2330
+ already named both states and is unchanged. */}
1805
2331
  <Tooltip content={
1806
2332
  pinStage
1807
2333
  ? 'Unpin โ€” let the stage scroll away with the page'
@@ -1810,15 +2336,16 @@ export function MediaGallery({
1810
2336
  <button
1811
2337
  // guardrails-ignore no-raw-action-button: see the previous button.
1812
2338
  type='button'
1813
- className='cbgd-order-btn'
2339
+ className={`cbgd-order-btn is-icon${pinStage ? ' is-active' : ''}`}
1814
2340
  aria-pressed={pinStage}
2341
+ aria-label={pinStage ? 'Pinned โ€” unpin the stage' : 'Pin the stage'}
1815
2342
  onClick={() => {
1816
2343
  const next = !pinStage;
1817
2344
  setPinStage(next);
1818
2345
  writePinStage(next);
1819
2346
  }}
1820
2347
  >
1821
- {pinStage ? '๐Ÿ“Œ Pinned' : '๐Ÿ“Œ Pin'}
2348
+ <Pin aria-hidden />
1822
2349
  </button>
1823
2350
  </Tooltip>
1824
2351
  {/* ๐Ÿ”ด Shown for EVERY kind, not only video. Three of the five settings run the
@@ -1826,11 +2353,18 @@ export function MediaGallery({
1826
2353
  gating this on `category === 'video'` meant the owner could not start one from
1827
2354
  a picture, which is the item most folders open on. `playbackPreferences.ts`
1828
2355
  explains why five settings on one button rather than a row of toggles. */}
1829
- <Tooltip content={PLAYBACK_END_LABELS[playbackEnd].hint}>
2356
+ {/* ๐Ÿ”ด The tooltip LEADS with the current value now. It used to carry the hint
2357
+ alone, which was enough while the label was printed on the pill beside it;
2358
+ with the word gone, a hover that explains what the setting does without
2359
+ saying which one is set has dropped the half he was reading. The
2360
+ `aria-label` is unchanged โ€” it always carried the value in full. */}
2361
+ <Tooltip
2362
+ content={`${PLAYBACK_END_LABELS[playbackEnd].label} โ€” ${PLAYBACK_END_LABELS[playbackEnd].hint}`}
2363
+ >
1830
2364
  <button
1831
2365
  // guardrails-ignore no-raw-action-button: see the previous button.
1832
2366
  type='button'
1833
- className={`cbgd-order-btn${playbackEnd === 'stop' ? '' : ' is-active'}`}
2367
+ className={`cbgd-order-btn is-icon${playbackEnd === 'stop' ? '' : ' is-active'}`}
1834
2368
  onClick={() => {
1835
2369
  const next = nextPlaybackEnd(playbackEnd);
1836
2370
  setPlaybackEnd(next);
@@ -1838,7 +2372,7 @@ export function MediaGallery({
1838
2372
  }}
1839
2373
  aria-label={`When an item ends: ${PLAYBACK_END_LABELS[playbackEnd].label}`}
1840
2374
  >
1841
- {PLAYBACK_END_LABELS[playbackEnd].glyph} {PLAYBACK_END_LABELS[playbackEnd].label}
2375
+ <PlaybackEndIcon aria-hidden />
1842
2376
  </button>
1843
2377
  </Tooltip>
1844
2378
  {/* The slideshow speed, and ONLY while a setting that advances is chosen โ€” a
@@ -1847,7 +2381,13 @@ export function MediaGallery({
1847
2381
  {runAdvances(playbackEnd) ? (
1848
2382
  <Tooltip content='How long each picture holds the screen before the next item'>
1849
2383
  <label className='cbgd-dwell'>
1850
- <span className='cbgd-dwell-label'>Hold</span>
2384
+ {/* The word `Hold` was the control's NAME and is now the hourglass; the
2385
+ `5s` beside it is its VALUE and stays printed, because a duration you
2386
+ have to hover to read is a duration you cannot compare. The select
2387
+ keeps its own `aria-label`, so the glyph is decoration. */}
2388
+ <span className='cbgd-dwell-label'>
2389
+ <Timer aria-hidden />
2390
+ </span>
1851
2391
  <select
1852
2392
  // guardrails-ignore no-raw-inputs: `.cbgd-dwell` is this module's own
1853
2393
  // self-contained plain-CSS control, for the same reason `.cbgd-search`
@@ -1927,17 +2467,43 @@ export function MediaGallery({
1927
2467
  // guardrails-ignore no-raw-action-button: action pill styled by the dense
1928
2468
  // gallery's own plain CSS, matching its neighbors.
1929
2469
  type='button'
1930
- className='cbgd-order-btn'
2470
+ className='cbgd-order-btn is-icon'
2471
+ aria-label='Rename'
1931
2472
  onClick={() => {
1932
2473
  setRenameDraft(selected.name);
1933
2474
  setRenaming(selected.id);
1934
2475
  }}
1935
2476
  >
1936
- Rename
2477
+ <Pencil aria-hidden />
1937
2478
  </button>
1938
2479
  </Tooltip>
1939
2480
  )
1940
2481
  ) : null}
2482
+ {/* Rename's neighbour, and for the same reason: both are about what this file IS
2483
+ rather than about the picture on the stage. Offered on EVERY kind โ€” a PDF and a
2484
+ spreadsheet are exactly as worth tagging as a photograph, which is the whole
2485
+ point of a gallery that holds any binary. See `onSaveMeta`. */}
2486
+ {onSaveMeta ? (
2487
+ <Tooltip content='Tag this file and say what it is'>
2488
+ <button
2489
+ // guardrails-ignore no-raw-action-button: see above.
2490
+ type='button'
2491
+ className={`cbgd-order-btn is-icon${
2492
+ (selected.tags && selected.tags.length > 0) || selected.description
2493
+ ? ' is-active'
2494
+ : ''
2495
+ }`}
2496
+ /* Lit when the file already carries one, which is the readout the strip
2497
+ would otherwise have no way to give: "has this been described yet" is the
2498
+ question somebody browsing an untagged album is actually asking, and it
2499
+ is invisible from the stage once the meta line scrolls past. */
2500
+ aria-label='Tags and description'
2501
+ onClick={() => setTaggingItems([selected])}
2502
+ >
2503
+ <Tags aria-hidden />
2504
+ </button>
2505
+ </Tooltip>
2506
+ ) : null}
1941
2507
  {/* Only a video has frames to choose between. An image IS its own thumbnail, and
1942
2508
  offering the picker on one would be a control with nothing to pick. */}
1943
2509
  {(onSaveThumbnail || onSaveSnapshot) && category === 'video' ? (
@@ -1949,7 +2515,19 @@ export function MediaGallery({
1949
2515
  <button
1950
2516
  // guardrails-ignore no-raw-action-button: see above.
1951
2517
  type='button'
1952
- className='cbgd-order-btn'
2518
+ className='cbgd-order-btn is-icon'
2519
+ /* ๐Ÿ”ด The three words this pill used to print, kept verbatim as its name โ€”
2520
+ they are not interchangeable. "Change" vs "Choose" says whether a poster
2521
+ already exists, and "Take a picture" is a DIFFERENT capability (the app
2522
+ wired `onSaveSnapshot` and not `onSaveThumbnail`), which is why the glyph
2523
+ changes with it: a camera, not a picture frame. */
2524
+ aria-label={
2525
+ onSaveThumbnail
2526
+ ? selected.posterUrl
2527
+ ? 'Change thumbnail'
2528
+ : 'Choose thumbnail'
2529
+ : 'Take a picture'
2530
+ }
1953
2531
  onClick={() => {
1954
2532
  /*
1955
2533
  * ๐Ÿ”ด Carry the PLAYHEAD across, read off the DOM rather than plumbed through.
@@ -1966,11 +2544,7 @@ export function MediaGallery({
1966
2544
  setGrabbing({ item: selected, at });
1967
2545
  }}
1968
2546
  >
1969
- {onSaveThumbnail
1970
- ? selected.posterUrl
1971
- ? '๐Ÿ–ผ Change thumbnail'
1972
- : '๐Ÿ–ผ Choose thumbnail'
1973
- : '๐Ÿ“ท Take a picture'}
2547
+ {onSaveThumbnail ? <ImageIcon aria-hidden /> : <Camera aria-hidden />}
1974
2548
  </button>
1975
2549
  </Tooltip>
1976
2550
  ) : null}
@@ -1979,12 +2553,17 @@ export function MediaGallery({
1979
2553
  <button
1980
2554
  // guardrails-ignore no-raw-action-button: see above.
1981
2555
  type='button'
1982
- className='cbgd-order-btn'
2556
+ className='cbgd-order-btn is-icon'
2557
+ aria-label='Edit picture'
1983
2558
  onClick={() => setEditingPicture(selected)}
1984
2559
  // The commonest press by far is one turn on a scan or a phone photograph stored
1985
2560
  // a quarter turn from how it should read, which is why the tooltip leads on it.
1986
2561
  >
1987
- โŸณ Edit picture
2562
+ {/* A crop frame rather than the `โŸณ` this printed: the pill opens the whole
2563
+ picture editor (turn, mirror, crop, re-light), and a rotate arrow here
2564
+ would be the same glyph as the video's turn button one row down while
2565
+ meaning something four times larger. */}
2566
+ <Crop aria-hidden />
1988
2567
  </button>
1989
2568
  </Tooltip>
1990
2569
  ) : null}
@@ -1996,10 +2575,11 @@ export function MediaGallery({
1996
2575
  <button
1997
2576
  // guardrails-ignore no-raw-action-button: see above.
1998
2577
  type='button'
1999
- className='cbgd-order-btn'
2578
+ className='cbgd-order-btn is-icon'
2579
+ aria-label='Turn video'
2000
2580
  onClick={() => setTurningVideo(selected)}
2001
2581
  >
2002
- โŸณ Turn video
2582
+ <RotateCw aria-hidden />
2003
2583
  </button>
2004
2584
  </Tooltip>
2005
2585
  ) : null}
@@ -2008,10 +2588,27 @@ export function MediaGallery({
2008
2588
  <button
2009
2589
  // guardrails-ignore no-raw-action-button: see above.
2010
2590
  type='button'
2011
- className='cbgd-order-btn'
2591
+ className='cbgd-order-btn is-icon'
2592
+ aria-label='Trim'
2012
2593
  onClick={() => setTrimmingVideo(selected)}
2013
2594
  >
2014
- โœ‚ Trim
2595
+ <Scissors aria-hidden />
2596
+ </button>
2597
+ </Tooltip>
2598
+ ) : null}
2599
+ {/* Trim's neighbour, and its opposite: the scissors mark what goes, this marks
2600
+ what a part IS and keeps it. Offered on video only, and only where the app can
2601
+ store the answer โ€” see `onSaveChapters`. */}
2602
+ {onSaveChapters && category === 'video' ? (
2603
+ <Tooltip content='Name the parts of this film โ€” the player shows them on the scrub bar'>
2604
+ <button
2605
+ // guardrails-ignore no-raw-action-button: see above.
2606
+ type='button'
2607
+ className='cbgd-order-btn is-icon'
2608
+ aria-label='Sections'
2609
+ onClick={() => setChapteringVideo(selected)}
2610
+ >
2611
+ <ListVideo aria-hidden />
2015
2612
  </button>
2016
2613
  </Tooltip>
2017
2614
  ) : null}
@@ -2020,13 +2617,21 @@ export function MediaGallery({
2020
2617
  <button
2021
2618
  // guardrails-ignore no-raw-action-button: see above.
2022
2619
  type='button'
2023
- className={`cbgd-order-btn${showPicture || isLightAdjusted(light) ? ' is-active' : ''}`}
2620
+ className={`cbgd-order-btn is-icon${showPicture || isLightAdjusted(light) ? ' is-active' : ''}`}
2024
2621
  aria-pressed={showPicture}
2622
+ /* ๐Ÿ”ด The trailing `ยท` this used to print was the one thing on the pill that
2623
+ was not a name: it meant "you have already moved these sliders", which is
2624
+ the difference between a stage that looks wrong and a stage you made look
2625
+ that way. The dot has nowhere to go on a bare glyph, so it becomes a word
2626
+ in the accessible name โ€” the lit pill already says it to the eye, because
2627
+ `is-active` is held whenever the light is adjusted even with the sliders
2628
+ put away. */
2629
+ aria-label={isLightAdjusted(light) ? 'Picture โ€” adjusted' : 'Picture'}
2025
2630
  onClick={() => setShowPicture((on) => !on)}
2026
2631
  // Offered for pictures as well as video: a dark photograph is exactly as hard
2027
2632
  // to look at as a dark clip, and the stage renders both.
2028
2633
  >
2029
- โ˜€ Picture{isLightAdjusted(light) ? ' ยท' : ''}
2634
+ <Sun aria-hidden />
2030
2635
  </button>
2031
2636
  </Tooltip>
2032
2637
  ) : null}
@@ -2072,39 +2677,58 @@ export function MediaGallery({
2072
2677
  {downloadHref ? (
2073
2678
  <Tooltip content={`Download ${selected.name}`}>
2074
2679
  <a
2075
- className='cbgd-order-btn'
2680
+ className='cbgd-order-btn is-icon'
2076
2681
  href={downloadHref}
2077
2682
  download={selected.name}
2683
+ // A link, so it has no `aria-pressed` to carry state and needs none โ€” but it
2684
+ // does need a name, and the file it fetches is the interesting half.
2685
+ aria-label={`Download ${selected.name}`}
2078
2686
  >
2079
- โฌ‡ Download
2687
+ <Download aria-hidden />
2080
2688
  </a>
2081
2689
  </Tooltip>
2082
2690
  ) : null}
2083
2691
  {onDelete ? (
2692
+ /* `iconOnly` is `DeleteButton`'s OWN prop, not a second icon convention
2693
+ invented here โ€” it draws the trash glyph and keeps the confirmation gate
2694
+ that is the component's whole reason to exist. `aria-label` is explicit
2695
+ because the children it would otherwise name itself from are gone. */
2084
2696
  <DeleteButton
2697
+ iconOnly
2698
+ /* Not the bare word "Delete": that is the CONFIRMATION dialog's button, and
2699
+ two controls with one name is a query โ€” a person's or a test's โ€” that
2700
+ cannot say which it means. */
2701
+ aria-label='Delete this item'
2085
2702
  itemLabel={CATEGORY_LABEL[category].toLowerCase()}
2086
2703
  tooltip='Delete this item'
2087
2704
  onDelete={async () => {
2088
2705
  await onDelete(selected);
2089
2706
  select(null);
2090
2707
  }}
2091
- >
2092
- โœ• Delete
2093
- </DeleteButton>
2708
+ />
2094
2709
  ) : null}
2095
- <button
2096
- // guardrails-ignore no-raw-action-button: back-to-grid navigation, styled by
2097
- // the dense gallery's own plain CSS (plugin-media-gallery precedent).
2098
- type='button'
2099
- className='cbgd-order-btn'
2100
- onClick={() => select(null)}
2101
- >
2102
- Back to grid
2103
- </button>
2710
+ <Tooltip content='Close this file and go back to the grid'>
2711
+ <button
2712
+ // guardrails-ignore no-raw-action-button: back-to-grid navigation, styled by
2713
+ // the dense gallery's own plain CSS (plugin-media-gallery precedent).
2714
+ type='button'
2715
+ className='cbgd-order-btn is-icon'
2716
+ aria-label='Back to grid'
2717
+ onClick={() => select(null)}
2718
+ >
2719
+ {/* A back ARROW, not a grid glyph: the grid icon is taken by the view
2720
+ toggle in the header, and two pills drawing the same square while one
2721
+ changes the layout and the other leaves the screen is the ambiguity an
2722
+ icon-only strip is most likely to introduce. */}
2723
+ <ArrowLeft aria-hidden />
2724
+ </button>
2725
+ </Tooltip>
2104
2726
  </>
2105
2727
  }
2106
- relatedLabel='More files'
2107
- itemCount={others.length}
2728
+ /* Not "More files" โ€” the strip holds the open file too now, so "more" would be the
2729
+ heading contradicting the list under it. */
2730
+ relatedLabel='Files'
2731
+ itemCount={displayItems.length}
2108
2732
  loading={loading}
2109
2733
  onNearEnd={onNearEnd}
2110
2734
  minColumnWidth={minColumnWidth}
@@ -2114,14 +2738,17 @@ export function MediaGallery({
2114
2738
  * Owner: *"toggling to rows from thumbnails did not work in the gallery with a video
2115
2739
  * active."* The detail view drew tiles unconditionally, so the control was live, the
2116
2740
  * preference was written, and nothing changed โ€” a pressed button doing provably
2117
- * nothing. It is the same `GalleryTable` the browse view uses, over the same `others`
2118
- * the tiles would have been, so the two screens cannot drift into two tables.
2741
+ * nothing. It is the same `GalleryTable` the browse view uses, over the same items the
2742
+ * tiles would have been, so the two screens cannot drift into two tables.
2119
2743
  */
2120
2744
  {...(viewMode === 'table'
2121
2745
  ? {
2122
2746
  relatedBody: (
2123
2747
  <GalleryTable
2124
- items={others}
2748
+ items={displayItems}
2749
+ // The open file is in this list exactly as it is in the tile strip, so it is
2750
+ // marked here exactly as it is there.
2751
+ playing={{ id: selected.id, label: stageMark }}
2125
2752
  cache={mediaCache}
2126
2753
  order={order}
2127
2754
  onOrderChange={setOrder}
@@ -2134,12 +2761,16 @@ export function MediaGallery({
2134
2761
  table as the browse view's โ€” so leaving it out here would fix the toggle on
2135
2762
  one of the two screens it is wrong on. */
2136
2763
  autoPreview={thumbAutoplay}
2764
+ /* Same argument for the exact-bytes column: this IS the browse view's
2765
+ table, so a column it offered and this one withheld would be the
2766
+ feature vanishing the moment you opened a file. */
2767
+ exactBytes={meta?.exactBytes === true}
2137
2768
  />
2138
2769
  ),
2139
2770
  }
2140
2771
  : {})}
2141
2772
  renderTile={(index) => {
2142
- const item = others[index];
2773
+ const item = displayItems[index];
2143
2774
  return item ? (
2144
2775
  <GalleryTile
2145
2776
  key={item.id}
@@ -2149,6 +2780,14 @@ export function MediaGallery({
2149
2780
  // The toggle means what it says on both screens โ€” "Preview all" that stopped
2150
2781
  // applying the moment you opened something would be a different feature.
2151
2782
  autoPreview={thumbAutoplay}
2783
+ // โ€ฆand so does the running time on a film's caption: the strip under an open
2784
+ // file is the same grid, and a tile that told you its length on one screen and
2785
+ // its size on the other would be two tiles.
2786
+ measure={meta?.tileMeasure === true}
2787
+ // The one tile the stage is showing. Clicking it re-selects what is already
2788
+ // open, which `select` makes a no-op change โ€” it stays a live tile rather than
2789
+ // a dead one, because a tile you cannot press reads as broken.
2790
+ {...(item.id === selected.id ? { stageMark } : {})}
2152
2791
  />
2153
2792
  ) : null;
2154
2793
  }}
@@ -2158,6 +2797,14 @@ export function MediaGallery({
2158
2797
  }
2159
2798
 
2160
2799
  return (
2800
+ <>
2801
+ {/* ๐Ÿ”ด The SAME dialog host as the detail view's, because exactly one capability is
2802
+ reachable from the browse screen: tagging a ticked selection (`onSaveMeta`). Every
2803
+ other dialog here is scoped to an open file and is null on this screen by
2804
+ construction, so rendering the one host in both places is what stops the bulk editor
2805
+ needing a second mount point that the `selectedId` reset would then have to know
2806
+ about separately. */}
2807
+ {capabilityDialogs}
2161
2808
  <DenseGalleryShell<MediaGalleryOrder>
2162
2809
  title={title}
2163
2810
  order={{
@@ -2182,10 +2829,10 @@ export function MediaGallery({
2182
2829
  <fieldset className='cbgd-order' aria-label='How files are shown'>
2183
2830
  {(
2184
2831
  [
2185
- { mode: 'grid', glyph: 'โ–ฆ', label: 'Grid', hint: 'Show files as thumbnails' },
2832
+ { mode: 'grid', Icon: LayoutGrid, label: 'Grid', hint: 'Show files as thumbnails' },
2186
2833
  {
2187
2834
  mode: 'table',
2188
- glyph: 'โ˜ฐ',
2835
+ Icon: Rows3,
2189
2836
  label: 'Table',
2190
2837
  hint: 'Show files as rows with size, length and date',
2191
2838
  },
@@ -2196,11 +2843,15 @@ export function MediaGallery({
2196
2843
  // guardrails-ignore no-raw-action-button: header pill styled by the dense
2197
2844
  // gallery's own plain CSS, matching its sort neighbours.
2198
2845
  type='button'
2199
- className={`cbgd-order-btn${viewMode === option.mode ? ' is-active' : ''}`}
2846
+ className={`cbgd-order-btn is-icon${viewMode === option.mode ? ' is-active' : ''}`}
2200
2847
  aria-pressed={viewMode === option.mode}
2848
+ /* The pair still says which one is live WITHOUT a word, because it is a
2849
+ pair: one pill is lit and the other is not, which is the whole argument
2850
+ for two pills rather than one toggle (see the note above). */
2851
+ aria-label={option.label}
2201
2852
  onClick={() => setViewMode(option.mode)}
2202
2853
  >
2203
- <span aria-hidden='true'>{option.glyph}</span> {option.label}
2854
+ <option.Icon aria-hidden />
2204
2855
  </button>
2205
2856
  </Tooltip>
2206
2857
  ))}
@@ -2231,11 +2882,12 @@ export function MediaGallery({
2231
2882
  // guardrails-ignore no-raw-action-button: header pill styled by the dense
2232
2883
  // gallery's own plain CSS, matching its sort neighbours.
2233
2884
  type='button'
2234
- className={`cbgd-order-btn${thumbAutoplay ? ' is-active' : ''}`}
2885
+ className={`cbgd-order-btn is-icon${thumbAutoplay ? ' is-active' : ''}`}
2235
2886
  aria-pressed={thumbAutoplay}
2887
+ aria-label='Preview all'
2236
2888
  onClick={() => setThumbAutoplay(!thumbAutoplay)}
2237
2889
  >
2238
- โ–ถ Preview all
2890
+ <Play aria-hidden />
2239
2891
  </button>
2240
2892
  </Tooltip>
2241
2893
  ) : null}
@@ -2274,6 +2926,11 @@ export function MediaGallery({
2274
2926
  }
2275
2927
  : undefined
2276
2928
  }
2929
+ /* ๐Ÿ”ด The SAME handler and the same dialog the open file's strip uses โ€” it is
2930
+ handed a list either way, so there is no singular path for a plural gesture to
2931
+ drift away from. Tagging forty holiday photographs one at a time is the case
2932
+ the owner will actually hit, and it is one call carrying forty ids. */
2933
+ onTag={onSaveMeta ? () => setTaggingItems(checkedItems) : undefined}
2277
2934
  extra={bulkActions?.(checkedItems, checks.clear)}
2278
2935
  />
2279
2936
  ) : null
@@ -2300,6 +2957,7 @@ export function MediaGallery({
2300
2957
  /* See the related table in the detail view above โ€” "Preview all" has to mean the
2301
2958
  same thing in both views or it is two features wearing one label. */
2302
2959
  autoPreview={thumbAutoplay}
2960
+ exactBytes={meta?.exactBytes === true}
2303
2961
  {...(selectable
2304
2962
  ? {
2305
2963
  selection: (item: MediaGalleryItem) => ({
@@ -2323,15 +2981,43 @@ export function MediaGallery({
2323
2981
  selection={tileSelection(item)}
2324
2982
  drag={tileDrag(item)}
2325
2983
  autoPreview={thumbAutoplay}
2984
+ measure={meta?.tileMeasure === true}
2326
2985
  />
2327
2986
  ) : null;
2328
2987
  }}
2329
2988
  />
2989
+ </>
2330
2990
  );
2331
2991
  }
2332
2992
 
2333
2993
  const isItem = (i: MediaGalleryItem | undefined): i is MediaGalleryItem => i !== undefined;
2334
2994
 
2995
+ /**
2996
+ * The titlebar's meta slot โ€” the one-line facts, plus the DESCRIPTION under them.
2997
+ *
2998
+ * ๐Ÿ”ด The description gets its OWN element rather than joining the ` ยท ` line, and that is a
2999
+ * layout fact rather than a preference: `.cbgd-meta-line` is `white-space: nowrap` with an
3000
+ * ellipsis, because the same class captions every tile in the grid. A sentence appended to
3001
+ * that line is a sentence nobody can read past its first few words โ€” the exact failure the
3002
+ * task warns about for the TILE caption, one element further up.
3003
+ *
3004
+ * So the slot becomes two children of the titlebar's own wrapping flex row: the facts stay on
3005
+ * the nowrap line they were always on, and `.cbmg-stage-description` is a block that wraps and
3006
+ * is width-capped for readability (`mediaGallery.css`). An item with no description renders
3007
+ * byte-for-byte what it rendered before โ€” `undefined` in, `undefined` out โ€” which is what
3008
+ * keeps this from restyling every gallery in the fleet.
3009
+ */
3010
+ function stageMeta(line: string | undefined, description?: string): ReactNode {
3011
+ const words = description?.trim();
3012
+ if (!words) return line;
3013
+ return (
3014
+ <>
3015
+ {line ? <span className='cbmg-stage-facts'>{line}</span> : null}
3016
+ <span className='cbmg-stage-description'>{words}</span>
3017
+ </>
3018
+ );
3019
+ }
3020
+
2335
3021
  /**
2336
3022
  * The multi-select bar. Always mounted while `selectable` (never conditionally, so the grid
2337
3023
  * does not jump the moment the first box is ticked) โ€” the destructive half only appears
@@ -2346,6 +3032,7 @@ function BulkBar({
2346
3032
  onToggleAll,
2347
3033
  onClear,
2348
3034
  onDelete,
3035
+ onTag,
2349
3036
  extra,
2350
3037
  }: {
2351
3038
  count: number;
@@ -2357,6 +3044,8 @@ function BulkBar({
2357
3044
  onToggleAll: () => void;
2358
3045
  onClear: () => void;
2359
3046
  onDelete?: () => Promise<void>;
3047
+ /** Open the tag editor over the whole selection. Absent unless the app wired `onSaveMeta`. */
3048
+ onTag?: () => void;
2360
3049
  /** App-specific actions, rendered before Delete. See `bulkActions` on the gallery. */
2361
3050
  extra?: ReactNode;
2362
3051
  }) {
@@ -2368,11 +3057,13 @@ function BulkBar({
2368
3057
  // guardrails-ignore no-raw-action-button: action pill styled by the dense
2369
3058
  // gallery's own plain CSS, matching its neighbors.
2370
3059
  type='button'
2371
- className={`cbgd-order-btn${armed ? ' is-active' : ''}`}
3060
+ className={`cbgd-order-btn is-icon${armed ? ' is-active' : ''}`}
2372
3061
  aria-pressed={armed === true}
3062
+ // Same bargain as Pin: the two words were the readout, and they stay the name.
3063
+ aria-label={armed ? 'Done selecting' : 'Select'}
2373
3064
  onClick={onToggleArmed}
2374
3065
  >
2375
- {armed ? 'Done selecting' : 'Select'}
3066
+ <SquareCheck aria-hidden />
2376
3067
  </button>
2377
3068
  </Tooltip>
2378
3069
  ) : null}
@@ -2394,6 +3085,26 @@ function BulkBar({
2394
3085
  {count > 0 ? (
2395
3086
  <>
2396
3087
  <span className='cbmg-bulkbar-spacer' />
3088
+ {/* The gallery's own bulk verb, ahead of the app's: this row reads the same in
3089
+ every app that wires it, for the same reason the detail strip puts the shared
3090
+ capabilities before `detailActions`. */}
3091
+ {onTag ? (
3092
+ <Tooltip content={`Tag the ${count} selected item${count === 1 ? '' : 's'} in one go`}>
3093
+ <button
3094
+ // guardrails-ignore no-raw-action-button: action pill styled by the dense
3095
+ // gallery's own plain CSS, matching its neighbors.
3096
+ type='button'
3097
+ className='cbgd-order-btn is-icon'
3098
+ // The count is in the NAME, because the visible label is a glyph and "tag"
3099
+ // without a number tells a screen reader nothing about what it acts on โ€”
3100
+ // the same argument the delete pill three lines down records.
3101
+ aria-label={`Tag the ${count} selected item${count === 1 ? '' : 's'}`}
3102
+ onClick={onTag}
3103
+ >
3104
+ <Tags aria-hidden />
3105
+ </button>
3106
+ </Tooltip>
3107
+ ) : null}
2397
3108
  {/* App actions come BEFORE Delete on purpose: Delete is the destructive one and
2398
3109
  belongs at the end, furthest from the pointer's resting place after the
2399
3110
  selection is made. */}
@@ -2411,19 +3122,26 @@ function BulkBar({
2411
3122
  message: 'This cannot be undone.',
2412
3123
  }}
2413
3124
  onDelete={onDelete}
2414
- >
2415
- โœ• Delete {count}
2416
- </DeleteButton>
3125
+ /* ๐Ÿ”ด The COUNT drops off the pill and loses nothing, because this bar prints
3126
+ "{count} selected" three controls to the left โ€” it was the one number in
3127
+ the strip that was already stated twice. Both the name and the tooltip
3128
+ above still carry it, so a reader hovering the trash still learns how many
3129
+ it takes. */
3130
+ iconOnly
3131
+ />
2417
3132
  ) : null}
2418
- <button
2419
- // guardrails-ignore no-raw-action-button: action pill styled by the dense
2420
- // gallery's own plain CSS, matching its neighbors.
2421
- type='button'
2422
- className='cbgd-order-btn'
2423
- onClick={onClear}
2424
- >
2425
- Clear
2426
- </button>
3133
+ <Tooltip content='Untick everything and leave the tiles alone'>
3134
+ <button
3135
+ // guardrails-ignore no-raw-action-button: action pill styled by the dense
3136
+ // gallery's own plain CSS, matching its neighbors.
3137
+ type='button'
3138
+ className='cbgd-order-btn is-icon'
3139
+ aria-label='Clear the selection'
3140
+ onClick={onClear}
3141
+ >
3142
+ <X aria-hidden />
3143
+ </button>
3144
+ </Tooltip>
2427
3145
  </>
2428
3146
  ) : null}
2429
3147
  </div>