cursedbelt 3.0.2 โ†’ 4.1.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 (252) 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 +130 -32
  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/paths.ts +106 -0
  151. package/scripts/styleAreas.ts +585 -0
  152. package/scripts/verify.ts +5 -0
  153. package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
  154. package/src/docsMatchTheSplit.spec.ts +98 -0
  155. package/src/fixtureAppCss.spec.ts +177 -0
  156. package/src/namedSubpathsResolve.spec.ts +35 -0
  157. package/src/publishShape.spec.ts +67 -0
  158. package/src/react/components/ListToolbar.spec.tsx +172 -0
  159. package/src/react/components/ListToolbar.tsx +151 -0
  160. package/src/react/file-tree/FileTree.spec.tsx +1009 -0
  161. package/src/react/file-tree/FileTree.tsx +1459 -309
  162. package/src/react/file-tree/fileTree.css +151 -0
  163. package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
  164. package/src/react/file-tree/fileTreeSelection.ts +321 -0
  165. package/src/react/file-tree/index.ts +21 -0
  166. package/src/react/index.ts +1 -0
  167. package/src/react/lib/form.ts +51 -8
  168. package/src/react/markdown/MarkdownDoc.spec.tsx +1 -1
  169. package/src/react/media/DenseGalleryShell.tsx +108 -36
  170. package/src/react/media/FocusViewer.tsx +15 -8
  171. package/src/react/media/FrameGrabber.spec.tsx +175 -0
  172. package/src/react/media/FrameGrabber.tsx +67 -0
  173. package/src/react/media/MediaResize.spec.tsx +84 -14
  174. package/src/react/media/MediaResize.tsx +51 -19
  175. package/src/react/media/VideoChapterEditor.tsx +428 -0
  176. package/src/react/media/VideoPlayer.spec.tsx +111 -1
  177. package/src/react/media/VideoPlayer.tsx +158 -11
  178. package/src/react/media/VideoTurner.spec.tsx +67 -0
  179. package/src/react/media/VideoTurner.tsx +70 -15
  180. package/src/react/media/denseGallery.css +104 -46
  181. package/src/react/media/hlsSource.recovery.spec.ts +213 -0
  182. package/src/react/media/hlsSource.spec.ts +8 -0
  183. package/src/react/media/hlsSource.ts +189 -1
  184. package/src/react/media/index.ts +14 -0
  185. package/src/react/media/mediaResize.css +127 -54
  186. package/src/react/media/pictureEditor.css +52 -6
  187. package/src/react/media/videoChapterEditor.css +203 -0
  188. package/src/react/media/videoChapters.spec.ts +176 -0
  189. package/src/react/media/videoChapters.ts +181 -0
  190. package/src/react/media-gallery/GalleryTable.tsx +93 -3
  191. package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
  192. package/src/react/media-gallery/MediaGallery.tsx +813 -95
  193. package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
  194. package/src/react/media-gallery/galleryTable.spec.ts +41 -1
  195. package/src/react/media-gallery/index.ts +7 -0
  196. package/src/react/media-gallery/mediaGallery.css +180 -4
  197. package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
  198. package/src/react/media-gallery/mediaMeta.ts +101 -0
  199. package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
  200. package/src/react/media-gallery/playbackPreferences.ts +100 -0
  201. package/src/react/media-gallery/types.ts +34 -0
  202. package/src/shippedFilesAreTracked.spec.ts +69 -0
  203. package/src/styles-areas/activity-bar.css +30 -0
  204. package/src/styles-areas/analytics.css +672 -0
  205. package/src/styles-areas/auth.css +154 -0
  206. package/src/styles-areas/calendar.css +416 -0
  207. package/src/styles-areas/chart-table.css +41 -0
  208. package/src/styles-areas/charts.css +89 -0
  209. package/src/styles-areas/chat.css +364 -0
  210. package/src/styles-areas/clipboard.css +26 -0
  211. package/src/styles-areas/code-editor.css +130 -0
  212. package/src/styles-areas/companion-link.css +70 -0
  213. package/src/styles-areas/core.css +2503 -0
  214. package/src/styles-areas/dashboard-grid.css +84 -0
  215. package/src/styles-areas/data-table.css +657 -0
  216. package/src/styles-areas/deep-link.css +26 -0
  217. package/src/styles-areas/diff-viewer.css +30 -0
  218. package/src/styles-areas/disk-usage.css +262 -0
  219. package/src/styles-areas/emoji.css +154 -0
  220. package/src/styles-areas/fields.css +582 -0
  221. package/src/styles-areas/file-tree.css +33 -0
  222. package/src/styles-areas/filter-rail.css +235 -0
  223. package/src/styles-areas/folder-tree.css +219 -0
  224. package/src/styles-areas/keep-awake.css +26 -0
  225. package/src/styles-areas/layout-engine.css +239 -0
  226. package/src/styles-areas/markdown.css +87 -0
  227. package/src/styles-areas/master-detail.css +239 -0
  228. package/src/styles-areas/master-lock.css +26 -0
  229. package/src/styles-areas/media-gallery.css +351 -0
  230. package/src/styles-areas/media.css +658 -0
  231. package/src/styles-areas/nav.css +488 -0
  232. package/src/styles-areas/notifications.css +245 -0
  233. package/src/styles-areas/overlays.css +133 -0
  234. package/src/styles-areas/palette.css +140 -0
  235. package/src/styles-areas/pdf-viewer.css +47 -0
  236. package/src/styles-areas/problem-detail.css +97 -0
  237. package/src/styles-areas/rich-text.css +167 -0
  238. package/src/styles-areas/sharing.css +153 -0
  239. package/src/styles-areas/spreadsheet.css +37 -0
  240. package/src/styles-areas/stats.css +281 -0
  241. package/src/styles-areas/test-report.css +133 -0
  242. package/src/styles-areas/virtual.css +30 -0
  243. package/src/styles-areas/wizard.css +381 -0
  244. package/src/styles-areas/workbench.css +543 -0
  245. package/src/styles-areas/workbook-viewer.css +44 -0
  246. package/src/styles-static.css +34 -23
  247. package/src/styles.css +34 -23
  248. package/src/stylesAreas.spec.ts +247 -0
  249. package/src/stylesUtilitiesMatches.spec.ts +6 -77
  250. package/src/testFilesRunInParallel.spec.ts +79 -0
  251. package/src/typecheckCachesAreSeparate.spec.ts +128 -0
  252. package/src/verifyGraph.spec.ts +6 -0
@@ -29,7 +29,7 @@ for (const prop of ['clientHeight', 'offsetHeight'] as const) {
29
29
  }
30
30
  HTMLElement.prototype.getBoundingClientRect = () => RECT;
31
31
 
32
- import { MediaGallery, stagePlayableVideo } from './MediaGallery';
32
+ import { MediaGallery, galleryTileLine, stagePlayableVideo } from './MediaGallery';
33
33
  import type { MediaGalleryItem } from './types';
34
34
 
35
35
  afterEach(cleanup);
@@ -294,7 +294,7 @@ describe('MediaGallery multi-select (opt-in)', () => {
294
294
  fireEvent.click(boxes(container)[0] as HTMLInputElement);
295
295
  expect(quiet(container)).toHaveLength(0);
296
296
  // โ€ฆand Clear puts the grid back to quiet rather than leaving it armed forever.
297
- fireEvent.click(getByRole('button', { name: 'Clear' }));
297
+ fireEvent.click(getByRole('button', { name: 'Clear the selection' }));
298
298
  expect(quiet(container)).toHaveLength(ITEMS.length);
299
299
  });
300
300
  });
@@ -307,9 +307,11 @@ describe('MediaGallery detail view', () => {
307
307
  // The stage shows the full image (url, not thumbnail).
308
308
  const stage = container.querySelector('.cbgd-stage-img img');
309
309
  expect(stage?.getAttribute('src')).toBe('/files/img1');
310
- // Related grid below with the OTHER items.
311
- expect(getByText('More files')).toBeDefined();
310
+ // The strip below, holding every displayed file โ€” the open one included, which is why the
311
+ // heading is no longer "More files". See the suite at the foot of this file.
312
+ expect(getByText('Files')).toBeDefined();
312
313
  expect(getByRole('button', { name: 'Open clip.mp4' })).toBeDefined();
314
+ expect(getByRole('button', { name: 'Open beach.jpg' })).toBeDefined();
313
315
  // Download + fullscreen affordances.
314
316
  expect(getByRole('link', { name: /download/i }).getAttribute('href')).toBe('/files/img1');
315
317
  expect(getByRole('button', { name: /fullscreen/i })).toBeDefined();
@@ -477,6 +479,37 @@ describe('MediaGallery โ€” turning a video', () => {
477
479
  expect(frame?.getAttribute('data-turned')).toBe('true');
478
480
  });
479
481
 
482
+ /**
483
+ * ๐Ÿ”ด A quarter turn has to REFIT the panel, not just spin the picture inside it.
484
+ *
485
+ * Owner, `ideas/collections-09-14.md`: *"If I rotate a portait style video 90 degrees it
486
+ * should be changed to fit the wide screen panel if I save it so I can watch it like it was
487
+ * recorded with wide screen."* The rotation above shipped without the fit, and the assertion
488
+ * above is exactly what let it through: it reads the transform and stops there.
489
+ *
490
+ * The fit is a pair of declarations rather than a scale factor on that transform, and the
491
+ * stylesheet says why at length โ€” a scale does not know the film's shape, so it can only be
492
+ * right for footage whose aspect is the panel's inverse and crops everything else. What a
493
+ * happy-dom test CAN prove is that the axis swap is declared where the turn is applied; that
494
+ * the film then covers the panel is a rectangle, measured in `e2e/video-turn.spec.ts`.
495
+ */
496
+ test('๐Ÿ”ด a quarter turn also carries the axis-swapped FIT, or it is a film in a letterbox', () => {
497
+ const css = readFileSync(join(import.meta.dir, 'mediaGallery.css'), 'utf8');
498
+ const block = css
499
+ .slice(css.indexOf(".cbmg-stage-turn[data-turned='true'] video"))
500
+ .slice(0, css.slice(css.indexOf(".cbmg-stage-turn[data-turned='true'] video")).indexOf('}') + 1);
501
+ // Width read from the container's BLOCK size and height from its INLINE size โ€” the swap.
502
+ expect(block).toContain('width: 100cqb');
503
+ expect(block).toContain('height: 100cqi');
504
+ // `max-*` alone is what shipped, and it can only shrink: a clip the player had already
505
+ // sized stayed exactly as it was, which is the pillar in the middle of a black panel.
506
+ expect(block).not.toContain('max-block-size: 100cqi');
507
+ expect(block).not.toContain('max-inline-size: 100cqb');
508
+ // A rotation turns about the element's own centre, so the element has to BE at the panel's
509
+ // centre โ€” off-centre by a quarter of the panel is what the first browser run measured.
510
+ expect(css).toContain(".cbmg-stage-turn[data-turned='true'] [data-media-provider]");
511
+ });
512
+
480
513
  test('an UNturned clip adds no box, no layer and no transform', () => {
481
514
  const plain: MediaGalleryItem[] = [{ ...TURNED[0], orientation: undefined } as MediaGalleryItem];
482
515
  const { container, getByRole } = render(<MediaGallery items={plain} nativeVideoStage />);
@@ -489,26 +522,61 @@ describe('MediaGallery โ€” turning a video', () => {
489
522
  });
490
523
 
491
524
  test('the Turn control is offered on a film, and only when the app can store the answer', () => {
525
+ // By accessible NAME rather than by text: the pill is an icon now, so `queryByText`
526
+ // would find nothing in either arm and the test would pass without proving anything.
492
527
  const withoutHandler = render(<MediaGallery items={TURNED} />);
493
528
  fireEvent.click(withoutHandler.getByRole('button', { name: 'Open clip.mp4' }));
494
- expect(withoutHandler.queryByText('โŸณ Turn video')).toBeNull();
529
+ expect(withoutHandler.queryByRole('button', { name: 'Turn video' })).toBeNull();
495
530
  withoutHandler.unmount();
496
531
 
497
532
  const withHandler = render(<MediaGallery items={TURNED} onSaveOrientation={() => {}} />);
498
533
  fireEvent.click(withHandler.getByRole('button', { name: 'Open clip.mp4' }));
499
- expect(withHandler.queryByText('โŸณ Turn video')).not.toBeNull();
534
+ expect(withHandler.queryByRole('button', { name: 'Turn video' })).not.toBeNull();
535
+ });
536
+
537
+ /**
538
+ * ๐Ÿ”ด The dialog is handed the RESOLVED source โ€” the owner's *"When I click the turn the video
539
+ * button I see a black screen."*
540
+ *
541
+ * It read `item.streamUrl ?? item.url` straight off the row, and an item is allowed to carry
542
+ * neither: `resolveMedia` is the documented path for a signed URL, and it is what the app that
543
+ * reported this uses. Both fields `undefined` meant `src=''`, which React renders as a
544
+ * `<video>` with no source at all โ€” an element that never loads, never errors, and paints
545
+ * black forever. Every other surface in the gallery reads an item through `useItemMedia`;
546
+ * this is the one that did not.
547
+ */
548
+ test('๐Ÿ”ด the turn dialog is handed the RESOLVED source, not the raw row', async () => {
549
+ const signed: MediaGalleryItem[] = [
550
+ {
551
+ id: 'vid2',
552
+ name: 'signed.mp4',
553
+ mime: 'video/mp4',
554
+ createdAt: 10,
555
+ // No `url` and no `streamUrl`: the app mints them when something needs them.
556
+ resolveMedia: async () => ({ streamUrl: '/signed/vid2', posterUrl: '/signed/poster2' }),
557
+ },
558
+ ];
559
+ const view = render(<MediaGallery items={signed} onSaveOrientation={() => {}} />);
560
+ await waitFor(() => expect(view.queryByRole('button', { name: 'Open signed.mp4' })).not.toBeNull());
561
+ fireEvent.click(view.getByRole('button', { name: 'Open signed.mp4' }));
562
+ fireEvent.click(view.getByRole('button', { name: 'Turn video' }));
563
+ // The dialog is a lazy chunk and a portal โ€” wait for it, then read it off `baseElement`.
564
+ await waitFor(() => expect(view.queryByText('Turn this video')).not.toBeNull());
565
+ const preview = view.baseElement.querySelector<HTMLVideoElement>('.cbpe-frame-film video');
566
+ expect(preview?.getAttribute('src')).toBe('/signed/vid2');
567
+ expect(preview?.getAttribute('poster')).toBe('/signed/poster2');
500
568
  });
501
569
 
502
570
  test('๐Ÿ”ด a PICTURE never gets the video control โ€” it already has the fuller editor', () => {
503
571
  const picture: MediaGalleryItem[] = [
504
572
  { id: 'img1', name: 'beach.jpg', url: '/files/img1', createdAt: 1 },
505
573
  ];
506
- const { getByRole, queryByText } = render(
574
+ const { getByRole, queryByRole } = render(
507
575
  <MediaGallery items={picture} onSaveOrientation={() => {}} />,
508
576
  );
509
577
  fireEvent.click(getByRole('button', { name: 'Open beach.jpg' }));
510
578
  // Two controls that both say "turn" on the same item is the confusion the split avoids.
511
- expect(queryByText('โŸณ Turn video')).toBeNull();
579
+ expect(queryByRole('button', { name: 'Turn video' })).toBeNull();
512
580
  });
513
581
  });
514
582
 
@@ -523,11 +591,20 @@ describe('MediaGallery โ€” turning a video', () => {
523
591
  * kind of thing a refactor drops silently.
524
592
  */
525
593
  describe('MediaGallery โ€” the playback run', () => {
526
- /** Press the cycling button until it reads `label`, and return the button. */
594
+ /**
595
+ * Press the cycling button until it reads `label`, and return the button.
596
+ *
597
+ * ๐Ÿ”ด Read off the ACCESSIBLE NAME, not `textContent`. The pill is a bare glyph since
598
+ * 2026-09-16 (owner: *"icons only, instead of words in the strip of tools"*), so its text
599
+ * content is the empty string for all five settings โ€” a `textContent` probe would loop six
600
+ * times and throw on every call, which is how a cosmetic change takes a behavioural suite
601
+ * with it. The name is where the value lives now, and asserting through it is also the
602
+ * point: it is the thing a screen reader gets.
603
+ */
527
604
  function cycleTo(getByRole: ReturnType<typeof render>['getByRole'], label: RegExp) {
528
605
  for (let i = 0; i < 6; i++) {
529
606
  const button = getByRole('button', { name: /^When an item ends:/ });
530
- if (label.test(button.textContent ?? '')) return button;
607
+ if (label.test(button.getAttribute('aria-label') ?? '')) return button;
531
608
  fireEvent.click(button);
532
609
  }
533
610
  throw new Error(`the playback button never reached ${label}`);
@@ -792,6 +869,162 @@ describe('MediaGallery table view', () => {
792
869
  });
793
870
  });
794
871
 
872
+ /**
873
+ * SIZE and LENGTH, reported where they are useful โ€” the `meta` prop.
874
+ *
875
+ * Three asks from two drafts, one surface. `ideas/collections-09-14.md`: *"In collections I
876
+ * want to see the exact file size in bytes as a column option so I can easily tell if two
877
+ * files are the exact same byte size to help me know if they are duplicates or not"* and
878
+ * *"in the grid view show the video length instead of the size."* `ideas/binary-server-09-15.md`
879
+ * asked for both facts on the same tiles and guessed the right home: *"maybe controlled
880
+ * through param on media gallery in cb."*
881
+ *
882
+ * ๐Ÿ”ด Every one of these is OFF unless asked for, which is why the last test in the block is
883
+ * the one that would catch a regression in four apps rather than in this feature.
884
+ */
885
+ describe('MediaGallery โ€” size and length', () => {
886
+ /** Two near-duplicates one byte apart, and a document. `formatBytes` calls both films `1.3 GB`. */
887
+ const MEASURED: MediaGalleryItem[] = [
888
+ {
889
+ id: 'v1',
890
+ name: 'holiday.mp4',
891
+ category: 'video',
892
+ streamUrl: '/files/v1',
893
+ posterUrl: '/thumbs/v1',
894
+ durationSeconds: 187,
895
+ sizeBytes: 1_402_653_184,
896
+ createdAt: 30,
897
+ },
898
+ {
899
+ id: 'v2',
900
+ name: 'holiday-copy.mp4',
901
+ category: 'video',
902
+ streamUrl: '/files/v2',
903
+ posterUrl: '/thumbs/v2',
904
+ durationSeconds: 187,
905
+ sizeBytes: 1_402_653_185,
906
+ createdAt: 20,
907
+ },
908
+ { id: 'd1', name: 'notes.pdf', url: '/files/d1', sizeBytes: 5_242_880, createdAt: 10 },
909
+ ];
910
+
911
+ /** The caption's second line for the tile whose title is `name` โ€” see `GalleryMeta`. */
912
+ const tileLine = (container: HTMLElement, name: string): string | null => {
913
+ const block = Array.from(container.querySelectorAll('.cbgd-meta')).find(
914
+ (meta) => meta.querySelector('.cbgd-meta-title')?.textContent === name,
915
+ );
916
+ return block?.querySelector('.cbgd-meta-line')?.textContent ?? null;
917
+ };
918
+
919
+ const showTable = (getByRole: ReturnType<typeof render>['getByRole']): void => {
920
+ fireEvent.click(getByRole('button', { name: /Table/ }));
921
+ };
922
+
923
+ test('a video tile leads with its running time, and a document keeps its size', () => {
924
+ const { container } = render(
925
+ <MediaGallery items={MEASURED} meta={{ tileMeasure: true }} />,
926
+ );
927
+ expect(tileLine(container, 'holiday.mp4')).toBe('3:07');
928
+ // Everything that is not time-based is unchanged by the same switch: a PDF has no
929
+ // running time, and its size is the measurement that means something for it.
930
+ expect(tileLine(container, 'notes.pdf')).toBe('5.0 MB');
931
+ });
932
+
933
+ test('tags keep their line and the running time joins them on it', () => {
934
+ // ๐Ÿ”ด Never a fourth line: the caption block is a fixed 65px (`CBGD_META_HEIGHT`), and a
935
+ // tile taller than its neighbour is what made rows lap each other across the fleet.
936
+ const tagged: MediaGalleryItem[] = [
937
+ { ...(MEASURED[0] as MediaGalleryItem), tags: ['holiday', 'beach'] },
938
+ ];
939
+ const { container } = render(<MediaGallery items={tagged} meta={{ tileMeasure: true }} />);
940
+ expect(tileLine(container, 'holiday.mp4')).toBe('holiday ยท beach ยท 3:07');
941
+ });
942
+
943
+ test('a film nobody measured falls back to its size, never to an empty caption', () => {
944
+ const unmeasured: MediaGalleryItem[] = [
945
+ { id: 'v9', name: 'unknown.mp4', category: 'video', streamUrl: '/files/v9', sizeBytes: 2048 },
946
+ { id: 'v8', name: 'nothing.mp4', category: 'video', streamUrl: '/files/v8' },
947
+ ];
948
+ const { container } = render(<MediaGallery items={unmeasured} meta={{ tileMeasure: true }} />);
949
+ expect(tileLine(container, 'unknown.mp4')).toBe('2.0 KB');
950
+ // Neither fact known: the same em dash the table's columns show, not a blank line.
951
+ expect(tileLine(container, 'nothing.mp4')).toBe('โ€”');
952
+ });
953
+
954
+ test('๐Ÿ”ด the exact-bytes column separates two files a rounded size cannot', () => {
955
+ const { getByRole, container } = render(
956
+ <MediaGallery items={MEASURED} meta={{ exactBytes: true }} />,
957
+ );
958
+ showTable(getByRole);
959
+ expect(container.querySelector('th.cbgt-cell--bytes')?.textContent).toBe('Bytes');
960
+ const bytesFor = (name: string): string | undefined =>
961
+ Array.from(container.querySelectorAll('.cbgt-row'))
962
+ .find((row) => row.textContent?.includes(name))
963
+ ?.querySelector('.cbgt-cell--bytes')?.textContent ?? undefined;
964
+ // The whole ask: one byte apart, and the column says so.
965
+ expect(bytesFor('holiday.mp4')).toMatch(/^1\D?402\D?653\D?184$/);
966
+ expect(bytesFor('holiday-copy.mp4')).toMatch(/^1\D?402\D?653\D?185$/);
967
+ expect(bytesFor('holiday.mp4')).not.toBe(bytesFor('holiday-copy.mp4'));
968
+ // โ€ฆwhile the rounded column beside it calls them the same file, which is the defect.
969
+ const sizeFor = (name: string): string | undefined =>
970
+ Array.from(container.querySelectorAll('.cbgt-row'))
971
+ .find((row) => row.textContent?.includes(name))
972
+ ?.querySelectorAll('.cbgt-cell--num')[0]?.textContent ?? undefined;
973
+ expect(sizeFor('holiday.mp4')).toBe(sizeFor('holiday-copy.mp4'));
974
+ });
975
+
976
+ test('the column is an ADDITION โ€” Size, Length and the sort are where they were', () => {
977
+ const { getByRole, container } = render(
978
+ <MediaGallery items={MEASURED} meta={{ exactBytes: true }} />,
979
+ );
980
+ showTable(getByRole);
981
+ // Without the direction glyph a sorted header carries โ€” the active sort is Date's, and
982
+ // this test is about which columns exist and in what order.
983
+ const headers = Array.from(container.querySelectorAll('thead th')).map((th) =>
984
+ th.textContent?.replace(/[โ–ฒโ–ผ]/g, '').trim(),
985
+ );
986
+ expect(headers).toEqual(['Preview', 'Name', 'Kind', 'Size', 'Bytes', 'Length', 'Date']);
987
+ // The size SORT is still the rounded column's, and it still sorts on the number: the
988
+ // exact column carries no sort of its own, so there is one ordering, not two.
989
+ fireEvent.click(container.querySelector('.cbgt-cell--num .cbgt-sort') as HTMLElement);
990
+ expect(container.querySelector('.cbgt-row')?.textContent).toContain('holiday-copy.mp4');
991
+ });
992
+
993
+ test('๐Ÿ”ด a consumer that passes nothing renders exactly what it rendered before', () => {
994
+ const { getByRole, container } = render(<MediaGallery items={MEASURED} />);
995
+ // The grid: a film shows its rounded SIZE, the way it always has.
996
+ expect(tileLine(container, 'holiday.mp4')).toBe('1.3 GB');
997
+ showTable(getByRole);
998
+ // The table: six columns, no exact-bytes cell anywhere.
999
+ expect(container.querySelectorAll('thead th')).toHaveLength(6);
1000
+ expect(container.querySelector('.cbgt-cell--bytes')).toBeNull();
1001
+ });
1002
+
1003
+ /**
1004
+ * The line itself, called directly โ€” the branch table is wider than any fixture, and two of
1005
+ * these combinations never appear in a rendered tile in this file.
1006
+ */
1007
+ test('the caption line, branch by branch', () => {
1008
+ const film = MEASURED[0] as MediaGalleryItem;
1009
+ expect(galleryTileLine(film)).toBe('1.3 GB');
1010
+ expect(galleryTileLine(film, { measure: true })).toBe('3:07');
1011
+ // Only the first three tags, as the line has always done.
1012
+ expect(
1013
+ galleryTileLine({ ...film, tags: ['a', 'b', 'c', 'd'] }, { measure: true }),
1014
+ ).toBe('a ยท b ยท c ยท 3:07');
1015
+ // An AUDIO track is time-based too โ€” the same argument as a film, and the same fix.
1016
+ expect(
1017
+ galleryTileLine(
1018
+ { id: 'a1', name: 'memo.m4a', category: 'audio', durationSeconds: 65, sizeBytes: 900 },
1019
+ { measure: true },
1020
+ ),
1021
+ ).toBe('1:05');
1022
+ // Off, nothing about the line moves โ€” tags win, then the size, then nothing at all.
1023
+ expect(galleryTileLine({ ...film, tags: ['holiday'] })).toBe('holiday');
1024
+ expect(galleryTileLine({ id: 'x', name: 'bare.bin' })).toBeNull();
1025
+ });
1026
+ });
1027
+
795
1028
  /**
796
1029
  * ๐Ÿ”ด The render loop that took `apps/family`'s `/#/p/<id>/media` off the air.
797
1030
  *
@@ -1006,6 +1239,90 @@ describe('๐Ÿ”ด the stage hands the player every transport the app resolved', ()
1006
1239
  expect(video.processingStatus).toBe('processing');
1007
1240
  expect(video.durationSeconds).toBe(12);
1008
1241
  });
1242
+
1243
+ /*
1244
+ * ๐Ÿ”ด The named sections are the FOURTH field to come down this road, and the only assertion
1245
+ * that can catch it being forgotten. The header above this function names the other three:
1246
+ * each was resolved by the app, carried on the item, and then simply not listed in the
1247
+ * mapping โ€” so the feature was invisible with nothing anywhere reporting a fault. A chapter
1248
+ * list would fail exactly that way, and more quietly than the rest: the player would draw a
1249
+ * bare scrub bar, which is precisely what a video with no chapters is supposed to look like.
1250
+ */
1251
+ test('๐Ÿ”ด the named sections reach the player', () => {
1252
+ const chaptered: MediaGalleryItem = {
1253
+ ...item,
1254
+ chapters: [
1255
+ { startSeconds: 0, title: 'Cold open' },
1256
+ { startSeconds: 6, title: 'The walk home' },
1257
+ ],
1258
+ };
1259
+ const video = stagePlayableVideo(chaptered, {}, '/files/v1.web.mp4');
1260
+ expect(video.chapters).toEqual([
1261
+ { startSeconds: 0, title: 'Cold open' },
1262
+ { startSeconds: 6, title: 'The walk home' },
1263
+ ]);
1264
+ });
1265
+
1266
+ test('a video with no sections leaves the field ABSENT, not empty', () => {
1267
+ // An empty chapters track is not neutral: the layout draws a chapter-title area and a menu
1268
+ // entry for a film that has no chapters.
1269
+ expect('chapters' in stagePlayableVideo(item, {}, '/files/v1.web.mp4')).toBe(false);
1270
+ expect('chapters' in stagePlayableVideo({ ...item, chapters: [] }, {}, '/files/v1.web.mp4')).toBe(
1271
+ false,
1272
+ );
1273
+ });
1274
+ });
1275
+
1276
+ /**
1277
+ * Naming the parts of a film โ€” the owner's `media-gallery-cb.md` ask, at the component edge.
1278
+ *
1279
+ * ๐Ÿ”ด What the chapter MODEL does is asserted in `react/media/videoChapters.spec.ts`, where it
1280
+ * is pure. What can only be checked here is the capability rule: the control exists exactly
1281
+ * when the app passed somewhere to put the answer, and never otherwise. That rule is the whole
1282
+ * of how a feature gets turned on in this component โ€” there is no flag and no config โ€” so it
1283
+ * is the thing a refactor can silently invert.
1284
+ */
1285
+ describe('MediaGallery โ€” naming the sections of a video', () => {
1286
+ const FILM: MediaGalleryItem[] = [
1287
+ {
1288
+ id: 'vid1',
1289
+ name: 'clip.mp4',
1290
+ streamUrl: '/files/vid1',
1291
+ mime: 'video/mp4',
1292
+ createdAt: 10,
1293
+ durationSeconds: 60,
1294
+ },
1295
+ ];
1296
+
1297
+ test('๐Ÿ”ด the Sections control appears only when the app can STORE the answer', () => {
1298
+ const withoutHandler = render(<MediaGallery items={FILM} />);
1299
+ fireEvent.click(withoutHandler.getByRole('button', { name: 'Open clip.mp4' }));
1300
+ expect(withoutHandler.queryByRole('button', { name: 'Sections' })).toBeNull();
1301
+ withoutHandler.unmount();
1302
+
1303
+ const withHandler = render(<MediaGallery items={FILM} onSaveChapters={() => {}} />);
1304
+ fireEvent.click(withHandler.getByRole('button', { name: 'Open clip.mp4' }));
1305
+ expect(withHandler.queryByRole('button', { name: 'Sections' })).not.toBeNull();
1306
+ });
1307
+
1308
+ test('๐Ÿ”ด a PICTURE never gets it โ€” there is no timeline to put a marker on', () => {
1309
+ const picture: MediaGalleryItem[] = [
1310
+ { id: 'img1', name: 'beach.jpg', url: '/files/img1', createdAt: 1 },
1311
+ ];
1312
+ const { getByRole, queryByRole } = render(
1313
+ <MediaGallery items={picture} onSaveChapters={() => {}} />,
1314
+ );
1315
+ fireEvent.click(getByRole('button', { name: 'Open beach.jpg' }));
1316
+ expect(queryByRole('button', { name: 'Sections' })).toBeNull();
1317
+ });
1318
+
1319
+ test('๐Ÿ”ด READING sections needs no handler โ€” only writing them is gated', () => {
1320
+ // An app that merely lists someone else's films still shows their chapters; what it cannot
1321
+ // do is save new ones. Asserted on the mapping, because the rendered player is a Vidstack
1322
+ // custom element happy-dom never brings to life.
1323
+ const item = { ...(FILM[0] as MediaGalleryItem), chapters: [{ startSeconds: 3, title: 'Two' }] };
1324
+ expect(stagePlayableVideo(item, {}, '/files/vid1')?.chapters).toHaveLength(1);
1325
+ });
1009
1326
  });
1010
1327
 
1011
1328
  /**
@@ -1300,3 +1617,832 @@ describe('๐Ÿ”ด Preview all reaches the TABLE, not only the grid', () => {
1300
1617
  expect(view.container.querySelectorAll('.cbgt-play')).toHaveLength(1);
1301
1618
  });
1302
1619
  });
1620
+
1621
+ /**
1622
+ * ๐Ÿ”ด A CAPABILITY DIALOG MAY NOT OUTLIVE THE FILE IT WAS OPENED FOR.
1623
+ *
1624
+ * The owner, `ideas/collections-09-14.md`, 2026-09-14, on the thumbnail chooser:
1625
+ *
1626
+ * *"In collections app I chose a thumbnail for a video but then I could not get back to the
1627
+ * normal viewing area โ€ฆ Clicking on other folders also did not move to view them and the
1628
+ * thumbnail selection screen for the first video stayed on the screen. Selecting other
1629
+ * sidebar files and icons should also leave that screen."*
1630
+ *
1631
+ * The reset effect keyed on `selectedId` cleared the rename field and the picture editor and
1632
+ * nothing else, so four of the five dialogs survived the selection that opened them โ€” and a
1633
+ * surviving dialog is not merely stale, it is WRONG: its title, its `src`, its carried playhead
1634
+ * and the item every save handler is closed over all still name the file you navigated away
1635
+ * from. The worst shape that has is the chooser's, because its Save writes a thumbnail onto
1636
+ * whichever film it is still holding.
1637
+ *
1638
+ * Driven through the CONTROLLED `selectedId`, because that is the wire the sidebar is on: an
1639
+ * app moves the selection and the gallery has to follow.
1640
+ */
1641
+ describe('๐Ÿ”ด selecting another file leaves the thumbnail chooser', () => {
1642
+ const VIDEOS: MediaGalleryItem[] = [
1643
+ {
1644
+ id: 'vid1',
1645
+ name: 'first.mp4',
1646
+ streamUrl: '/files/vid1',
1647
+ mime: 'video/mp4',
1648
+ durationSeconds: 12,
1649
+ createdAt: 20,
1650
+ },
1651
+ {
1652
+ id: 'vid2',
1653
+ name: 'second.mp4',
1654
+ streamUrl: '/files/vid2',
1655
+ mime: 'video/mp4',
1656
+ durationSeconds: 9,
1657
+ createdAt: 10,
1658
+ },
1659
+ ];
1660
+
1661
+ const gallery = (selectedId: string) => (
1662
+ <MediaGallery
1663
+ items={VIDEOS}
1664
+ selectedId={selectedId}
1665
+ nativeVideoStage
1666
+ onSaveThumbnail={() => {}}
1667
+ onSaveSnapshot={() => {}}
1668
+ />
1669
+ );
1670
+
1671
+ /** Press `Choose thumbnail` and wait for the lazily-imported dialog to arrive. */
1672
+ const openChooser = async (view: ReturnType<typeof render>, name: string) => {
1673
+ fireEvent.click(view.getByRole('button', { name: /thumbnail/ }));
1674
+ await waitFor(() =>
1675
+ expect(document.body.textContent).toContain(`Pick a moment in ${name}`),
1676
+ );
1677
+ };
1678
+
1679
+ test('the chooser closes when the selection moves to another film', async () => {
1680
+ const view = render(gallery('vid1'));
1681
+ await openChooser(view, 'first.mp4');
1682
+ view.rerender(gallery('vid2'));
1683
+ await waitFor(() =>
1684
+ expect(document.body.textContent, 'the chooser stayed on screen').not.toContain(
1685
+ 'Pick a moment in',
1686
+ ),
1687
+ );
1688
+ });
1689
+
1690
+ test('๐Ÿ”ด and it never comes back describing the wrong film', async () => {
1691
+ // The half a "did it disappear" check cannot see. If the dialog were merely hidden and
1692
+ // re-shown, or if `grabbing` held the OLD item, this is where that shows up โ€” the title is
1693
+ // read straight off the item every save handler is closed over.
1694
+ const view = render(gallery('vid1'));
1695
+ await openChooser(view, 'first.mp4');
1696
+ view.rerender(gallery('vid2'));
1697
+ await waitFor(() => expect(document.body.textContent).not.toContain('Pick a moment in'));
1698
+ await openChooser(view, 'second.mp4');
1699
+ // Scoped to the DIALOG: `first.mp4` is legitimately on the page, in the "Files" strip
1700
+ // beside the stage. What must not name it is the chooser.
1701
+ const dialog = document.body.querySelector('[role="dialog"]');
1702
+ expect(dialog?.textContent, 'the chooser is still holding the old film').not.toContain(
1703
+ 'first.mp4',
1704
+ );
1705
+ });
1706
+
1707
+ test('closing the file entirely leaves it too', async () => {
1708
+ const view = render(gallery('vid1'));
1709
+ await openChooser(view, 'first.mp4');
1710
+ // Escape / the back arrow โ€” the app clears the selection.
1711
+ view.rerender(
1712
+ <MediaGallery
1713
+ items={VIDEOS}
1714
+ selectedId={null}
1715
+ nativeVideoStage
1716
+ onSaveThumbnail={() => {}}
1717
+ onSaveSnapshot={() => {}}
1718
+ />,
1719
+ );
1720
+ await waitFor(() => expect(document.body.textContent).not.toContain('Pick a moment in'));
1721
+ });
1722
+
1723
+ test('๐Ÿ”ด while the chooser is open, Space does not toggle the film BEHIND it', async () => {
1724
+ /*
1725
+ * The owner, same report: *"the space bar should toggle playing the video no matter what is
1726
+ * focused but isn't working on the thumbnail selection page."*
1727
+ *
1728
+ * It was working โ€” on the wrong film. This component binds `isPlayPauseSpace` at CAPTURE on
1729
+ * `window` whenever a file is open, so a press inside the dialog reached the STAGE player
1730
+ * first. `stopPropagation` is no help: it does not stop other listeners on the same target,
1731
+ * so once `FrameGrabber` bound the same rule BOTH films would have toggled on one press.
1732
+ * The gallery yields instead โ€” whoever is on top owns the key.
1733
+ */
1734
+ const view = render(gallery('vid1'));
1735
+ const stage = view.container.querySelector('.cbmg-stage-video') as HTMLVideoElement;
1736
+ let paused = true;
1737
+ const calls: string[] = [];
1738
+ Object.defineProperty(stage, 'paused', { configurable: true, get: () => paused });
1739
+ stage.play = (() => {
1740
+ calls.push('play');
1741
+ paused = false;
1742
+ return Promise.resolve();
1743
+ }) as HTMLVideoElement['play'];
1744
+ stage.pause = () => {
1745
+ calls.push('pause');
1746
+ paused = true;
1747
+ };
1748
+
1749
+ // With no dialog up the gallery owns the key โ€” the behaviour this must not cost us.
1750
+ const press = () => {
1751
+ const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true });
1752
+ Object.defineProperty(event, 'target', { value: document.body });
1753
+ window.dispatchEvent(event);
1754
+ return event.defaultPrevented;
1755
+ };
1756
+ expect(press()).toBe(true);
1757
+ expect(calls).toEqual(['play']);
1758
+
1759
+ await openChooser(view, 'first.mp4');
1760
+ // The dialog's own `<video>` is what a press must reach now; happy-dom implements neither
1761
+ // method on it, so it is stubbed to keep the press from throwing rather than to be asserted
1762
+ // on โ€” `FrameGrabber.spec.tsx` owns that half.
1763
+ const inDialog = document.body.querySelector(
1764
+ '[role="dialog"] video',
1765
+ ) as HTMLVideoElement | null;
1766
+ expect(inDialog, 'the chooser rendered no player').not.toBeNull();
1767
+ Object.defineProperty(inDialog, 'paused', { configurable: true, get: () => true });
1768
+ if (inDialog) {
1769
+ inDialog.play = (() => Promise.resolve()) as HTMLVideoElement['play'];
1770
+ inDialog.pause = () => {};
1771
+ }
1772
+
1773
+ press();
1774
+ expect(calls, 'the film behind the dialog moved').toEqual(['play']);
1775
+ });
1776
+ });
1777
+
1778
+ /**
1779
+ * ๐Ÿ”ด The strip under the stage keeps the file that is PLAYING, and keeps its place.
1780
+ *
1781
+ * Owner, `ideas/collections-09-14.md`: *"In collections sometimes I click on a video and while
1782
+ * it's playing I click on a different video in the thumbnails under the playing video and the
1783
+ * video I click on gets taken out of the list. I want to change this cb media gallery behavior
1784
+ * so that I can still see the playing video in the list and nothing about the list changes as I
1785
+ * pick other videos."*
1786
+ *
1787
+ * The cause was one line โ€” the strip was `displayItems.filter(i => i.id !== selected.id)` โ€” and
1788
+ * the consequence is the one he describes: each pick removed the tile he had just clicked and
1789
+ * put the previous one back, so every tile between the two moved by one. *"Nothing about the
1790
+ * list changes"* is a claim about the ids AND their ORDER, which is why the first test below
1791
+ * compares the whole sequence rather than a count; a count cannot tell a re-sort from a rest.
1792
+ */
1793
+ describe('MediaGallery โ€” the strip keeps the file that is playing', () => {
1794
+ /** Five clips, `createdAt` descending so the default Newest sort is the order written here. */
1795
+ const CLIPS: MediaGalleryItem[] = ['one', 'two', 'three', 'four', 'five'].map((word, i) => ({
1796
+ id: `c${i}`,
1797
+ name: `${word}.mp4`,
1798
+ streamUrl: `/files/c${i}`,
1799
+ posterUrl: `/thumbs/c${i}`,
1800
+ mime: 'video/mp4',
1801
+ durationSeconds: 10 + i,
1802
+ createdAt: 500 - i * 10,
1803
+ }));
1804
+
1805
+ /** Every tile caption the gallery is drawing, in the order it draws them. */
1806
+ const captions = (c: HTMLElement): (string | null)[] =>
1807
+ [...c.querySelectorAll('.cbgd-meta-title')].map((el) => el.textContent);
1808
+
1809
+ const title = (c: HTMLElement) => c.querySelector('.cbgd-watch-title')?.textContent;
1810
+
1811
+ const open = (view: ReturnType<typeof render>, name: string) =>
1812
+ fireEvent.click(view.getByRole('button', { name: `Open ${name}` }));
1813
+
1814
+ test('๐Ÿ”ด the ids AND their order are identical before and after every pick', () => {
1815
+ const view = render(<MediaGallery items={CLIPS} />);
1816
+ const browse = captions(view.container);
1817
+ expect(browse).toEqual(['one.mp4', 'two.mp4', 'three.mp4', 'four.mp4', 'five.mp4']);
1818
+
1819
+ // The first pick. Before the fix this came back four long, missing `two.mp4` โ€” the tile he
1820
+ // had just clicked, gone from under his pointer.
1821
+ open(view, 'two.mp4');
1822
+ expect(captions(view.container), 'the strip dropped the file it opened').toEqual(browse);
1823
+
1824
+ // โ€ฆand it stays that way however many he picks, including picking the one already playing.
1825
+ for (const name of ['four.mp4', 'one.mp4', 'five.mp4', 'five.mp4', 'three.mp4']) {
1826
+ open(view, name);
1827
+ expect(captions(view.container), `the strip moved when ${name} was picked`).toEqual(browse);
1828
+ expect(title(view.container)).toBe(name);
1829
+ }
1830
+ });
1831
+
1832
+ test('๐Ÿ”ด the one that is playing is IN the strip, and is marked', () => {
1833
+ const view = render(<MediaGallery items={CLIPS} />);
1834
+ open(view, 'three.mp4');
1835
+
1836
+ const marked = view.container.querySelectorAll('[data-playing]');
1837
+ expect(marked.length, 'exactly one tile is the one on the stage').toBe(1);
1838
+ const tile = marked[0] as HTMLElement;
1839
+ expect(tile.textContent).toContain('three.mp4');
1840
+ expect(tile.className).toContain('is-playing');
1841
+ // A ring drawn by a stylesheet says nothing to a screen reader; these two do.
1842
+ expect(tile.getAttribute('aria-current')).toBe('true');
1843
+ expect(tile.textContent).toContain('Now playing');
1844
+
1845
+ // The mark MOVES rather than accumulating, and the tile it leaves goes back to plain.
1846
+ open(view, 'five.mp4');
1847
+ const after = view.container.querySelectorAll('[data-playing]');
1848
+ expect(after.length).toBe(1);
1849
+ expect((after[0] as HTMLElement).textContent).toContain('five.mp4');
1850
+ });
1851
+
1852
+ test('a still is "On screen", not "Now playing" โ€” the album holds any kind of file', () => {
1853
+ const view = render(<MediaGallery items={ITEMS} />);
1854
+ open(view, 'beach.jpg');
1855
+ const tile = view.container.querySelector('[data-playing]');
1856
+ expect(tile?.textContent).toContain('beach.jpg');
1857
+ expect(tile?.textContent).toContain('On screen');
1858
+ expect(tile?.textContent).not.toContain('Now playing');
1859
+ });
1860
+
1861
+ test('๐Ÿ”ด the heading no longer says "More files" โ€” the open one is one of them', () => {
1862
+ const view = render(<MediaGallery items={CLIPS} />);
1863
+ open(view, 'two.mp4');
1864
+ const head = view.container.querySelector('.cbgd-section-head');
1865
+ expect(head?.textContent).toBe('Files');
1866
+ });
1867
+
1868
+ test('the TABLE view of the same strip marks the same row, and keeps it', () => {
1869
+ const view = render(<MediaGallery items={CLIPS} />);
1870
+ fireEvent.click(view.getByRole('button', { name: /Table/ }));
1871
+ open(view, 'two.mp4');
1872
+
1873
+ const rows = view.container.querySelectorAll('.cbgt-row');
1874
+ expect(rows.length, 'the open file left the table').toBe(CLIPS.length);
1875
+ const playing = view.container.querySelectorAll('.cbgt-row.is-playing');
1876
+ expect(playing.length).toBe(1);
1877
+ expect((playing[0] as HTMLElement).textContent).toContain('two.mp4');
1878
+ expect((playing[0] as HTMLElement).getAttribute('aria-current')).toBe('true');
1879
+ expect((playing[0] as HTMLElement).getAttribute('data-playing')).toBe('true');
1880
+ // The name a screen reader reads, since the row's mark is a tint and a bar.
1881
+ expect((playing[0] as HTMLElement).textContent).toContain('Now playing');
1882
+ });
1883
+
1884
+ /*
1885
+ * ๐Ÿ”ด The trap the strip change walks straight into, if `advance` ever reads the strip.
1886
+ *
1887
+ * `nextInRun` is handed `displayIds` โ€” the whole displayed order, which has ALWAYS included
1888
+ * the open item โ€” and it excludes the current one itself (`playbackRun.ts`, asserted there
1889
+ * over hundreds of steps with an injected generator). What that pure test cannot see is the
1890
+ * WIRING: an `advance` that started counting the strip instead would now be offered the item
1891
+ * already on the stage, and `shuffle` would sit on one clip replaying it forever โ€” the exact
1892
+ * failure `playbackPreferences.ts` describes for `loop` + `ended`.
1893
+ */
1894
+ const cycleTo = (view: ReturnType<typeof render>, label: RegExp) => {
1895
+ for (let i = 0; i < 6; i++) {
1896
+ const button = view.getByRole('button', { name: /^When an item ends:/ });
1897
+ // The pill is a bare glyph โ€” see the note on the other `cycleTo` in this file.
1898
+ if (label.test(button.getAttribute('aria-label') ?? '')) return;
1899
+ fireEvent.click(button);
1900
+ }
1901
+ throw new Error(`the playback button never reached ${label}`);
1902
+ };
1903
+
1904
+ /** End the clip on the stage the way the element does. */
1905
+ const endStage = (view: ReturnType<typeof render>) => {
1906
+ const video = view.container.querySelector('.cbmg-stage-video');
1907
+ expect(video, 'the native stage did not render a player').not.toBeNull();
1908
+ if (video) fireEvent.ended(video);
1909
+ };
1910
+
1911
+ test('๐Ÿ”ด shuffle never offers the item already playing as its next', () => {
1912
+ const view = render(<MediaGallery items={CLIPS} nativeVideoStage />);
1913
+ open(view, 'one.mp4');
1914
+ cycleTo(view, /Shuffle/);
1915
+ // Ten hand-offs over five clips: two full passes plus the refill between them, which is
1916
+ // where a shuffle that counts the current item as a candidate repeats.
1917
+ for (let i = 0; i < 10; i++) {
1918
+ const before = title(view.container);
1919
+ endStage(view);
1920
+ expect(title(view.container), `shuffle replayed ${before}`).not.toBe(before);
1921
+ }
1922
+ });
1923
+
1924
+ test('๐Ÿ”ด play all steps to the NEXT file, never back to the one that just ended', () => {
1925
+ const view = render(<MediaGallery items={CLIPS} nativeVideoStage />);
1926
+ open(view, 'one.mp4');
1927
+ cycleTo(view, /Play all/);
1928
+ // The displayed order, wrapping past the last โ€” and never the item that just played.
1929
+ for (const next of ['two.mp4', 'three.mp4', 'four.mp4', 'five.mp4', 'one.mp4']) {
1930
+ endStage(view);
1931
+ expect(title(view.container)).toBe(next);
1932
+ }
1933
+ });
1934
+
1935
+ test('โ‡ง-arrow stepping still walks the displayed order, which now matches the strip', () => {
1936
+ const view = render(<MediaGallery items={CLIPS} />);
1937
+ open(view, 'two.mp4');
1938
+ // Dispatched at the BODY rather than at `window`: the gallery's handler is a capture
1939
+ // listener on the window, so a press anywhere reaches it โ€” and `fireEvent` is what wraps
1940
+ // the state change it causes in `act`, which a raw `dispatchEvent` does not.
1941
+ const press = (key: string) => fireEvent.keyDown(document.body, { key, shiftKey: true });
1942
+ press('ArrowRight');
1943
+ expect(title(view.container)).toBe('three.mp4');
1944
+ press('ArrowLeft');
1945
+ press('ArrowLeft');
1946
+ expect(title(view.container)).toBe('one.mp4');
1947
+ // The strip did not move while the stepping did.
1948
+ expect(captions(view.container)).toEqual([
1949
+ 'one.mp4',
1950
+ 'two.mp4',
1951
+ 'three.mp4',
1952
+ 'four.mp4',
1953
+ 'five.mp4',
1954
+ ]);
1955
+ });
1956
+ });
1957
+
1958
+ /**
1959
+ * The REVEAL โ€” bringing the marked tile back into view.
1960
+ *
1961
+ * Owner, same report: *"I can still see the playing video in the list."* A tile that stays in
1962
+ * the list but forty rows above the fold is not one he can see, so the strip scrolls to it โ€”
1963
+ * the same thing `FileTree`'s reveal does, with the same two limits (only when it is off
1964
+ * screen, never mid-drag) and one more: only while the stage is PINNED, because the watch view
1965
+ * is one scroller and scrolling the strip in the unpinned mode would scroll the playing film
1966
+ * off the top, which is the 2026-08-14 report ("navigate automatically back to the top to see
1967
+ * the movie I am playing") re-opened.
1968
+ *
1969
+ * happy-dom lays nothing out, so every rect in this file is the same 800ร—600 stub โ€” which
1970
+ * reads as "everything is on screen" and would make the reveal untestable. These tests give
1971
+ * the marked tile a rect of its own, which is the only part of the geometry that matters.
1972
+ */
1973
+ describe('MediaGallery โ€” the playing tile is scrolled back into view', () => {
1974
+ const CLIPS: MediaGalleryItem[] = ['one', 'two', 'three', 'four', 'five'].map((word, i) => ({
1975
+ id: `c${i}`,
1976
+ name: `${word}.mp4`,
1977
+ streamUrl: `/files/c${i}`,
1978
+ posterUrl: `/thumbs/c${i}`,
1979
+ mime: 'video/mp4',
1980
+ createdAt: 500 - i * 10,
1981
+ }));
1982
+
1983
+ const rect = (top: number, bottom: number): DOMRect =>
1984
+ ({
1985
+ top,
1986
+ bottom,
1987
+ height: bottom - top,
1988
+ left: 0,
1989
+ right: 800,
1990
+ width: 800,
1991
+ x: 0,
1992
+ y: top,
1993
+ toJSON: () => ({}),
1994
+ }) as DOMRect;
1995
+
1996
+ /** The file-wide stub, restored after each test here. */
1997
+ const FLAT = HTMLElement.prototype.getBoundingClientRect;
1998
+ afterEach(() => {
1999
+ HTMLElement.prototype.getBoundingClientRect = FLAT;
2000
+ });
2001
+
2002
+ /**
2003
+ * Lay the view out: the scroller and the pinned stage as written, the MARKED tile wherever
2004
+ * the test puts it. Everything else keeps the file's flat 0โ€“600 stub.
2005
+ */
2006
+ const layOut = (playing: DOMRect) => {
2007
+ HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement): DOMRect {
2008
+ if (this.getAttribute('data-playing') === 'true') return playing;
2009
+ // The pinned top region covers the first half of the scroller, so a tile revealed to
2010
+ // the frame's own top edge would land behind the film.
2011
+ if (this.classList.contains('cbgd-watch-top')) return rect(0, 300);
2012
+ return rect(0, 600);
2013
+ };
2014
+ };
2015
+
2016
+ /** Open a file with the stage pinned, and hand back the watch scroller. */
2017
+ const pinned = (view: ReturnType<typeof render>, name: string): HTMLElement => {
2018
+ fireEvent.click(view.getByRole('button', { name: `Open ${name}` }));
2019
+ fireEvent.click(view.getByRole('button', { name: /Pin/ }));
2020
+ return view.container.querySelector('.cbgd-watch') as HTMLElement;
2021
+ };
2022
+
2023
+ test('๐Ÿ”ด a tile BELOW the fold is brought up to the bottom edge', () => {
2024
+ const view = render(<MediaGallery items={CLIPS} />);
2025
+ const scroller = pinned(view, 'one.mp4');
2026
+ scroller.scrollTop = 0;
2027
+ // The next pick's tile is 300px below the strip's bottom edge.
2028
+ layOut(rect(900, 1100));
2029
+ fireEvent.click(view.getByRole('button', { name: 'Open three.mp4' }));
2030
+ // Exactly enough to bring its bottom edge into the frame, and not one pixel more.
2031
+ expect(scroller.scrollTop).toBe(500);
2032
+ });
2033
+
2034
+ test('๐Ÿ”ด a tile hidden BEHIND the pinned stage is brought below it, not to the top', () => {
2035
+ const view = render(<MediaGallery items={CLIPS} />);
2036
+ const scroller = pinned(view, 'one.mp4');
2037
+ scroller.scrollTop = 400;
2038
+ // 100โ€“250: inside the scroller, but under the pinned region that ends at 300. Revealing
2039
+ // to the frame's top (`scrollIntoView`'s `nearest`) would leave it there, behind the film.
2040
+ layOut(rect(100, 250));
2041
+ fireEvent.click(view.getByRole('button', { name: 'Open three.mp4' }));
2042
+ expect(scroller.scrollTop).toBe(200);
2043
+ });
2044
+
2045
+ test('a tile already in view does not move the strip โ€” limit 2', () => {
2046
+ const view = render(<MediaGallery items={CLIPS} />);
2047
+ const scroller = pinned(view, 'one.mp4');
2048
+ scroller.scrollTop = 120;
2049
+ layOut(rect(320, 460));
2050
+ fireEvent.click(view.getByRole('button', { name: 'Open three.mp4' }));
2051
+ expect(scroller.scrollTop).toBe(120);
2052
+ });
2053
+
2054
+ test('๐Ÿ”ด UNPINNED, the strip does not move โ€” going back to the stage wins', () => {
2055
+ const view = render(<MediaGallery items={CLIPS} />);
2056
+ fireEvent.click(view.getByRole('button', { name: 'Open one.mp4' }));
2057
+ const scroller = view.container.querySelector('.cbgd-watch') as HTMLElement;
2058
+ scroller.scrollTop = 0;
2059
+ layOut(rect(900, 1100));
2060
+ fireEvent.click(view.getByRole('button', { name: 'Open three.mp4' }));
2061
+ // The effect above this one scrolls the STAGE back into view; a reveal here would undo it
2062
+ // every single time and play the film off the top of the screen.
2063
+ expect(scroller.scrollTop).toBe(0);
2064
+ });
2065
+
2066
+ /*
2067
+ * Limit 3 โ€” *never mid-drag* โ€” is vacuous in this strip rather than skipped, and this is what
2068
+ * keeps it that way: the detail view hands its tiles no `drag` prop, so there is no drag of
2069
+ * ours to scroll out from under. A strip that becomes a drag source fails here, which is the
2070
+ * moment to write the guard `FileTree` has.
2071
+ */
2072
+ test('the strip is not a drag source, which is what makes the reveal safe mid-drag', () => {
2073
+ const view = render(<MediaGallery items={CLIPS} onItemDragStart={() => {}} />);
2074
+ // The BROWSE grid is one โ€” that is the feature, and it is what makes this assertion mean
2075
+ // something rather than pass by accident.
2076
+ expect(view.container.querySelector('.cbmg-selectable[draggable="true"]')).not.toBeNull();
2077
+ fireEvent.click(view.getByRole('button', { name: 'Open one.mp4' }));
2078
+ expect(view.container.querySelector('.cbmg-selectable[draggable="true"]')).toBeNull();
2079
+ });
2080
+ });
2081
+
2082
+ /**
2083
+ * โ”€โ”€ ๐Ÿ”ด The icon-only toolbar โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2084
+ *
2085
+ * Owner, `ideas/collections-09-14.md`, 2026-09-14: *"In the cb media galleries like collections
2086
+ * uses I want to make the toolbar look more professional. Make it have icons only, instead of
2087
+ * words in the strip of tools where have things like pin, stop at the end, and turn video.
2088
+ * Hovering over the tooltip can show the words."*
2089
+ *
2090
+ * A visual change, so what is worth asserting is exactly what a visual change silently drops:
2091
+ *
2092
+ * 1. **The name.** A pill whose only content is an `<svg>` has no accessible name unless
2093
+ * somebody put one there. Deleting the words deletes the name unless an `aria-label`
2094
+ * takes over, and nothing about the page LOOKS broken when it happens โ€” the toolbar is
2095
+ * simply unusable with a screen reader from that commit on. Every control the owner
2096
+ * named is listed here, plus the ones in the same strip he meant by *"things like"*.
2097
+ * 2. **The state that was a word.** `๐Ÿ“Œ Pin` vs `๐Ÿ“Œ Pinned` and the five end-behaviour
2098
+ * labels were readouts, not names. One glyph in two states removes information he was
2099
+ * reading off the strip, so the value has to be somewhere a test can see it.
2100
+ * 3. **That the words are actually GONE.** Otherwise "icons only" is a change that half
2101
+ * happened โ€” a pill still printing its label passes (1) and (2) perfectly.
2102
+ *
2103
+ * The 44px floor is the fourth thing this change could drop and is deliberately NOT here:
2104
+ * happy-dom computes no layout, so every rectangle in this file is the 800ร—600 stub at the
2105
+ * top. `e2e/touch-targets.spec.ts` measures it in a real engine on a coarse pointer, which is
2106
+ * this repo's standing lesson (task 69, and the 36ร—36 false green of 2026-09-14).
2107
+ */
2108
+ describe('MediaGallery โ€” the toolbar is icons, and the words are its names', () => {
2109
+ /** Every capability wired, so the whole strip renders on one item. */
2110
+ const wired = (items: MediaGalleryItem[]) =>
2111
+ render(
2112
+ <MediaGallery
2113
+ items={items}
2114
+ onRename={() => {}}
2115
+ onSaveThumbnail={() => {}}
2116
+ onSaveEdit={() => {}}
2117
+ onSaveOrientation={() => {}}
2118
+ onTrimVideo={() => {}}
2119
+ onSaveChapters={() => {}}
2120
+ onDelete={() => {}}
2121
+ />,
2122
+ );
2123
+
2124
+ test('๐Ÿ”ด every converted control still exposes its accessible name', () => {
2125
+ const view = wired(ITEMS);
2126
+ fireEvent.click(view.getByRole('button', { name: 'Open clip.mp4' }));
2127
+ for (const name of [
2128
+ // The three the owner named by handโ€ฆ
2129
+ 'Pin the stage',
2130
+ 'When an item ends: Stop at the end',
2131
+ 'Turn video',
2132
+ // โ€ฆand the rest of the same strip, which "things like" is his phrasing for.
2133
+ 'Rename',
2134
+ // `clip.mp4` already carries a `posterUrl`, so it is CHANGE rather than choose โ€” which
2135
+ // is the distinction the next test is about.
2136
+ 'Change thumbnail',
2137
+ 'Trim',
2138
+ 'Sections',
2139
+ 'Picture',
2140
+ 'Delete this item',
2141
+ 'Back to grid',
2142
+ 'Fullscreen',
2143
+ ]) {
2144
+ expect(view.getByRole('button', { name }), `no control named "${name}"`).toBeDefined();
2145
+ }
2146
+ });
2147
+
2148
+ test('๐Ÿ”ด โ€ฆand so does the download, which is a LINK โ€” same rule, different role', () => {
2149
+ // On the photograph, because `clip.mp4` in this file's fixture has a `streamUrl` and no
2150
+ // downloadable `url` โ€” so the film renders no link at all and the assertion would be
2151
+ // measuring an absence.
2152
+ const view = wired(ITEMS);
2153
+ fireEvent.click(view.getByRole('button', { name: 'Open beach.jpg' }));
2154
+ const link = view.getByRole('link', { name: 'Download beach.jpg' });
2155
+ expect(link.getAttribute('href')).toBe('/files/img1');
2156
+ expect(link.textContent?.trim()).toBe('');
2157
+ });
2158
+
2159
+ test('๐Ÿ”ด the strip prints no words at all โ€” the half of "icons only" a name check cannot see', () => {
2160
+ const view = wired(ITEMS);
2161
+ fireEvent.click(view.getByRole('button', { name: 'Open clip.mp4' }));
2162
+ const row = view.container.querySelector('.cbgd-watch-actions') as HTMLElement;
2163
+ expect(row).not.toBeNull();
2164
+ const wordy = [...row.querySelectorAll<HTMLElement>('.cbgd-order-btn')]
2165
+ .filter((el) => (el.textContent ?? '').trim().length > 0)
2166
+ .map((el) => `${el.getAttribute('aria-label')}: "${el.textContent?.trim()}"`);
2167
+ expect(wordy, `pills still printing a label:\n${wordy.join('\n')}`).toEqual([]);
2168
+ // โ€ฆand it is a real strip that was measured, not an empty one the filter walked past.
2169
+ expect(row.querySelectorAll('.cbgd-order-btn').length).toBeGreaterThan(8);
2170
+ });
2171
+
2172
+ test('๐Ÿ”ด Pin says WHICH WAY it goes โ€” the state that used to be the word', () => {
2173
+ const view = wired(ITEMS);
2174
+ fireEvent.click(view.getByRole('button', { name: 'Open clip.mp4' }));
2175
+ const pin = () => view.getByRole('button', { name: /[Pp]in/ });
2176
+ // Unpinned: not pressed, not lit, and the name is the verb.
2177
+ expect(pin().getAttribute('aria-pressed')).toBe('false');
2178
+ expect(pin().className).not.toContain('is-active');
2179
+ expect(pin().getAttribute('aria-label')).toBe('Pin the stage');
2180
+
2181
+ fireEvent.click(pin());
2182
+ // Pinned: all three flip together. The lit pill is what a sighted reader sees instead of
2183
+ // the word "Pinned" โ€” this control had no `is-active` at all before the conversion,
2184
+ // because the word was carrying the whole state on its own.
2185
+ expect(pin().getAttribute('aria-pressed')).toBe('true');
2186
+ expect(pin().className).toContain('is-active');
2187
+ expect(pin().getAttribute('aria-label')).toBe('Pinned โ€” unpin the stage');
2188
+ });
2189
+
2190
+ test('๐Ÿ”ด the end-behaviour control names its CURRENT setting, all five of them', () => {
2191
+ const view = wired(ITEMS);
2192
+ fireEvent.click(view.getByRole('button', { name: 'Open beach.jpg' }));
2193
+ const button = () => view.getByRole('button', { name: /^When an item ends:/ });
2194
+ const seen: string[] = [];
2195
+ for (let i = 0; i < 5; i++) {
2196
+ seen.push(button().getAttribute('aria-label') ?? '');
2197
+ fireEvent.click(button());
2198
+ }
2199
+ expect(seen).toEqual([
2200
+ 'When an item ends: Stop at the end',
2201
+ 'When an item ends: Repeat one',
2202
+ 'When an item ends: Play next',
2203
+ 'When an item ends: Play all',
2204
+ 'When an item ends: Shuffle',
2205
+ ]);
2206
+ });
2207
+
2208
+ test('๐Ÿ”ด โ€ฆand it draws a DIFFERENT glyph for each one, so the value is legible unhovered', () => {
2209
+ const view = wired(ITEMS);
2210
+ fireEvent.click(view.getByRole('button', { name: 'Open beach.jpg' }));
2211
+ const button = () => view.getByRole('button', { name: /^When an item ends:/ });
2212
+ // lucide stamps the icon's name onto the rendered `<svg>` as a class โ€” `lucide-repeat-1`,
2213
+ // `lucide-shuffle` โ€” which is the one handle a DOM test has on which glyph is drawn. Five
2214
+ // settings sharing one icon would collapse this set to a single entry.
2215
+ const glyphs = new Set<string>();
2216
+ for (let i = 0; i < 5; i++) {
2217
+ glyphs.add(button().querySelector('svg')?.getAttribute('class') ?? '');
2218
+ fireEvent.click(button());
2219
+ }
2220
+ expect(glyphs.size).toBe(5);
2221
+ });
2222
+
2223
+ test('the Picture pill says so when the light has been moved, not only when it is open', () => {
2224
+ const view = wired(ITEMS);
2225
+ fireEvent.click(view.getByRole('button', { name: 'Open beach.jpg' }));
2226
+ // The `ยท` this pill used to print meant "you have already moved these sliders" โ€” it is a
2227
+ // word in the name now, because a bare glyph has nowhere to put a dot.
2228
+ expect(view.getByRole('button', { name: 'Picture' })).toBeDefined();
2229
+ fireEvent.click(view.getByRole('button', { name: 'Picture' }));
2230
+ const slider = view.container.querySelector<HTMLInputElement>(
2231
+ '.cbmg-picture-sliders input[type="range"]',
2232
+ ) as HTMLInputElement;
2233
+ // `LIGHT_RANGE` is 0.2โ€“2 around a neutral 1, not a percentage.
2234
+ fireEvent.change(slider, { target: { value: '1.3' } });
2235
+ expect(view.getByRole('button', { name: 'Picture โ€” adjusted' })).toBeDefined();
2236
+ });
2237
+
2238
+ test('the thumbnail control still says whether a poster exists โ€” three different names', () => {
2239
+ const noPoster: MediaGalleryItem[] = [
2240
+ { id: 'p1', name: 'clip.mp4', streamUrl: '/f/p1', createdAt: 1 },
2241
+ ];
2242
+ const a = wired(noPoster);
2243
+ fireEvent.click(a.getByRole('button', { name: 'Open clip.mp4' }));
2244
+ expect(a.getByRole('button', { name: 'Choose thumbnail' })).toBeDefined();
2245
+ a.unmount();
2246
+
2247
+ // `onSaveSnapshot` without `onSaveThumbnail` is a different capability, not a different
2248
+ // wording โ€” hence a camera rather than a picture frame.
2249
+ const b = render(<MediaGallery items={noPoster} onSaveSnapshot={() => {}} />);
2250
+ fireEvent.click(b.getByRole('button', { name: 'Open clip.mp4' }));
2251
+ expect(b.getByRole('button', { name: 'Take a picture' })).toBeDefined();
2252
+ });
2253
+
2254
+ test('the header pills and the selection bar went with it, rather than half converting', () => {
2255
+ const view = render(<MediaGallery items={ITEMS} selectable selectionReveal='on-demand' />);
2256
+ for (const name of ['Grid', 'Table', 'Select']) {
2257
+ expect(view.getByRole('button', { name }), `no control named "${name}"`).toBeDefined();
2258
+ }
2259
+ expect(view.getByRole('button', { name: 'Grid' }).textContent?.trim()).toBe('');
2260
+ expect(view.getByRole('button', { name: 'Table' }).textContent?.trim()).toBe('');
2261
+ // Which one is live is still readable without a word, because they are a PAIR.
2262
+ expect(view.getByRole('button', { name: 'Grid' }).getAttribute('aria-pressed')).toBe('true');
2263
+ expect(view.getByRole('button', { name: 'Table' }).getAttribute('aria-pressed')).toBe('false');
2264
+ // The count stays a WORD: it is a value you read, and the bar is the only place it is said.
2265
+ expect(view.getByText('Select all')).toBeDefined();
2266
+ });
2267
+ });
2268
+
2269
+ /**
2270
+ * โ”€โ”€ ๐Ÿ”ด Tagging and describing โ€” the capability, not a flag โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
2271
+ *
2272
+ * Owner, `ideas/collections-09-14.md`, 2026-09-14: *"In media galleries have an option for apps
2273
+ * to tag media and aadd descriptions to media. In collections allow these features so I can tag
2274
+ * and add descriptions to its media."*
2275
+ *
2276
+ * The gallery could already READ `tags` and had no way to write one and no `description` field
2277
+ * at all. These assert the three claims that make the new half real: the control is absent
2278
+ * without a handler and present with one (the `onTrimVideo` rule, held here too), a bulk tag
2279
+ * reports every ticked id in ONE call, and a description renders on the stage without touching
2280
+ * the tile caption โ€” which is two lines by design and already contested.
2281
+ */
2282
+ describe('MediaGallery โ€” tags and descriptions', () => {
2283
+ const TAGGABLE: MediaGalleryItem[] = [
2284
+ { id: 'img1', name: 'beach.jpg', url: '/files/img1', createdAt: 30, tags: ['holiday'] },
2285
+ { id: 'img2', name: 'dog.jpg', url: '/files/img2', createdAt: 20 },
2286
+ { id: 'img3', name: 'receipt.pdf', url: '/files/pdf1', createdAt: 10 },
2287
+ ];
2288
+ const boxes = (c: HTMLElement) =>
2289
+ [...c.querySelectorAll('.cbmg-select-box input')] as HTMLInputElement[];
2290
+
2291
+ test('๐Ÿ”ด the control appears only when the app can STORE the answer', () => {
2292
+ const withoutHandler = render(<MediaGallery items={TAGGABLE} />);
2293
+ fireEvent.click(withoutHandler.getByRole('button', { name: 'Open beach.jpg' }));
2294
+ expect(withoutHandler.queryByRole('button', { name: 'Tags and description' })).toBeNull();
2295
+ withoutHandler.unmount();
2296
+
2297
+ const withHandler = render(<MediaGallery items={TAGGABLE} onSaveMeta={() => {}} />);
2298
+ fireEvent.click(withHandler.getByRole('button', { name: 'Open beach.jpg' }));
2299
+ expect(withHandler.queryByRole('button', { name: 'Tags and description' })).not.toBeNull();
2300
+ });
2301
+
2302
+ test('๐Ÿ”ด the bulk verb is gated by the same handler, and by nothing else', () => {
2303
+ const { container, queryByRole, rerender } = render(
2304
+ <MediaGallery items={TAGGABLE} selectable />,
2305
+ );
2306
+ fireEvent.click(boxes(container)[0] as HTMLInputElement);
2307
+ expect(queryByRole('button', { name: /^Tag the 1 selected item$/ })).toBeNull();
2308
+
2309
+ rerender(<MediaGallery items={TAGGABLE} selectable onSaveMeta={() => {}} />);
2310
+ expect(queryByRole('button', { name: /^Tag the 1 selected item$/ })).not.toBeNull();
2311
+ });
2312
+
2313
+ test('๐Ÿ”ด a bulk tag reports EVERY selected id in one call, not one call per item', async () => {
2314
+ // The whole reason the handler takes a list. A gallery that looped the singular would call
2315
+ // this three times with one item, and an app re-deriving its rows between the writes is how
2316
+ // a batch tag half-lands โ€” the failure `tasks/Completed/240-*.md` records for the tree's
2317
+ // batch move, in the same words.
2318
+ const calls: { ids: string[]; add: string[]; remove: string[] }[] = [];
2319
+ const { container, getByRole, getByLabelText, findByRole } = render(
2320
+ <MediaGallery
2321
+ items={TAGGABLE}
2322
+ selectable
2323
+ onSaveMeta={(items, change) => {
2324
+ calls.push({
2325
+ ids: items.map((i) => i.id),
2326
+ add: change.addTags,
2327
+ remove: change.removeTags,
2328
+ });
2329
+ }}
2330
+ />,
2331
+ );
2332
+ fireEvent.click(container.querySelector('.cbmg-bulkbar-all input') as HTMLInputElement);
2333
+ fireEvent.click(getByRole('button', { name: 'Tag the 3 selected items' }));
2334
+
2335
+ const add = (await findByRole('dialog')) && getByLabelText('Tags to add to all 3 selected items');
2336
+ fireEvent.change(add, { target: { value: 'greece' } });
2337
+ fireEvent.keyDown(add, { key: 'Enter' });
2338
+ fireEvent.click(getByRole('button', { name: /^Save$/ }));
2339
+
2340
+ await waitFor(() => expect(calls).toHaveLength(1));
2341
+ expect(calls[0]).toEqual({
2342
+ ids: ['img1', 'img2', 'img3'],
2343
+ add: ['greece'],
2344
+ remove: [],
2345
+ });
2346
+ });
2347
+
2348
+ test('๐Ÿ”ด the single-item save arrives as the SAME add/remove pair a bulk one sends', async () => {
2349
+ // An array rather than a `let โ€ฆ = null`: TypeScript narrows a local that is only ever
2350
+ // written inside a callback back to `null`, so the assertion below would not compile.
2351
+ const calls: { ids: string[]; add: string[]; remove: string[]; description?: string }[] = [];
2352
+ const { getByRole, getByLabelText, findByRole } = render(
2353
+ <MediaGallery
2354
+ items={TAGGABLE}
2355
+ onSaveMeta={(items, change) => {
2356
+ calls.push({
2357
+ ids: items.map((i) => i.id),
2358
+ add: change.addTags,
2359
+ remove: change.removeTags,
2360
+ ...(change.description === undefined ? {} : { description: change.description }),
2361
+ });
2362
+ }}
2363
+ />,
2364
+ );
2365
+ fireEvent.click(getByRole('button', { name: 'Open beach.jpg' }));
2366
+ fireEvent.click(getByRole('button', { name: 'Tags and description' }));
2367
+ await findByRole('dialog');
2368
+
2369
+ const tags = getByLabelText('Tags for beach.jpg');
2370
+ fireEvent.change(tags, { target: { value: 'greece' } });
2371
+ fireEvent.keyDown(tags, { key: 'Enter' });
2372
+ fireEvent.change(getByLabelText('Description of beach.jpg'), {
2373
+ target: { value: 'The last morning in Naxos.' },
2374
+ });
2375
+ fireEvent.click(getByRole('button', { name: /^Save$/ }));
2376
+
2377
+ await waitFor(() => expect(calls).toHaveLength(1));
2378
+ expect(calls[0]).toEqual({
2379
+ ids: ['img1'],
2380
+ add: ['greece'],
2381
+ remove: [],
2382
+ description: 'The last morning in Naxos.',
2383
+ });
2384
+ });
2385
+
2386
+ test('๐Ÿ”ด the bulk form offers no description โ€” one sentence cannot describe three files', async () => {
2387
+ const { container, getByRole, queryByLabelText, findByRole } = render(
2388
+ <MediaGallery items={TAGGABLE} selectable onSaveMeta={() => {}} />,
2389
+ );
2390
+ fireEvent.click(container.querySelector('.cbmg-bulkbar-all input') as HTMLInputElement);
2391
+ fireEvent.click(getByRole('button', { name: 'Tag the 3 selected items' }));
2392
+ await findByRole('dialog');
2393
+ expect(queryByLabelText(/^Description of/)).toBeNull();
2394
+ expect(queryByLabelText('Tags to remove from all 3 selected items')).not.toBeNull();
2395
+ });
2396
+
2397
+ test('a description RENDERS on the stage, under the title', () => {
2398
+ const described: MediaGalleryItem[] = [
2399
+ {
2400
+ id: 'img1',
2401
+ name: 'beach.jpg',
2402
+ url: '/files/img1',
2403
+ createdAt: 30,
2404
+ description: 'The last morning in Naxos.',
2405
+ },
2406
+ ];
2407
+ const { container, getByRole, getByText } = render(<MediaGallery items={described} />);
2408
+ fireEvent.click(getByRole('button', { name: 'Open beach.jpg' }));
2409
+ expect(getByText('The last morning in Naxos.')).toBeDefined();
2410
+ // Its own element rather than appended to the ` ยท ` facts line, which is nowrap with an
2411
+ // ellipsis because the same class captions every tile in the grid.
2412
+ expect(container.querySelector('.cbmg-stage-description')?.textContent).toBe(
2413
+ 'The last morning in Naxos.',
2414
+ );
2415
+ });
2416
+
2417
+ test('๐Ÿ”ด reading a description needs NO handler โ€” only writing is gated', () => {
2418
+ const described: MediaGalleryItem[] = [
2419
+ { id: 'img1', name: 'beach.jpg', url: '/files/img1', createdAt: 30, description: 'Naxos.' },
2420
+ ];
2421
+ const { container, getByRole } = render(<MediaGallery items={described} />);
2422
+ fireEvent.click(getByRole('button', { name: 'Open beach.jpg' }));
2423
+ expect(container.querySelector('.cbmg-stage-description')).not.toBeNull();
2424
+ expect(container.querySelector('button[aria-label="Tags and description"]')).toBeNull();
2425
+ });
2426
+
2427
+ test('๐Ÿ”ด the TILE caption is unchanged by a description', () => {
2428
+ // The caption is two lines by design and already contested between the tags and the
2429
+ // measurement; a sentence there would be an ellipsis or a third line on every tile.
2430
+ const bare: MediaGalleryItem = { id: 'i', name: 'beach.jpg', sizeBytes: 2_048, createdAt: 1 };
2431
+ const described: MediaGalleryItem = { ...bare, description: 'A very long sentence indeed.' };
2432
+ expect(galleryTileLine(described)).toBe(galleryTileLine(bare));
2433
+ expect(galleryTileLine(described, { measure: true })).toBe(
2434
+ galleryTileLine(bare, { measure: true }),
2435
+ );
2436
+
2437
+ const { container } = render(<MediaGallery items={[described]} />);
2438
+ expect(container.querySelector('.cbgd-meta-line')?.textContent).toBe('2.0 KB');
2439
+ });
2440
+
2441
+ test('a gallery with no description renders exactly the meta line it rendered before', () => {
2442
+ const { container, getByRole } = render(<MediaGallery items={TAGGABLE} />);
2443
+ fireEvent.click(getByRole('button', { name: 'Open beach.jpg' }));
2444
+ const line = container.querySelector('.cbgd-watch-titlebar .cbgd-meta-line');
2445
+ expect(line?.querySelector('.cbmg-stage-description')).toBeNull();
2446
+ expect(line?.textContent).toContain('holiday');
2447
+ });
2448
+ });