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
@@ -68,8 +68,10 @@ function Harness({ containerPx = 1000 }: { containerPx?: number }) {
68
68
  return (
69
69
  <div>
70
70
  <output data-testid='height'>{height === null ? 'auto' : String(height)}</output>
71
- <MediaResizeCorner {...handle} label='Corner' />
72
- <MediaResizeBar {...handle} label='Bar' />
71
+ <div className='cbmr-rail'>
72
+ <MediaResizeBar {...handle} label='Bar' />
73
+ <MediaResizeCorner {...handle} label='Corner' />
74
+ </div>
73
75
  </div>
74
76
  );
75
77
  }
@@ -154,6 +156,72 @@ describe('🔴 it is visible at rest — the reported failure', () => {
154
156
  });
155
157
  });
156
158
 
159
+ /**
160
+ * 🔴 The grips are OUTSIDE the picture — the 2026-09-14 report, as the three things that can
161
+ * put them back.
162
+ *
163
+ * Owner, `ideas/collections-09-14.md`: *"Fix the drag to resize area to no longer overlap part
164
+ * of the horizontal play timeline area that shows what part of the video is playing. It should
165
+ * only be draggable when my mouse pointer is at the bottom right corner of the video player,
166
+ * just like mac windows for other apps work."*
167
+ *
168
+ * The RECTANGLES are measured in `e2e/resize-grip-rail.spec.ts`, because that is the only
169
+ * place they exist — happy-dom computes no layout, so a rendered assertion here would report
170
+ * 0×0 for both boxes and agree with anything. These are the cheap regressions that name the
171
+ * reasons: the rail has to hold the grip, the grip has to be bounded BY the rail, and no
172
+ * player may reach back into its frame to draw one.
173
+ */
174
+ describe('🔴 the resize grips live in the rail, not on the picture', () => {
175
+ test('the rail is exactly as tall as the grip it holds — one number, not two', () => {
176
+ // A literal in either place is the desync this couple prevents, and its symptom is a grip
177
+ // clipped by its own strip, which is only visible by hand.
178
+ expect(ruleBody('.cbmr-rail')).toContain('block-size: var(--cbmr-corner-size');
179
+ expect(ruleBody('.cbmr-corner')).toContain('var(--cbmr-corner-size');
180
+ });
181
+
182
+ test('the corner is anchored to the RAIL\'s bottom-right, never to a frame edge', () => {
183
+ const corner = ruleBody('.cbmr-corner');
184
+ expect(corner).toContain('position: absolute');
185
+ expect(corner).toContain('inset-inline-end: 0');
186
+ expect(corner).toContain('inset-block-end: 0');
187
+ // …and the rail is what it is measured against. Without this the grip climbs to whatever
188
+ // positioned ancestor exists, which in the gallery is the frame — the reported bug.
189
+ expect(ruleBody('.cbmr-rail')).toContain('position: relative');
190
+ });
191
+
192
+ test('🔴 the bar keeps its reach INSIDE the strip — no negative margin', () => {
193
+ // `margin-block: -11px` is how the bar's invisible padding came to hang over the picture
194
+ // above it and over the focus viewer's timeline below it. The reach is the rail's job now.
195
+ const bar = ruleBody('.cbmr-bar');
196
+ expect(bar).not.toMatch(/margin-block:\s*-/);
197
+ expect(bar).not.toMatch(/margin:\s*-/);
198
+ });
199
+
200
+ test('the bar is a COARSE-pointer affordance — one drag surface on a desktop', () => {
201
+ // The decision, stated in `MediaResize.tsx`'s header: *"It should only be draggable when
202
+ // my mouse pointer is at the bottom right corner"*. The bar's own reason for existing is
203
+ // about a thumb, so it lives behind the media query that is about the input device.
204
+ expect(ruleBody('.cbmr-bar')).toContain('display: none');
205
+ const coarse = css.slice(css.lastIndexOf('@media (pointer: coarse)'));
206
+ expect(coarse).toContain('.cbmr-bar {');
207
+ expect(coarse).toContain('display: flex');
208
+ });
209
+
210
+ test('🔴 the gallery reserves NO gutter in the player control bar any more', () => {
211
+ const dense = stripComments(readFileSync(join(import.meta.dir, 'denseGallery.css'), 'utf8'));
212
+ // A `padding-inline-end` gutter was the old bargain: raise the grip over Vidstack's
213
+ // control layer, and push the layer's own contents clear of it. It bought a clear corner
214
+ // by spending 64px of the timeline — the thing the owner was complaining about — and it
215
+ // only ever worked on a control bar this package styles. Its return would mean something
216
+ // is back on the picture; `e2e/resize-grip-rail.spec.ts` has the measurements.
217
+ expect(dense).not.toContain('padding-inline-end: var(--cbmr-corner-size');
218
+ expect(dense).not.toContain('.vds-controls-group:last-child');
219
+ // The rail is width-matched to the 16:9 frame instead, so its bottom-right corner IS the
220
+ // picture's bottom-right corner.
221
+ expect(dense).toContain('.cbgd-watch-player > .cbmr-rail');
222
+ });
223
+ });
224
+
157
225
  /**
158
226
  * 🔴 The grip has to WIN against the video player's own control layer.
159
227
  *
@@ -185,17 +253,6 @@ describe('🔴 the corner grip is reachable over a video control bar', () => {
185
253
  expect(z).toBeLessThan(50);
186
254
  });
187
255
 
188
- test('the control bar reserves exactly the corner the grip occupies — one number, not two', () => {
189
- const dense = stripComments(readFileSync(join(import.meta.dir, 'denseGallery.css'), 'utf8'));
190
- // The gutter and the grip must be the SAME declaration. A literal in either file is the
191
- // desync this couple exists to prevent — the symptoms (a grip over the fullscreen
192
- // button, or an empty gutter beside the cog) are only visible by hand.
193
- expect(dense).toContain('padding-inline-end: var(--cbmr-corner-size');
194
- expect(ruleBody('.cbmr-corner')).toContain('var(--cbmr-corner-size');
195
- // …and the gutter must not apply in fullscreen, where the gallery frame — grip included
196
- // — is not in the fullscreen element's tree at all.
197
- expect(dense).toContain(":not([data-fullscreen]) .vds-controls-group:last-child");
198
- });
199
256
  });
200
257
 
201
258
  describe('🔴 every cb media player offers the gesture', () => {
@@ -210,13 +267,26 @@ describe('🔴 every cb media player offers the gesture', () => {
210
267
  const PLAYERS = ['DenseGalleryShell.tsx', 'FocusViewer.tsx'] as const;
211
268
 
212
269
  for (const file of PLAYERS) {
213
- test(`${file} mounts both grips`, () => {
270
+ test(`${file} mounts both grips, in the rail`, () => {
214
271
  const source = readFileSync(join(import.meta.dir, file), 'utf8');
215
272
  expect(source).toContain('MediaResizeCorner');
216
273
  expect(source).toContain('MediaResizeBar');
217
274
  // From the shared hook, never a second hand-rolled copy of the pointer maths — the
218
275
  // drift this file exists to prevent.
219
276
  expect(source).toContain('useMediaResize');
277
+ /*
278
+ * 🔴 And in `.cbmr-rail`, which is the 2026-09-14 fix. The rail is what puts both grips
279
+ * outside the picture; a player that renders a grip anywhere else is the *"drag to
280
+ * resize area … overlap[s] part of the horizontal play timeline"* report, on that one
281
+ * player, with nothing else failing. The same asymmetry — a fix landing on one of two
282
+ * surfaces — is why this whole describe block exists.
283
+ */
284
+ const opens = source.search(/className=['"]cbmr-rail['"]/);
285
+ expect(opens, `${file} renders no <div className='cbmr-rail'>`).toBeGreaterThan(-1);
286
+ const rail = source.slice(opens);
287
+ // Both grips are rendered from INSIDE the rail element, not before it.
288
+ expect(rail).toContain('MediaResizeBar');
289
+ expect(rail).toContain('MediaResizeCorner');
220
290
  });
221
291
  }
222
292
 
@@ -21,13 +21,38 @@
21
21
  * So the gesture is a hook and the affordances are components, and a third player gets both
22
22
  * by calling them rather than by copying eighty lines that drift.
23
23
  *
24
+ * ── 🔴 Both affordances live in a RAIL, below the picture ─────────────────────────────
25
+ * Owner, `ideas/collections-09-14.md`: *"Fix the drag to resize area to no longer overlap
26
+ * part of the horizontal play timeline area that shows what part of the video is playing. It
27
+ * should only be draggable when my mouse pointer is at the bottom right corner of the video
28
+ * player, just like mac windows for other apps work."*
29
+ *
30
+ * So every consumer renders exactly this, directly after the box that holds the picture:
31
+ *
32
+ * <div className='cbmr-rail'>
33
+ * <MediaResizeBar {...handle} label='…' />
34
+ * <MediaResizeCorner {...handle} label='…' />
35
+ * </div>
36
+ *
37
+ * and `MediaResize.spec.tsx` fails if a player stops doing so. The rail is a strip of chrome
38
+ * OUTSIDE the frame — a Mac window's grab zone is the window's own edge, not a patch of its
39
+ * content — which is what makes "nothing of it overlaps the control bar" a property of the
40
+ * layout rather than a gutter that has to be tuned against whatever the player draws. The
41
+ * geometry is proved in `e2e/resize-grip-rail.spec.ts`, in a real browser, at two viewports;
42
+ * happy-dom computes no layout, so nothing under `bun test` can see it.
43
+ *
24
44
  * ── What each affordance is for ───────────────────────────────────────────────────────
25
- * · {@link MediaResizeCorner} — the literal ask. Bottom-right of the picture, on top of it,
26
- * the same overlay convention the gallery's ⟨ ⟩ step arrows already use inside the same
27
- * clipped frame. Visible at rest, because that is the whole point of this pass.
28
- * · {@link MediaResizeBar} — a full-width edge under the frame. Kept because a corner is a
29
- * small target on a phone and because a letterboxed picture's corner is black on a
30
- * portrait clip; the bar is always exactly where the bottom edge is.
45
+ * · {@link MediaResizeCorner} — the literal ask. The rail's bottom-right corner, 64px of
46
+ * reach, visible at rest, because that is the whole point of this pass.
47
+ * · {@link MediaResizeBar} — the rail's full-width edge. 🔴 **Coarse pointers only, and that
48
+ * is this pass's second decision.** *"It should only be draggable when my mouse pointer is
49
+ * at the bottom right corner"* is a verdict on the bar too: on a desktop there is now one
50
+ * drag surface and it is the corner, exactly like a window. The one argument that was ever
51
+ * made for keeping the bar beside the corner is about a THUMB — a corner is a small target
52
+ * on a phone, and on a portrait clip the picture's corner is empty letterboxing while the
53
+ * bottom edge is always where the picture ends — so it survives under `(pointer: coarse)`
54
+ * and nowhere else. It is still rendered by both players at every size; `mediaResize.css`
55
+ * decides where it is shown, because the input device is a media query and not a prop.
31
56
  *
32
57
  * Both drive the same {@link useMediaResize} state, so a viewer may render either or both
33
58
  * and they can never disagree about how tall it is.
@@ -224,14 +249,14 @@ export interface MediaResizeAffordanceProps extends MediaResizeHandleProps {
224
249
  }
225
250
 
226
251
  /**
227
- * The corner grip — *"dragging the corner"*, drawn ON the picture at its bottom-right.
252
+ * The corner grip — *"dragging the corner"*, at the bottom-right of the rail under the
253
+ * picture.
228
254
  *
229
- * 🔴 An overlay INSIDE the clipped frame, deliberately, and the earlier note in
230
- * `DenseGalleryShell` arguing a handle there would be "painted over by a full-bleed video"
231
- * is superseded by the frame's own ⟨ ⟩ step arrows, which are exactly this: absolutely
232
- * positioned children of the same box, over arbitrary media, that have always been
233
- * clickable. Being on the picture is what makes it a CORNER rather than a bar that happens
234
- * to sit near one.
255
+ * 🔴 OUTSIDE the clipped frame since 2026-09-16, and the reason is in `mediaResize.css`'s
256
+ * header: keeping an overlay in that corner clear of a control bar means shortening the
257
+ * control bar, which costs the timeline the width it was complained about for and works only
258
+ * on a player this package styles. *"just like mac windows for other apps work"* — a window's
259
+ * grab zone is its outside corner, not a patch of its content, and the rail is that corner.
235
260
  */
236
261
  export function MediaResizeCorner({
237
262
  label = 'Viewer height',
@@ -256,13 +281,20 @@ export function MediaResizeCorner({
256
281
  }
257
282
 
258
283
  /**
259
- * The bottom edge — a full-width bar under the frame.
284
+ * The bottom edge — the rail's full-width bar.
285
+ *
286
+ * 🔴 **Shown on coarse pointers only** (`mediaResize.css`), which is the decision this
287
+ * component's existence turns on and is argued in this file's header: on a desktop the owner
288
+ * asked for one drag surface, in the corner, and the bar's own stated reason — a corner is a
289
+ * small target for a thumb, and a letterboxed picture's corner is empty black on a portrait
290
+ * clip while the bottom edge is always where the picture ends — is entirely about touch. It
291
+ * is rendered unconditionally so the two players cannot disagree about the markup; where it
292
+ * is SHOWN is a media query, because the input device is not something a prop can know.
260
293
  *
261
- * Kept alongside the corner because the corner is a small target on a phone, and because a
262
- * letterboxed picture's corner is empty black on a portrait clip and off the edge of the
263
- * picture on a wide one; the bottom edge is the one part of the stage whose position does
264
- * not move with the file. `DESIGN.md` §5 wants a 44px target: the bar is 10px of ink with
265
- * the rest as invisible padding, so it reads as a hairline and is hit like a button.
294
+ * `DESIGN.md` §5 wants a 44px target: the bar is 4px of ink with the rest as invisible
295
+ * padding, so it reads as a hairline and is hit like a button. That padding stays INSIDE the
296
+ * rail — it used to be pulled back over the picture and the transport with `margin-block:
297
+ * -11px`, which is one of the overlaps the 2026-09-14 report is about.
266
298
  */
267
299
  export function MediaResizeBar({
268
300
  label = 'Viewer height',
@@ -0,0 +1,428 @@
1
+ import './videoChapterEditor.css';
2
+ import React from 'react';
3
+ import { Bookmark, Trash2 } from 'lucide-react';
4
+ import { cutTimecode } from 'cursedbelt-core/media/video-cuts';
5
+ import { ActionBar } from '../components/ActionBar';
6
+ import { Button } from '../components/Button';
7
+ import { InfoHint } from '../components/InfoHint';
8
+ import { Input } from '../components/Input';
9
+ import { Modal } from '../components/Modal';
10
+ import {
11
+ chapterProblem,
12
+ chapterSpans,
13
+ normalizeChapters,
14
+ type VideoChapter,
15
+ } from './videoChapters';
16
+
17
+ /**
18
+ * Put markers on a video at points in time and NAME them — the dialog.
19
+ *
20
+ * ── The owner's ask, 2026-09-15, which is the whole scope ──────────────────────────────
21
+ * *"allow putting markers on videos at points in time and giving them a title for the section
22
+ * for knowing what that part of the video is about, like youtube chapters."*
23
+ *
24
+ * So this dialog never touches a byte and never queues anything. It produces a
25
+ * `VideoChapter[]`, hands it to `onSubmit`, and closes — everything about what makes a list
26
+ * valid is `videoChapters.ts`, shared with the player that draws the dividers, so the strip in
27
+ * here and the scrub bar out there cannot disagree about where a section starts.
28
+ *
29
+ * 🔴 The sibling of `VideoCutEditor`, and deliberately NOT part of it. The two gestures look
30
+ * identical on a timeline — click a point, do something to it — and have nothing else in
31
+ * common. A cut is a REQUEST that costs a full re-encode and comes back as a second file
32
+ * minutes or hours later; a chapter is metadata that applies the moment it is saved and is
33
+ * free to be wrong. One dialog holding both would mean naming a section and queueing an ffmpeg
34
+ * job through the same Send button.
35
+ *
36
+ * ── Why the name comes BEFORE the mark ────────────────────────────────────────────────
37
+ * The field is filled first and `◆ Mark here` is disabled until it holds something. The other
38
+ * order — mark now, name later — is how every list of "Chapter 3" ends up in a video nobody
39
+ * can navigate: the marker exists, it is already useful enough to leave alone, and the name is
40
+ * the part that gets skipped. Requiring it up front costs one keystroke's worth of order and
41
+ * makes an unnamed chapter unreachable rather than merely discouraged.
42
+ *
43
+ * ── Keeping it out of the way when it is not in use ───────────────────────────────────
44
+ * One `InfoHint` for the whole model, the fleet's standard ⓘ — a 16px glyph at rest that
45
+ * opens on hover, focus or tap. A line of instructions under the player is permanent rent paid
46
+ * by every visit after the first.
47
+ */
48
+ export interface VideoChapterEditorProps {
49
+ /** The clip, as a URL a `<video>` can play. */
50
+ src: string;
51
+ /** Shown until the first frame decodes — a black box reads as a broken player. */
52
+ posterUrl?: string;
53
+ name?: string;
54
+ /**
55
+ * The clip's length, when the app already knows it. The element's own metadata still wins
56
+ * once it loads: a duration the catalog rounded to the second would put the last chapter's
57
+ * section a fraction out, and could refuse a mark near the end that is perfectly legal.
58
+ */
59
+ durationSeconds?: number | null;
60
+ /** The sections this video already has. Edited in place; nothing is saved until Save. */
61
+ chapters?: readonly VideoChapter[];
62
+ open?: boolean;
63
+ onClose: () => void;
64
+ /**
65
+ * Save the sections. Resolving CLOSES the dialog; rejecting leaves it open with the list
66
+ * intact, because the alternative is losing several minutes of careful naming to one failed
67
+ * request.
68
+ *
69
+ * An EMPTY list is a legitimate answer — it means "this video has no sections" and is how
70
+ * every chapter gets removed. Unlike a cut list, there is nothing expensive on the other
71
+ * side of it to refuse.
72
+ */
73
+ onSubmit: (chapters: VideoChapter[]) => void | Promise<void>;
74
+ /** Wording for the confirm button — apps store this differently. */
75
+ submitLabel?: string;
76
+ /** Shown under the list: anything the app wants to say about where these are kept. */
77
+ note?: React.ReactNode;
78
+ }
79
+
80
+ export function VideoChapterEditor({
81
+ src,
82
+ posterUrl,
83
+ name,
84
+ durationSeconds,
85
+ chapters: initialChapters,
86
+ open = true,
87
+ onClose,
88
+ onSubmit,
89
+ submitLabel = 'Save sections',
90
+ note,
91
+ }: VideoChapterEditorProps) {
92
+ const videoRef = React.useRef<HTMLVideoElement>(null);
93
+ const [duration, setDuration] = React.useState(
94
+ durationSeconds && Number.isFinite(durationSeconds) ? durationSeconds : 0,
95
+ );
96
+ const [playhead, setPlayhead] = React.useState(0);
97
+ /*
98
+ * 🔴 Seeded from the prop SORTED but not normalized, and this is the bug that shape avoids:
99
+ * `normalizeChapters` needs a duration, the duration is frequently unknown at mount (the
100
+ * element has not read its metadata yet), and with `duration === 0` it returns an empty
101
+ * list. Normalizing here would therefore wipe every existing chapter of every video whose
102
+ * length the app did not already know — and since this is initial state, they would never
103
+ * come back. Normalization happens where a duration is real: on the strip, and on save.
104
+ */
105
+ const [chapters, setChapters] = React.useState<VideoChapter[]>(() =>
106
+ [...(initialChapters ?? [])].sort((a, b) => a.startSeconds - b.startSeconds),
107
+ );
108
+ const [draftTitle, setDraftTitle] = React.useState('');
109
+ const [submitting, setSubmitting] = React.useState(false);
110
+ const [error, setError] = React.useState<string | null>(null);
111
+
112
+ const spans = React.useMemo(() => chapterSpans(chapters, duration), [chapters, duration]);
113
+ /** What Save would send. Also what `isDirty` compares, so the two can never disagree. */
114
+ const saved = React.useMemo(
115
+ () => normalizeChapters(chapters, duration),
116
+ [chapters, duration],
117
+ );
118
+ const baseline = React.useMemo(
119
+ () => JSON.stringify(normalizeChapters(initialChapters ?? [], duration)),
120
+ [initialChapters, duration],
121
+ );
122
+ const isDirty = JSON.stringify(saved) !== baseline;
123
+ /**
124
+ * A row whose name has been cleared. It is REPORTED rather than dropped: `normalizeChapters`
125
+ * would quietly discard it on save, so somebody who emptied a field to retype it and then
126
+ * pressed Save would be told the save succeeded and lose the marker.
127
+ */
128
+ const unnamed = chapters.filter((chapter) => chapter.title.trim().length === 0).length;
129
+
130
+ const pct = React.useCallback(
131
+ (seconds: number) => (duration > 0 ? Math.min(100, Math.max(0, (seconds / duration) * 100)) : 0),
132
+ [duration],
133
+ );
134
+
135
+ const seek = React.useCallback((seconds: number) => {
136
+ const video = videoRef.current;
137
+ if (!video || !Number.isFinite(seconds)) return;
138
+ video.currentTime = Math.max(0, seconds);
139
+ setPlayhead(Math.max(0, seconds));
140
+ }, []);
141
+
142
+ /** ◆ — name the section that starts at the playhead. */
143
+ const markHere = React.useCallback(() => {
144
+ const draft: VideoChapter = { startSeconds: playhead, title: draftTitle };
145
+ const problem = chapterProblem(draft, chapters, duration);
146
+ if (problem) {
147
+ setError(problem);
148
+ return;
149
+ }
150
+ setError(null);
151
+ setChapters((current) =>
152
+ [...current, { startSeconds: playhead, title: draftTitle.trim() }].sort(
153
+ (a, b) => a.startSeconds - b.startSeconds,
154
+ ),
155
+ );
156
+ setDraftTitle('');
157
+ }, [playhead, draftTitle, chapters, duration]);
158
+
159
+ /** Rename in place. Keyed by the START, which is unique and does not move when a name does. */
160
+ const rename = React.useCallback((startSeconds: number, title: string) => {
161
+ setError(null);
162
+ setChapters((current) =>
163
+ current.map((chapter) =>
164
+ chapter.startSeconds === startSeconds ? { ...chapter, title } : chapter,
165
+ ),
166
+ );
167
+ }, []);
168
+
169
+ const remove = React.useCallback((startSeconds: number) => {
170
+ setError(null);
171
+ setChapters((current) =>
172
+ current.filter((chapter) => chapter.startSeconds !== startSeconds),
173
+ );
174
+ }, []);
175
+
176
+ /*
177
+ * `Space` plays, and nothing else is bound — deliberately.
178
+ *
179
+ * 🔴 There is no key for "mark here". This dialog's primary control is a TEXT FIELD, which
180
+ * owns the keyboard the whole time somebody is using it: any letter bound on the container
181
+ * is a letter that either cannot be typed or fires twice depending on where focus happens to
182
+ * be. Enter in the field marks, which is the one shortcut that needs no arbitration.
183
+ *
184
+ * Bound on the DIALOG, not the window: the gallery has its own ⇧←/⇧→ and Space handling, and
185
+ * a global listener would keep firing after this closed. `Modal` traps focus, so the dialog
186
+ * really does have the keystrokes while it is open.
187
+ */
188
+ const onKeyDown = (event: React.KeyboardEvent<HTMLFieldSetElement>): void => {
189
+ const target = event.target as HTMLElement | null;
190
+ if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
191
+ if (event.key !== ' ') return;
192
+ event.preventDefault();
193
+ const video = videoRef.current;
194
+ if (!video) return;
195
+ if (video.paused) void video.play().catch(() => undefined);
196
+ else video.pause();
197
+ };
198
+
199
+ const submit = async (): Promise<void> => {
200
+ if (unnamed > 0) {
201
+ setError(
202
+ unnamed === 1
203
+ ? 'One section has no name. Name it, or remove it.'
204
+ : `${unnamed} sections have no name. Name them, or remove them.`,
205
+ );
206
+ return;
207
+ }
208
+ setSubmitting(true);
209
+ setError(null);
210
+ try {
211
+ await onSubmit(saved);
212
+ onClose();
213
+ } catch (err) {
214
+ // 🔴 Left open, with the list intact. A failed request must not cost somebody the
215
+ // several minutes of scrubbing and naming that produced it.
216
+ setError(err instanceof Error ? err.message : 'That could not be saved. Nothing was changed.');
217
+ } finally {
218
+ setSubmitting(false);
219
+ }
220
+ };
221
+
222
+ /** Why `◆ Mark here` is unavailable, as the sentence its tooltip shows. */
223
+ const markProblem = chapterProblem({ startSeconds: playhead, title: draftTitle }, chapters, duration);
224
+ const leadPct = spans.length > 0 ? pct(spans[0]?.startSeconds ?? 0) : 0;
225
+
226
+ return (
227
+ <Modal
228
+ open={open}
229
+ onClose={onClose}
230
+ title={name ? `Sections of ${name}` : 'Video sections'}
231
+ subtitle='Mark a point, name the part that starts there — the player shows them on the scrub bar.'
232
+ size='lg'
233
+ // 🔴 The discard guard. This body holds text fields, so a backdrop click without it
234
+ // throws away every name typed since the dialog opened, with no prompt.
235
+ isDirty={isDirty}
236
+ {...(error ? { problem: error } : {})}
237
+ footer={
238
+ <ActionBar
239
+ primary={
240
+ <>
241
+ <Button variant='secondary' onClick={onClose} tooltip='Close without saving anything'>
242
+ Cancel
243
+ </Button>
244
+ <Button
245
+ onClick={() => void submit()}
246
+ disabled={submitting || unnamed > 0}
247
+ tooltip={
248
+ unnamed > 0
249
+ ? 'Every section needs a name before this can be saved'
250
+ : 'Save these sections — nothing about the video itself is changed'
251
+ }
252
+ >
253
+ {submitting ? 'Saving…' : submitLabel}
254
+ </Button>
255
+ </>
256
+ }
257
+ />
258
+ }
259
+ >
260
+ {/* A `<fieldset>`, not a `<div>` with a role: this really is one labelled set of controls
261
+ over one clip, and the native element is what makes a key handler on the container
262
+ legitimate rather than a shortcut hidden on a static box. */}
263
+ <fieldset
264
+ className='cbch'
265
+ onKeyDown={onKeyDown}
266
+ aria-label='Name the sections of this video. Type a name, then mark the point it starts at. Space plays and pauses.'
267
+ >
268
+ {/* biome-ignore lint/a11y/useMediaCaption: arbitrary user-stored files carry no captions. */}
269
+ <video
270
+ ref={videoRef}
271
+ className='cbch-video'
272
+ src={src}
273
+ poster={posterUrl}
274
+ controls
275
+ playsInline
276
+ preload='metadata'
277
+ onLoadedMetadata={(event) => {
278
+ const seen = event.currentTarget.duration;
279
+ // The element's own metadata wins over anything the catalog rounded.
280
+ if (Number.isFinite(seen) && seen > 0) setDuration(seen);
281
+ }}
282
+ onTimeUpdate={(event) => setPlayhead(event.currentTarget.currentTime)}
283
+ onSeeked={(event) => setPlayhead(event.currentTarget.currentTime)}
284
+ />
285
+
286
+ <div className='cbch-add'>
287
+ <div className='cbch-add-field'>
288
+ <Input
289
+ value={draftTitle}
290
+ onChange={(event) => {
291
+ setDraftTitle(event.currentTarget.value);
292
+ setError(null);
293
+ }}
294
+ onEnter={markHere}
295
+ aria-label='What this part of the video is about'
296
+ placeholder='What is this part about?'
297
+ />
298
+ </div>
299
+ <Button
300
+ size='sm'
301
+ variant='secondary'
302
+ onClick={markHere}
303
+ disabled={markProblem !== null}
304
+ leftIcon={<Bookmark aria-hidden='true' size={14} />}
305
+ tooltip={markProblem ?? `Start a section called "${draftTitle.trim()}" here`}
306
+ >
307
+ Mark here
308
+ </Button>
309
+ {/* The playhead, in tenths — the same readout `VideoCutEditor` shows, because the two
310
+ dialogs sit on the same clip and a timecode that rounds differently between them
311
+ reads as the playhead having moved. */}
312
+ <span className='cbch-playhead'>{cutTimecode(playhead)}</span>
313
+ <InfoHint title='Naming the parts of a video' align='right'>
314
+ <p>
315
+ Play or scrub to where a part begins, type what it is about, and press{' '}
316
+ <strong>Mark here</strong> (or <kbd>Enter</kbd> in the box).
317
+ </p>
318
+ <p>
319
+ Each section runs until the next one starts. Click a section's time to jump there,
320
+ edit a name in place, and <kbd>Space</kbd> plays and pauses.
321
+ </p>
322
+ <p>
323
+ Nothing here changes your video — these are names the player shows on the scrub
324
+ bar and in its chapters menu.
325
+ </p>
326
+ </InfoHint>
327
+ </div>
328
+
329
+ {/*
330
+ * The strip — what the scrub bar will look like once this is saved.
331
+ *
332
+ * 🔴 A `<ul>`, not a `<div role="group">`: the strip IS a list of the clip's sections,
333
+ * the name is announced from the list, and — the practical half — `display: flex` on a
334
+ * `<fieldset>` (which is what the group role would have to become here) is the one
335
+ * layout browsers have historically forced back to `block`.
336
+ */}
337
+ <ul
338
+ className='cbch-strip'
339
+ aria-label={
340
+ spans.length === 0
341
+ ? 'No sections yet — the whole clip is one unnamed part'
342
+ : `${spans.length} section${spans.length === 1 ? '' : 's'}`
343
+ }
344
+ >
345
+ {leadPct > 0 ? (
346
+ <li
347
+ className='cbch-lead'
348
+ style={{ width: `${leadPct}%` }}
349
+ aria-label='The opening, which has no section yet'
350
+ />
351
+ ) : null}
352
+ {spans.map((span) => (
353
+ <li
354
+ key={span.key}
355
+ className='cbch-span-item'
356
+ style={{ width: `${pct(span.endSeconds) - pct(span.startSeconds)}%` }}
357
+ >
358
+ <button
359
+ // guardrails-ignore no-raw-action-button: a section of the timeline, sized to
360
+ // its own duration — a `Button` carries padding and a min-width, which is
361
+ // exactly wrong for a control whose WIDTH is the information.
362
+ type='button'
363
+ className='cbch-span'
364
+ aria-label={`Jump to "${span.title}" at ${cutTimecode(span.startSeconds)}`}
365
+ onClick={() => seek(span.startSeconds)}
366
+ >
367
+ <span className='cbch-span-label'>{span.title}</span>
368
+ </button>
369
+ </li>
370
+ ))}
371
+ <span className='cbch-strip-head' style={{ left: `${pct(playhead)}%` }} />
372
+ </ul>
373
+
374
+ <p className='cbch-summary'>
375
+ {duration > 0
376
+ ? spans.length === 0
377
+ ? `${cutTimecode(duration)} of video, in one unnamed part`
378
+ : `${spans.length} section${spans.length === 1 ? '' : 's'} across ${cutTimecode(duration)}`
379
+ : 'Reading the clip…'}
380
+ </p>
381
+
382
+ {chapters.length === 0 ? (
383
+ <p className='cbch-empty'>
384
+ No sections yet. Scrub to where a part begins, name it, and mark it.
385
+ </p>
386
+ ) : (
387
+ <ul className='cbch-list'>
388
+ {chapters.map((chapter) => (
389
+ <li key={chapter.startSeconds.toFixed(3)} className='cbch-row'>
390
+ <button
391
+ // guardrails-ignore no-raw-action-button: a plain-CSS row inside a lazily
392
+ // imported, deliberately Tailwind-free editor — see `videoCutEditor.css`.
393
+ type='button'
394
+ className='cbch-row-goto'
395
+ aria-label={`Jump to ${cutTimecode(chapter.startSeconds)}`}
396
+ onClick={() => seek(chapter.startSeconds)}
397
+ >
398
+ <Bookmark aria-hidden='true' size={12} /> {cutTimecode(chapter.startSeconds)}
399
+ </button>
400
+ <div className='cbch-row-title'>
401
+ <Input
402
+ size='sm'
403
+ value={chapter.title}
404
+ onChange={(event) => rename(chapter.startSeconds, event.currentTarget.value)}
405
+ aria-label={`Name of the section at ${cutTimecode(chapter.startSeconds)}`}
406
+ aria-invalid={chapter.title.trim().length === 0}
407
+ placeholder='Name this section'
408
+ />
409
+ </div>
410
+ <Button
411
+ size='sm'
412
+ variant='ghost'
413
+ tooltip='Remove this section — the video keeps playing straight through'
414
+ aria-label={`Remove the section at ${cutTimecode(chapter.startSeconds)}`}
415
+ onClick={() => remove(chapter.startSeconds)}
416
+ >
417
+ <Trash2 aria-hidden='true' size={14} />
418
+ </Button>
419
+ </li>
420
+ ))}
421
+ </ul>
422
+ )}
423
+
424
+ {note ? <div className='cbch-note'>{note}</div> : null}
425
+ </fieldset>
426
+ </Modal>
427
+ );
428
+ }