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,5 @@
1
1
  import './denseGallery.css';
2
+ import { ChevronLeft, ChevronRight, Maximize2, Minimize2 } from 'lucide-react';
2
3
  import {
3
4
  type ButtonHTMLAttributes,
4
5
  type CSSProperties,
@@ -43,11 +44,28 @@ export interface GalleryActionButtonProps
43
44
  active?: boolean;
44
45
  /** Hover/focus explanation. A pill with a non-obvious effect should always carry one. */
45
46
  tooltip?: ReactNode;
47
+ /**
48
+ * Draw `children` as a bare glyph in a square pill instead of a labelled one โ€” the shape
49
+ * the whole gallery toolbar uses since 2026-09-16 (owner: *"Make it have icons only,
50
+ * instead of words in the strip of tools"*).
51
+ *
52
+ * ๐Ÿ”ด An `aria-label` becomes REQUIRED, and this prop cannot supply one for you: a pill
53
+ * whose only content is an `<svg>` has no accessible name at all, and a toolbar of
54
+ * unnamed glyphs is unusable with a screen reader. The same bargain
55
+ * {@link DeleteButton}'s `iconOnly` already makes โ€” named so a consumer meets one word
56
+ * for one idea across the design system.
57
+ *
58
+ * The 44px tap floor comes with it, in `denseGallery.css`'s `.is-icon` โ€” a consumer
59
+ * cannot get that wrong from out here, which is the reason this is a prop rather than a
60
+ * class name in a doc.
61
+ */
62
+ iconOnly?: boolean;
46
63
  }
47
64
 
48
65
  export function GalleryActionButton({
49
66
  active,
50
67
  tooltip,
68
+ iconOnly,
51
69
  children,
52
70
  type,
53
71
  ...props
@@ -57,7 +75,7 @@ export function GalleryActionButton({
57
75
  // guardrails-ignore no-raw-action-button: THE shared dense-gallery action pill โ€”
58
76
  // this is the component consumers use instead of hand-rolling one.
59
77
  type={type === 'submit' || type === 'reset' ? type : 'button'}
60
- className={`cbgd-order-btn${active ? ' is-active' : ''}`}
78
+ className={`cbgd-order-btn${active ? ' is-active' : ''}${iconOnly ? ' is-icon' : ''}`}
61
79
  {...(active === undefined ? {} : { 'aria-pressed': active })}
62
80
  {...props}
63
81
  >
@@ -363,7 +381,7 @@ export interface DenseGalleryDetailProps {
363
381
  /** See {@link DenseGalleryShellProps.rowExtraHeight} โ€” leave unset. */
364
382
  rowExtraHeight?: number;
365
383
  /**
366
- * Full-stage mode as a CONTROLLED value โ€” the shell's own `โคข Fullscreen`, which hides the
384
+ * Full-stage mode as a CONTROLLED value โ€” the shell's own `Fullscreen`, which hides the
367
385
  * rail and the related grid so the picture takes the whole container.
368
386
  *
369
387
  * Omit both and the toggle keeps its own state, which is what every consumer had before
@@ -385,7 +403,7 @@ export interface DenseGalleryDetailProps {
385
403
  * `false` (the default) for everything else, and since 2026-09-11 that is a FEATURE rather
386
404
  * than an absence. Owner: *"Doubleclicking an image to toggle full screen is not working at
387
405
  * all and may be a new feature to add now."* A still picture has no player and therefore no
388
- * gesture of its own, so the shell's `โคข Fullscreen` is what a double-click means on one โ€”
406
+ * gesture of its own, so the shell's `Fullscreen` is what a double-click means on one โ€”
389
407
  * in BOTH directions, which is the half that was missing (it used only to LEAVE).
390
408
  *
391
409
  * ๐Ÿ”ด Either way the shell still handles the leave while `fullStage` is on, because the video
@@ -451,6 +469,20 @@ export function DenseGalleryDetail({
451
469
  * pass. A consumer that knows better still wins โ€” it sets `--cbgd-stage-h`, which
452
470
  * this never writes.
453
471
  */
472
+ /**
473
+ * How tall the resize rail is, right now.
474
+ *
475
+ * ๐Ÿ”ด Lifted into state on 2026-09-16 because TWO things need it and they must not disagree:
476
+ * the automatic fit below, and the DRAG's own ceiling (`containerPx`, further down). The
477
+ * rail is the strip of chrome the grips moved into when they came off the picture โ€” 64px on
478
+ * a mouse, 84px under a coarse pointer โ€” and a `MAX_STAGE_SHARE` computed against a
479
+ * container that pretends the rail is not there hands the stage 86% of a box it does not
480
+ * have, which puts the related grid back below the fold at the top of the drag. That is the
481
+ * 2026-08-23 *"the bottom gets cut off โ€ฆ instead of showing more of the thumbnails under
482
+ * it"* report, arriving from the control added to serve it, for the second time.
483
+ */
484
+ const [railPx, setRailPx] = useState(0);
485
+
454
486
  useEffect(() => {
455
487
  if (!watchEl || typeof ResizeObserver === 'undefined') return;
456
488
  const host = watchEl.parentElement ?? watchEl;
@@ -458,21 +490,46 @@ export function DenseGalleryDetail({
458
490
  const apply = () => {
459
491
  const available = host.clientHeight;
460
492
  if (!available) return; // unmeasurable โ€” the stylesheet's default stands
461
- // ๐Ÿ”ด Minus the resize bar, which is chrome the stage OWNS but is not part of the
462
- // frame it sizes. Once the player became a column (see `.cbgd-watch-player`) the bar
463
- // took real height out of the same box, so a fit computed from the raw container
464
- // overshot by exactly that much and pushed the last row of thumbnails through the
465
- // bottom of the gallery โ€” the 2026-08-23 report, arriving from a control added to
466
- // serve it. Measured from the element rather than written as a constant, because a
467
- // constant here is a second copy of a number that lives in `mediaResize.css`.
468
- const grip = watchEl.querySelector('.cbmr-bar');
493
+ const grip = watchEl.querySelector('.cbmr-rail');
469
494
  const gripPx = grip ? Math.round(grip.getBoundingClientRect().height) : 0;
495
+ // Compared before setting, like every other measurement in this file: an observer that
496
+ // wrote state on each sub-pixel reflow would re-render the whole detail view forever.
497
+ setRailPx((current) => (current === gripPx ? current : gripPx));
498
+ /*
499
+ * ๐Ÿ”ด Minus EVERY pixel of the top region that is not the frame โ€” the resize rail, the
500
+ * titlebar and the padding around both.
501
+ *
502
+ * `STAGE_SHARE` is a share of what is LEFT for the grid to live in, so a fit computed
503
+ * from the raw container hands the stage 58% of a box that also has to hold the chrome,
504
+ * and the grid gets 42% minus all of it. That is the 2026-08-23 *"the bottom gets cut
505
+ * off for empty space instead of showing more of the thumbnails under it"* report, and
506
+ * it arrives from whatever control is added under the picture next.
507
+ *
508
+ * It used to subtract only `.cbmr-bar`, which was a 22px strip and left the sum just
509
+ * inside the margin. The grips moved into a 64px rail on 2026-09-16 and it stopped
510
+ * being inside it โ€” `e2e/stage-layout.spec.ts` caught the first row of tiles hanging
511
+ * 10px through the bottom of the gallery โ€” so the measurement is the general one now
512
+ * rather than one more named control.
513
+ *
514
+ * ๐Ÿ”ด A DIFFERENCE, and that is what makes it loop-proof: the chrome's height does not
515
+ * depend on the frame's, so shrinking the frame shrinks the region and leaves this
516
+ * number alone. The same shape `FocusViewer` uses for its own ceiling. Falls back to
517
+ * the rail alone if either box is missing, which is never worse than what it replaced.
518
+ */
519
+ const top = watchEl.querySelector('.cbgd-watch-top');
520
+ const frame = watchEl.querySelector('.cbgd-watch-player-box');
521
+ const chromePx =
522
+ top && frame
523
+ ? Math.round(
524
+ top.getBoundingClientRect().height - frame.getBoundingClientRect().height,
525
+ )
526
+ : gripPx;
470
527
  // A ternary rather than `Math.max`, deliberately: `denseGallery.layout.spec.ts` asserts
471
528
  // the fit expression contains no `max(` โ€” the CSS `min()` there may never become a
472
529
  // `max()`, because a rule that can GROW the stage tracks the content it is measuring
473
530
  // and chases itself. That guard reads the source text, so the JS clamp is written in a
474
531
  // form that cannot be mistaken for the thing it forbids.
475
- const usable = available > gripPx ? available - gripPx : 0;
532
+ const usable = available > chromePx ? available - chromePx : 0;
476
533
  const fit = `min(${Math.round(usable * STAGE_SHARE)}px, var(--cbgd-stage-default))`;
477
534
  if (fit === last) return;
478
535
  last = fit;
@@ -559,7 +616,11 @@ export function DenseGalleryDetail({
559
616
  handle: resizeHandle,
560
617
  } = useMediaResize({
561
618
  storageKey: STAGE_HEIGHT_KEY,
562
- containerPx: watchEl?.clientHeight ?? 0,
619
+ // ๐Ÿ”ด Minus the rail โ€” the SAME subtraction the automatic fit makes a few lines up, and for
620
+ // the same reason. `MAX_STAGE_SHARE` promises a band of tiles is still on screen at the
621
+ // largest size the handle can reach; that promise is about the space the FRAME may take,
622
+ // and the rail is chrome below the frame rather than part of it.
623
+ containerPx: Math.max(0, (watchEl?.clientHeight ?? 0) - railPx),
563
624
  frameEl,
564
625
  });
565
626
 
@@ -582,7 +643,7 @@ export function DenseGalleryDetail({
582
643
  <div className='cbgd-watch-main'>
583
644
  <div className='cbgd-watch-player'>
584
645
  {/* biome-ignore lint/a11y/noStaticElementInteractions: the frame is a picture,
585
- not a control โ€” the double-click is a SHORTCUT for the `โคข Fullscreen` toggle
646
+ not a control โ€” the double-click is a SHORTCUT for the `Fullscreen` toggle
586
647
  in the titlebar, which is the keyboard-reachable route and stays the only
587
648
  one a screen reader is told about. See `onStageDoubleClick`. */}
588
649
  <div
@@ -595,27 +656,26 @@ export function DenseGalleryDetail({
595
656
  onDoubleClick={onStageDoubleClick}
596
657
  >
597
658
  {stage}
598
- {/*
599
- * The corner grip โ€” the owner's literal words, *"dragging the corner"*.
600
- *
601
- * ๐Ÿ”ด Inside the clipped frame, on the picture. The note that used to stand
602
- * here said a handle in this box "would be painted over by a full-bleed
603
- * video and unreachable"; the big โŸจ โŸฉ step arrows that USED to hang in this
604
- * same box were the counter-example โ€” absolutely positioned over arbitrary
605
- * media, clickable every day they existed. (They are gone: the owner asked
606
- * for them off the sides of the video on 2026-08-25, and stepping now lives
607
- * in the titlebar and the player's control bar. Do not read this as a reason
608
- * to bring them back.) What the old reasoning bought instead was a bar nobody
609
- * found, reported on 2026-08-25 as the feature not existing.
610
- */}
611
- {fullStage ? null : <MediaResizeCorner {...resizeHandle} label='Viewer height' />}
612
659
  </div>
613
660
  {/*
614
- * And the bottom edge, kept beside the corner: a corner is a small target on a
615
- * phone, and on a portrait clip the picture's corner is empty letterboxing
616
- * while the bottom edge is always exactly where the picture ends.
661
+ * ๐Ÿ”ด The resize rail โ€” a SIBLING of the frame, never a child of it.
662
+ *
663
+ * Owner, `ideas/collections-09-14.md`: *"Fix the drag to resize area to no longer
664
+ * overlap part of the horizontal play timeline area โ€ฆ It should only be draggable
665
+ * when my mouse pointer is at the bottom right corner of the video player, just
666
+ * like mac windows for other apps work."* The corner grip was a 64px overlay in
667
+ * the box above until 2026-09-16, and no gutter in the player's control bar can
668
+ * clear it โ€” `mediaResize.css`'s header has the measurement. Out here, nothing of
669
+ * either grip is inside the frame at any size.
670
+ *
671
+ * The bar beside it is shown on coarse pointers only; see `MediaResize.tsx`.
617
672
  */}
618
- {fullStage ? null : <MediaResizeBar {...resizeHandle} label='Viewer height' />}
673
+ {fullStage ? null : (
674
+ <div className='cbmr-rail'>
675
+ <MediaResizeBar {...resizeHandle} label='Viewer height' />
676
+ <MediaResizeCorner {...resizeHandle} label='Viewer height' />
677
+ </div>
678
+ )}
619
679
  </div>
620
680
  <div className='cbgd-watch-titlebar'>
621
681
  <span className='cbgd-watch-title'>{title}</span>
@@ -640,21 +700,28 @@ export function DenseGalleryDetail({
640
700
  */}
641
701
  {onStep ? (
642
702
  <span className='cbgd-watch-step'>
703
+ {/* Lucide chevrons rather than the `โ€น โ€บ` characters these were until
704
+ 2026-09-16: the rest of this row is a lucide set now, and a typographic
705
+ glyph beside real icons is the seam that reads as unfinished โ€” its
706
+ weight, its baseline and its optical size come from whichever font the
707
+ host happens to have. */}
643
708
  <GalleryActionButton
709
+ iconOnly
644
710
  tooltip='Previous file (โ‡งโ†)'
645
711
  aria-label='Previous file'
646
712
  disabled={!canStep}
647
713
  onClick={() => onStep(-1)}
648
714
  >
649
- <span aria-hidden='true'>โ€น</span>
715
+ <ChevronLeft aria-hidden />
650
716
  </GalleryActionButton>
651
717
  <GalleryActionButton
718
+ iconOnly
652
719
  tooltip='Next file (โ‡งโ†’)'
653
720
  aria-label='Next file'
654
721
  disabled={!canStep}
655
722
  onClick={() => onStep(1)}
656
723
  >
657
- <span aria-hidden='true'>โ€บ</span>
724
+ <ChevronRight aria-hidden />
658
725
  </GalleryActionButton>
659
726
  </span>
660
727
  ) : null}
@@ -669,11 +736,16 @@ export function DenseGalleryDetail({
669
736
  // pill styling as the sort pills above โ€” a UI-preference toggle (not
670
737
  // persisted, not destructive) styled by this module's own plain CSS.
671
738
  type='button'
672
- className='cbgd-order-btn'
739
+ className='cbgd-order-btn is-icon'
673
740
  aria-pressed={fullStage}
741
+ // ๐Ÿ”ด The word moved into the accessible name rather than being deleted with
742
+ // the glyph. It is also the STATE โ€” `Shrink` is only offered while the stage
743
+ // is already filling the frame โ€” so a reader who cannot see which of the two
744
+ // arrows is drawn still learns which way this press goes.
745
+ aria-label={fullStage ? 'Shrink' : 'Fullscreen'}
674
746
  onClick={() => setFullStage(!fullStage)}
675
747
  >
676
- {fullStage ? 'โคก Shrink' : 'โคข Fullscreen'}
748
+ {fullStage ? <Minimize2 aria-hidden /> : <Maximize2 aria-hidden />}
677
749
  </button>
678
750
  </Tooltip>
679
751
  </span>
@@ -680,16 +680,23 @@ export function FocusViewer({
680
680
  />
681
681
  {marquee ? <span className="inspector__marquee" style={marqueeStyle(marquee)} /> : null}
682
682
  </div>
683
- {/*
684
- * ๐Ÿ”ด A child of the VIEWPORT, not of `.inspector__frame`. The frame's every
685
- * fraction is a coordinate โ€” the marquee, the pan and the snapshot crop are all
686
- * fractions of that box โ€” so an extra child there is a pointer target sitting
687
- * inside the drawing surface, and its `onPointerDown` would race the marquee's.
688
- * The viewport is the same rectangle visually and owns no geometry.
689
- */}
683
+ </div>
684
+ {/*
685
+ * ๐Ÿ”ด The resize rail โ€” OUTSIDE the viewport, the same shape the gallery stage uses.
686
+ *
687
+ * The corner grip used to be a child of `.inspector__viewport` (never of
688
+ * `.inspector__frame`, whose every fraction is a marquee/pan/crop coordinate). It is
689
+ * out here now for the 2026-09-14 report โ€” *"It should only be draggable when my mouse
690
+ * pointer is at the bottom right corner โ€ฆ just like mac windows"* โ€” and because the
691
+ * bar's old `margin-block: -11px` reach hung over the picture above it AND over
692
+ * `.inspector__timeline` below it, which is the *"horizontal play timeline area"* in
693
+ * that same sentence. Both grips are inside the strip now, and the viewport is once
694
+ * again exactly the picture โ€” no pointer target of ours inside the drawing surface.
695
+ */}
696
+ <div className="cbmr-rail">
697
+ <MediaResizeBar {...resizeHandle} label="Picture height" />
690
698
  <MediaResizeCorner {...resizeHandle} label="Picture height" />
691
699
  </div>
692
- <MediaResizeBar {...resizeHandle} label="Picture height" />
693
700
 
694
701
  <div className="inspector__transport">
695
702
  <Button onClick={() => stepFrame(-1)} tooltip="Back one frame (โ†)" variant="ghost">
@@ -0,0 +1,175 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { readFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { cleanup, render } from '@testing-library/react';
5
+ import { FrameGrabber } from './FrameGrabber';
6
+
7
+ afterEach(cleanup);
8
+
9
+ // The dialog is a Radix portal, so everything it renders is queried off `document.body`.
10
+ const body = () => document.body;
11
+
12
+ /**
13
+ * The dialog's `<video>`, with a recording stub for the two methods happy-dom does not
14
+ * implement. Same technique, and the same reason, as `MediaGallery.spec.tsx`'s.
15
+ */
16
+ function stubVideo(): { calls: string[]; video: HTMLVideoElement } {
17
+ const video = body().querySelector('video') as HTMLVideoElement;
18
+ expect(video, 'the dialog rendered no <video> to toggle').not.toBeNull();
19
+ let paused = true;
20
+ const calls: string[] = [];
21
+ Object.defineProperty(video, 'paused', { configurable: true, get: () => paused });
22
+ video.play = (() => {
23
+ calls.push('play');
24
+ paused = false;
25
+ return Promise.resolve();
26
+ }) as HTMLVideoElement['play'];
27
+ video.pause = () => {
28
+ calls.push('pause');
29
+ paused = true;
30
+ };
31
+ return { calls, video };
32
+ }
33
+
34
+ /** Dispatch a bare Space at `target`, and report whether the dialog claimed it. */
35
+ function press(target: EventTarget): boolean {
36
+ const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true });
37
+ Object.defineProperty(event, 'target', { value: target });
38
+ window.dispatchEvent(event);
39
+ return event.defaultPrevented;
40
+ }
41
+
42
+ function open(props: { open?: boolean; startAt?: number } = {}) {
43
+ return render(
44
+ <FrameGrabber
45
+ src='/films/wedding.mp4'
46
+ name='wedding.mp4'
47
+ onClose={() => {}}
48
+ onSaveThumbnail={() => {}}
49
+ onSaveSnapshot={() => {}}
50
+ {...props}
51
+ />,
52
+ );
53
+ }
54
+
55
+ /**
56
+ * ๐Ÿ”ด SPACE IS PLAY/PAUSE IN HERE TOO.
57
+ *
58
+ * The owner, 2026-09-14, on the thumbnail chooser in `apps/collections`: *"the space bar
59
+ * should toggle playing the video no matter what is focused but isn't working on the
60
+ * thumbnail selection page."*
61
+ *
62
+ * Finding one frame in a two-hour wedding video is not a job for a slider you nudge โ€” you let
63
+ * it run and stop where you want it. The press was not being dropped, either: `MediaGallery`
64
+ * binds the same rule at capture on `window`, so it was toggling the STAGE player behind this
65
+ * dialog, a film the reader cannot see. That handler now stands down while a capability dialog
66
+ * is open (asserted in `MediaGallery.spec.tsx`), and this is the other half.
67
+ */
68
+ describe('๐Ÿ”ด FrameGrabber โ€” Space toggles the clip you are scrubbing', () => {
69
+ test('a press with nothing focused plays, and the next one pauses', () => {
70
+ open();
71
+ const { calls } = stubVideo();
72
+ expect(press(body()), 'the dialog did not claim the press').toBe(true);
73
+ expect(calls).toEqual(['play']);
74
+ press(body());
75
+ expect(calls).toEqual(['play', 'pause']);
76
+ });
77
+
78
+ test('๐Ÿ”ด โ€ฆand it still toggles while one of the dialogโ€™s own buttons has focus', () => {
79
+ // The state the hand is actually in: Cancel, Centre it, a crop pill or the segment was the
80
+ // last thing pressed, and the browser would otherwise activate it on the Space KEYUP. That
81
+ // inversion is the whole point of `playerSpaceKey` โ€” see its header.
82
+ open();
83
+ const { calls } = stubVideo();
84
+ const cancel = Array.from(body().querySelectorAll('button')).find(
85
+ (b) => b.textContent === 'Cancel',
86
+ );
87
+ expect(cancel, 'no Cancel button to focus').toBeDefined();
88
+ expect(press(cancel as HTMLElement)).toBe(true);
89
+ expect(calls).toEqual(['play']);
90
+ });
91
+
92
+ test('๐Ÿ”ด a press on the MOMENT slider toggles playback rather than nudging the frame', () => {
93
+ // `<input type="range">` is deliberately NOT text entry: the dialog is made of sliders, so
94
+ // standing down for one would turn the shortcut off exactly where the pointer has been.
95
+ open();
96
+ const { calls } = stubVideo();
97
+ const slider = body().querySelector('input[type="range"]') as HTMLElement;
98
+ expect(slider, 'the dialog rendered no slider').not.toBeNull();
99
+ expect(press(slider)).toBe(true);
100
+ expect(calls).toEqual(['play']);
101
+ });
102
+
103
+ test('typing a space in a text field is never stolen', () => {
104
+ open();
105
+ const { calls } = stubVideo();
106
+ for (const tag of ['input', 'textarea'] as const) {
107
+ const field = document.createElement(tag);
108
+ body().appendChild(field);
109
+ expect(press(field), `${tag} lost its space`).toBe(false);
110
+ field.remove();
111
+ }
112
+ const editable = document.createElement('div');
113
+ editable.setAttribute('contenteditable', 'true');
114
+ body().appendChild(editable);
115
+ expect(press(editable), 'a contenteditable lost its space').toBe(false);
116
+ editable.remove();
117
+ expect(calls, 'a text field must never reach the player').toEqual([]);
118
+ });
119
+
120
+ test('a CLOSED grabber does not own the space bar', () => {
121
+ // It is mounted-but-closed for as long as a caller holds `open={false}`, and a dialog
122
+ // nobody can see claiming a global key is the bug one file over.
123
+ open({ open: false });
124
+ expect(body().querySelector('video'), 'a closed dialog rendered a player').toBeNull();
125
+ expect(press(body())).toBe(false);
126
+ });
127
+
128
+ test('๐Ÿ”ด the rule is IMPORTED from playerSpaceKey, not re-implemented here', () => {
129
+ // The three previous copies of this rule disagreed in the one case the owner hits, which is
130
+ // why `playerSpaceKey.ts` exists. A behavioural test cannot tell a fourth copy that happens
131
+ // to agree today from the shared rule; this can.
132
+ const source = readFileSync(resolve(import.meta.dir, 'FrameGrabber.tsx'), 'utf8');
133
+ expect(source).toInclude("from './playerSpaceKey'");
134
+ expect(source).toInclude('usePlayPauseSpace(');
135
+ expect(source, 'a hand-rolled Space test means the shared rule was bypassed').not.toInclude(
136
+ "=== ' '",
137
+ );
138
+ });
139
+ });
140
+
141
+ /**
142
+ * The owner's OTHER ask about this dialog, from 2026-08-13 โ€” *"should open to the same spot in
143
+ * the timeline I click it at because that's usually about where I want the snapshot"* โ€” was
144
+ * already built. This is the check that it stays built: `startAt` is React state from the first
145
+ * render, so the readout says where it opened before the clip has decoded a single frame.
146
+ */
147
+ describe('FrameGrabber โ€” it opens at the playhead it was handed', () => {
148
+ test('the Moment readout shows the carried time, not 0:00', () => {
149
+ open({ startAt: 214.6 });
150
+ const readout = body().querySelector('.cbpe-readout');
151
+ expect(readout?.textContent).toBe('3:34');
152
+ });
153
+
154
+ test('no playhead means the start of the clip', () => {
155
+ open();
156
+ expect(body().querySelector('.cbpe-readout')?.textContent).toBe('0:00');
157
+ });
158
+ });
159
+
160
+ /**
161
+ * ๐Ÿ”ด The way out is SAID, not discovered.
162
+ *
163
+ * Owner, 2026-09-14: *"I did not see the 'Done' button for a while and thought that refreshing
164
+ * was the only way I could find to get out of it."* The button was on screen โ€” that is measured
165
+ * at his window height in `e2e/dialog-reach.spec.ts` โ€” so this is the second, separate defect in
166
+ * the same sentence, and it is not one geometry can answer.
167
+ */
168
+ describe('FrameGrabber โ€” the exit is named in the dialog itself', () => {
169
+ test('the subtitle says where the keep button is and what Escape does', () => {
170
+ open();
171
+ const text = (document.body.querySelector('[role="dialog"]')?.textContent ?? '').toLowerCase();
172
+ expect(text, 'nothing tells the reader where the keep button is').toContain('bottom');
173
+ expect(text, 'nothing tells the reader how to leave without saving').toContain('escape');
174
+ });
175
+ });
@@ -16,6 +16,7 @@ import {
16
16
  NO_LIGHT,
17
17
  type PictureLight,
18
18
  } from './pictureEdit';
19
+ import { usePlayPauseSpace } from './playerSpaceKey';
19
20
  import {
20
21
  DEFAULT_FRAMING,
21
22
  drawFramedPoster,
@@ -101,6 +102,29 @@ type Grab = 'thumbnail' | 'picture';
101
102
  * gallery and two cannot be kept. That is the case the ruling bites on, so the button SAYS
102
103
  * it is a replacement when there is something to replace (`hasPoster`) rather than quietly
103
104
  * overwriting the poster somebody chose last week.
105
+ *
106
+ * โ”€โ”€ ๐Ÿ”ด STILL A DIALOG, and the owner asked for a page. Here is the reasoning โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
107
+ * `ideas/collections-09-14.md`, 2026-09-14, ends: *"Let's put the thumbnail selector on it's own
108
+ * page to make this less confusing."* Read with the sentence before it, that is a proposed FIX
109
+ * for four complaints, not a fifth want โ€” the footer he could not reach, the folders that would
110
+ * not respond, the selection that did not clear this away, and the space bar.
111
+ *
112
+ * It stays a dialog, because all four are now answered where they belong and a page would cost
113
+ * the one thing this shape buys: **the gallery's player stays mounted behind it**, which is what
114
+ * makes "open at the moment I was watching" a carried number rather than a second load and a
115
+ * second seek of a two-hour film. A route would also have to invent a way back, and "back" from
116
+ * a route is the browser's history โ€” the control that, in the report one file over, took him out
117
+ * of the app.
118
+ *
119
+ * ยท the footer cannot leave the screen: `Modal` caps the card and pins the action bar, and
120
+ * `e2e/dialog-reach.spec.ts` MEASURES both at a 420px-tall window (proven to go red when
121
+ * the cap is removed โ€” no unit test can see it, happy-dom computes no layout);
122
+ * ยท selecting anything else leaves this screen: `MediaGallery` clears the dialog with the
123
+ * selection, which is the wire a sidebar is on;
124
+ * ยท Space plays and pauses the clip you are scrubbing, through `playerSpaceKey`;
125
+ * ยท and the way out is now SAID rather than discovered โ€” see the `subtitle` below, which is
126
+ * the half of *"I did not see the 'Done' button for a while and thought that refreshing was
127
+ * the only way I could find to get out of it"* that geometry does not fix.
104
128
  */
105
129
  export function FrameGrabber({
106
130
  src,
@@ -178,6 +202,38 @@ export function FrameGrabber({
178
202
  return () => stage.removeEventListener('wheel', onWheel);
179
203
  }, [grab]);
180
204
 
205
+ /*
206
+ * ๐Ÿ”ด Space is play/pause HERE TOO, through the fleet's one rule rather than a fourth copy.
207
+ *
208
+ * The owner, 2026-09-14: *"the space bar should toggle playing the video no matter what is
209
+ * focused but isn't working on the thumbnail selection page."*
210
+ *
211
+ * Two things were wrong and only one of them was here. `MediaGallery` binds the same rule at
212
+ * capture on `window` whenever a file is open, so the press WAS being handled โ€” against the
213
+ * stage player behind this dialog, a film the reader cannot see. That handler now stands
214
+ * down while a capability dialog is up (see its comment), which leaves the key for this one.
215
+ *
216
+ * Finding the moment is what this dialog is for, and letting it RUN is how you find it: a
217
+ * two-hour wedding video is not scrubbable a slider-nudge at a time. `usePlayPauseSpace`
218
+ * carries the whole rule โ€” capture phase, `preventDefault` on the keydown so a focused
219
+ * Cancel button is not activated on the keyup, and the text-entry exemption. Every control
220
+ * in here is a button or a `range`, which that rule deliberately takes the key back from.
221
+ *
222
+ * `enabled: open` rather than always: this component is mounted by a caller that may hold it
223
+ * closed, and a closed dialog owning the space bar is the bug one file over.
224
+ */
225
+ usePlayPauseSpace(
226
+ () => {
227
+ const video = videoRef.current;
228
+ if (!video) return;
229
+ // `.play()` rejects if the element is torn down mid-gesture; there is nothing to say
230
+ // about that to somebody who pressed a key, and an unhandled rejection is noise.
231
+ if (video.paused) void video.play().catch(() => {});
232
+ else video.pause();
233
+ },
234
+ { enabled: open },
235
+ );
236
+
181
237
  const setCrop = (rect: CropRect | undefined) => setCropState(rect);
182
238
 
183
239
  /** A pointer position as a fraction of the stage box โ€” the marquee's coordinate system. */
@@ -292,6 +348,17 @@ export function FrameGrabber({
292
348
  onClose={onClose}
293
349
  size='lg'
294
350
  title={name ? `Pick a moment in ${name}` : 'Pick a moment'}
351
+ /*
352
+ * ๐Ÿ”ด The way OUT, stated. Owner, 2026-09-14: *"I did not see the 'Done' button for a
353
+ * while and thought that refreshing was the only way I could find to get out of it."*
354
+ *
355
+ * The button was on screen (that is measured now, at his window height), so this is not
356
+ * a layout fix wearing a sentence โ€” it is the other defect in the same report. The
357
+ * dialog is a wide two-column workspace whose one exit is a pill in the bottom corner,
358
+ * and a reader hunting for it has no reason to believe the corner is where it lives.
359
+ * `DESIGN.md` ยง6 keeps this to two muted lines; one is enough.
360
+ */
361
+ subtitle='Scrub to the moment you want, then keep it with the button at the bottom. Escape leaves it as it was.'
295
362
  scrollBody
296
363
  /*
297
364
  * Scrubbing to a moment, framing it and re-lighting it IS unsaved work, even though