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
@@ -0,0 +1,237 @@
1
+ import React from 'react';
2
+ import { ActionBar } from '../components/ActionBar';
3
+ import { AutoGrowTextarea } from '../components/AutoGrowTextarea';
4
+ import { Button } from '../components/Button';
5
+ import { FieldLabel } from '../components/FieldLabel';
6
+ import { InfoHint } from '../components/InfoHint';
7
+ import { Modal } from '../components/Modal';
8
+ import { TagsInput } from '../components/TagsInput';
9
+ import {
10
+ type MediaMetaChange,
11
+ cleanTags,
12
+ isEmptyMetaChange,
13
+ tagDiff,
14
+ tagsOn,
15
+ } from './mediaMeta';
16
+ import type { MediaGalleryItem } from './types';
17
+
18
+ /**
19
+ * Tag media, and say what it is — the dialog.
20
+ *
21
+ * ── The owner's ask, `ideas/collections-09-14.md`, 2026-09-14 ──────────────────────────
22
+ * *"In media galleries have an option for apps to tag media and aadd descriptions to media."*
23
+ *
24
+ * The gallery could already READ `tags` — the tile prints the first three and the chips filter
25
+ * on them — and had no way to write one and no description field at all. So the capability
26
+ * that was missing is authoring, and like every other one on this component it arrives as a
27
+ * HANDLER: pass `onSaveMeta` and the control appears, pass nothing and this dialog is not even
28
+ * in the bundle. See `MediaGalleryProps.onSaveMeta`, and `onTrimVideo` for the rule stated in
29
+ * full.
30
+ *
31
+ * ── 🔴 One item and forty are the SAME dialog, and not the same form ──────────────────
32
+ * Forty photographs do not share one tag list, so "here are the new tags" cannot be a single
33
+ * array — it would wipe thirty-nine lists nobody looked at. Over a selection the two fields
34
+ * are therefore ADD and REMOVE, which mean the same thing for one item as for forty and reach
35
+ * the app as one call carrying every id (`mediaMeta.ts` explains the shape).
36
+ *
37
+ * The description is deliberately absent from the bulk form. One sentence cannot describe
38
+ * forty files, and a field that writes the same words onto all of them is a field whose only
39
+ * correct use is the one nobody wants.
40
+ *
41
+ * ── Why the vocabulary is offered rather than typed ───────────────────────────────────
42
+ * `suggestions` is every tag already in the gallery, handed to `TagsInput`'s native
43
+ * `<datalist>`. Re-typing `holiday` is how `Holiday` and `holidays` get minted beside it, and
44
+ * three spellings of one tag is three filter chips that each find a third of the album. The
45
+ * model matches case-insensitively for the same reason; this is the half that stops it
46
+ * happening at the keyboard rather than cleaning up afterwards.
47
+ */
48
+ export interface MediaMetaEditorProps {
49
+ /** What is being tagged. One item opens the single form; two or more open the bulk one. */
50
+ items: readonly MediaGalleryItem[];
51
+ /** Every tag already in use in this gallery, offered as you type. */
52
+ suggestions?: readonly string[];
53
+ open?: boolean;
54
+ onClose: () => void;
55
+ /**
56
+ * Save. Resolving CLOSES the dialog; rejecting leaves it open with the draft intact,
57
+ * because a failed request must not cost somebody the tags they just typed.
58
+ */
59
+ onSubmit: (change: MediaMetaChange) => void | Promise<void>;
60
+ }
61
+
62
+ export function MediaMetaEditor({
63
+ items,
64
+ suggestions,
65
+ open = true,
66
+ onClose,
67
+ onSubmit,
68
+ }: MediaMetaEditorProps) {
69
+ const single = items.length === 1 ? items[0] : undefined;
70
+ const [tags, setTags] = React.useState<string[]>(() => cleanTags(single?.tags ?? []));
71
+ const [description, setDescription] = React.useState(() => single?.description ?? '');
72
+ const [addTags, setAddTags] = React.useState<string[]>([]);
73
+ const [removeTags, setRemoveTags] = React.useState<string[]>([]);
74
+ const [saving, setSaving] = React.useState(false);
75
+ const [error, setError] = React.useState<string | null>(null);
76
+
77
+ /** The tags on the selection — the only ones a Remove can act on. */
78
+ const onSelection = React.useMemo(() => tagsOn(items), [items]);
79
+
80
+ const change: MediaMetaChange = React.useMemo(() => {
81
+ if (single) {
82
+ const diff = tagDiff(single.tags, tags);
83
+ const next = description.trim();
84
+ return next === (single.description ?? '').trim()
85
+ ? diff
86
+ : { ...diff, description: next };
87
+ }
88
+ return { addTags: cleanTags(addTags), removeTags: cleanTags(removeTags) };
89
+ }, [single, tags, description, addTags, removeTags]);
90
+
91
+ const nothingToDo = isEmptyMetaChange(change);
92
+
93
+ const submit = async (): Promise<void> => {
94
+ if (nothingToDo) return;
95
+ setSaving(true);
96
+ setError(null);
97
+ try {
98
+ await onSubmit(change);
99
+ onClose();
100
+ } catch (cause) {
101
+ setError(cause instanceof Error ? cause.message : 'That could not be saved.');
102
+ } finally {
103
+ setSaving(false);
104
+ }
105
+ };
106
+
107
+ const what =
108
+ single?.name ?? `${items.length} item${items.length === 1 ? '' : 's'}`;
109
+
110
+ return (
111
+ <Modal
112
+ open={open}
113
+ onClose={onClose}
114
+ title={single ? `Tags and description — ${single.name}` : `Tag ${what}`}
115
+ subtitle={
116
+ single
117
+ ? 'Tags put this file on a filter chip; the description shows under the title.'
118
+ : 'Added to every item you ticked, in one go — nothing else about them changes.'
119
+ }
120
+ size='md'
121
+ // 🔴 The discard guard. This body is nothing but text fields, so a backdrop click
122
+ // without it throws away every tag typed since the dialog opened, with no prompt.
123
+ isDirty={!nothingToDo}
124
+ {...(error ? { problem: error } : {})}
125
+ footer={
126
+ <ActionBar
127
+ primary={
128
+ <>
129
+ <Button variant='secondary' onClick={onClose} tooltip='Close without saving anything'>
130
+ Cancel
131
+ </Button>
132
+ <Button
133
+ onClick={() => void submit()}
134
+ disabled={saving || nothingToDo}
135
+ tooltip={
136
+ nothingToDo
137
+ ? 'Nothing has changed yet'
138
+ : single
139
+ ? 'Save these tags and this description'
140
+ : `Apply this to all ${items.length} items in one go`
141
+ }
142
+ >
143
+ {saving ? 'Saving…' : 'Save'}
144
+ </Button>
145
+ </>
146
+ }
147
+ />
148
+ }
149
+ >
150
+ <div className='cbmm'>
151
+ {single ? (
152
+ <>
153
+ <div className='cbmm-field'>
154
+ <FieldLabel
155
+ hint={
156
+ <>
157
+ Tags are how this file is found again: each one becomes a chip above the
158
+ grid that filters the album down to the things wearing it. Type and press{' '}
159
+ <kbd>Enter</kbd>, or pick one already in use.
160
+ </>
161
+ }
162
+ >
163
+ Tags
164
+ </FieldLabel>
165
+ <TagsInput
166
+ value={tags}
167
+ onChange={setTags}
168
+ aria-label={`Tags for ${single.name}`}
169
+ placeholder='Add a tag…'
170
+ {...(suggestions ? { suggestions } : {})}
171
+ />
172
+ </div>
173
+ <div className='cbmm-field'>
174
+ <FieldLabel>Description</FieldLabel>
175
+ <AutoGrowTextarea
176
+ value={description}
177
+ onChange={(event) => setDescription(event.currentTarget.value)}
178
+ aria-label={`Description of ${single.name}`}
179
+ placeholder='What is this, and why did you keep it?'
180
+ maxLength={2_000}
181
+ />
182
+ </div>
183
+ </>
184
+ ) : (
185
+ <>
186
+ <p className='cbmm-count'>
187
+ {items.length} items ticked.
188
+ <InfoHint title='Tagging a selection' align='right'>
189
+ <p>
190
+ Whatever you add here is added to <strong>every</strong> item you ticked, and
191
+ whatever you remove is removed from all of them — one request, not one per
192
+ file.
193
+ </p>
194
+ <p>
195
+ Tags each item already has are left alone, and a tag that differs only in
196
+ capitalisation from one it already has is not added twice.
197
+ </p>
198
+ <p>
199
+ There is no description field here on purpose: one sentence cannot describe
200
+ several files. Open one on its own to describe it.
201
+ </p>
202
+ </InfoHint>
203
+ </p>
204
+ <div className='cbmm-field'>
205
+ <FieldLabel>Add tags</FieldLabel>
206
+ <TagsInput
207
+ value={addTags}
208
+ onChange={setAddTags}
209
+ aria-label={`Tags to add to all ${items.length} selected items`}
210
+ placeholder='Add a tag to all of them…'
211
+ {...(suggestions ? { suggestions } : {})}
212
+ />
213
+ </div>
214
+ <div className='cbmm-field'>
215
+ <FieldLabel>Remove tags</FieldLabel>
216
+ <TagsInput
217
+ value={removeTags}
218
+ onChange={setRemoveTags}
219
+ aria-label={`Tags to remove from all ${items.length} selected items`}
220
+ placeholder={
221
+ onSelection.length > 0
222
+ ? 'Take a tag off all of them…'
223
+ : 'None of these have a tag yet'
224
+ }
225
+ disabled={onSelection.length === 0}
226
+ // Only what is actually ON the selection: offering the whole album's
227
+ // vocabulary here would invite removing a tag none of these items carries,
228
+ // which is a request that does nothing and reads as a broken control.
229
+ suggestions={onSelection}
230
+ />
231
+ </div>
232
+ </>
233
+ )}
234
+ </div>
235
+ </Modal>
236
+ );
237
+ }
@@ -1,6 +1,12 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import { readFileSync } from 'node:fs';
3
- import { GALLERY_TABLE_ROW_HEIGHT, durationLabel, visibleWindow } from './GalleryTable';
3
+ import { formatBytes } from 'cwip/format';
4
+ import {
5
+ GALLERY_TABLE_ROW_HEIGHT,
6
+ durationLabel,
7
+ exactBytesLabel,
8
+ visibleWindow,
9
+ } from './GalleryTable';
4
10
 
5
11
  /**
6
12
  * The table's arithmetic, tested where a browser is not needed.
@@ -56,3 +62,37 @@ describe('GalleryTable — durations', () => {
56
62
  expect(durationLabel(Number.NaN)).toBeNull();
57
63
  });
58
64
  });
65
+
66
+ /**
67
+ * The exact-bytes column's label.
68
+ *
69
+ * 🔴 The first test here is the WHOLE ask, and it was red before this column existed:
70
+ * *"I want to see the exact file size in bytes … so I can easily tell if two files are the
71
+ * exact same byte size to help me know if they are duplicates or not."* `formatBytes` — the
72
+ * Size column beside it — renders both of these files as `1.4 GB`.
73
+ */
74
+ describe('GalleryTable — exact bytes', () => {
75
+ test('🔴 two files one byte apart read differently, which `formatBytes` cannot manage', () => {
76
+ const a = 1_402_653_184;
77
+ const b = a + 1;
78
+ expect(exactBytesLabel(a)).not.toBe(exactBytesLabel(b));
79
+ // …and the same two through the rounded column, which is why this column exists.
80
+ expect(formatBytes(a)).toBe(formatBytes(b));
81
+ });
82
+
83
+ test('the digits are grouped, because ten in a row are not comparable at a glance', () => {
84
+ // The separator is the reader's locale's (a space in fr-FR, a comma in en-US), so the
85
+ // assertion is on the GROUPING rather than on the character — the digits, in order, in
86
+ // threes. Pinning a comma here would be this spec asserting the test machine's locale.
87
+ expect(exactBytesLabel(1_402_653_184)).toMatch(/^1\D?402\D?653\D?184$/);
88
+ // Under four digits there is nothing to group, and nothing is invented.
89
+ expect(exactBytesLabel(512)).toBe('512');
90
+ expect(exactBytesLabel(0)).toBe('0');
91
+ });
92
+
93
+ test('null for a row with no size, so the cell shows the same — every column does', () => {
94
+ expect(exactBytesLabel(undefined)).toBeNull();
95
+ expect(exactBytesLabel(Number.NaN)).toBeNull();
96
+ expect(exactBytesLabel(Number.POSITIVE_INFINITY)).toBeNull();
97
+ });
98
+ });
@@ -24,9 +24,16 @@ export {
24
24
  sortGalleryItems,
25
25
  } from './mediaGalleryModel';
26
26
  export type { MediaGalleryItem, MediaGalleryItemMedia, MediaGalleryOrder } from './types';
27
+ // `onSaveMeta`'s contract, and the merge every consumer of it would otherwise write itself.
28
+ // Exported for the same reason `fileTreeSelection.ts`'s rules are: the gallery persists
29
+ // nothing, so the "add these, remove those, keep the spelling" arithmetic happens in the app —
30
+ // and four apps re-deriving it is four chances to mint `Holiday` beside `holiday`.
31
+ export { type MediaMetaChange, applyMetaChange } from './mediaMeta';
27
32
  // Re-exported so an app wiring `onTrimVideo` types its handler from the gallery's own subpath
28
33
  // rather than reaching into `cursedbelt/react/media` for one interface.
29
34
  export type { VideoCut } from 'cursedbelt-core/media/video-cuts';
35
+ // Same reason for `onSaveChapters`: an app types its handler from the gallery's own subpath.
36
+ export type { VideoChapter } from '../media/videoChapters';
30
37
  // The open file, in the URL. A BINDING over `cursedbelt/react/deep-link`, not a second way
31
38
  // of writing one — see the header of `galleryDeepLink.ts`.
32
39
  export {
@@ -177,12 +177,52 @@
177
177
  container-type: size;
178
178
  }
179
179
 
180
+ /* 🔴 The turned picture surface is SIZED to the swapped panel, not merely bounded by it.
181
+ Owner, `ideas/collections-09-14.md`: *"If I rotate a portait style video 90 degrees it should
182
+ be changed to fit the wide screen panel … so I can watch it like it was recorded with wide
183
+ screen."* — a rotation on its own leaves the footage the size it already was.
184
+
185
+ These used to be `max-block-size`/`max-inline-size`, which can only SHRINK. Measured in
186
+ Chromium on 2026-09-16 against `demo`'s 9:16 clip in a 505×284 panel: the player gives its
187
+ `<video>` a 16:9 box of its own (284×160 there), `object-fit: contain` draws the portrait
188
+ film as a 90×160 pillar inside it, and the max-* pair had nothing to bind on — so the turn
189
+ painted 160×90 of film in a 505×284 panel, which is the tall thin picture in the middle of a
190
+ black stage that was reported. `e2e/video-turn.spec.ts` measures exactly that rectangle.
191
+
192
+ Width from the container's BLOCK size and height from its INLINE size — the axis swap, said
193
+ in the only units that can say it — so after the rotation the box is the panel again. The
194
+ fit itself is then `object-fit: contain`'s, which is what keeps this honest for footage whose
195
+ aspect is not the panel's inverse: a 16:9 clip turned on its end is letterboxed whole rather
196
+ than blown up and cropped, and a square one is left alone. A scale factor on the transform
197
+ cannot make that distinction — it does not know the film's shape — which is why the fit lives
198
+ on the box and only the rotation travels in `--cbmg-turn`. */
180
199
  .cbmg-stage-turn[data-turned='true'] video,
181
200
  .cbmg-stage-turn[data-turned='true'] .vds-poster img {
182
201
  max-width: none;
183
202
  max-height: none;
184
- max-block-size: 100cqi;
185
- max-inline-size: 100cqb;
203
+ max-block-size: none;
204
+ max-inline-size: none;
205
+ width: 100cqb;
206
+ height: 100cqi;
207
+ /* The player sets its own 16:9 on the element; an explicit pair of sides must win over it,
208
+ or the swap above is computed and then thrown away. */
209
+ aspect-ratio: auto;
210
+ object-fit: contain;
211
+ }
212
+
213
+ /* 🔴 …and it has to be turned about the PANEL's centre, which means it has to sit at it first.
214
+ A rotation pivots on the element's own middle, so an element the player has left against the
215
+ left edge of its provider box lands a quarter of the panel off to one side — measured in
216
+ Chromium on 2026-09-16: a correctly sized 505×284 of film, painted at x=278 in a panel
217
+ starting at x=388, i.e. 110px of it outside the frame and a black band the same width down
218
+ the other edge. The wrapper above centres what IS its child; on the Vidstack path the child
219
+ is the player, and the element being turned is two boxes further in. Same reach as
220
+ `.vds-poster` / `.vds-controls` elsewhere in this file, and inert on the native stage, whose
221
+ `<video>` is already the wrapper's own flex-centred child. */
222
+ .cbmg-stage-turn[data-turned='true'] [data-media-provider] {
223
+ display: flex;
224
+ align-items: center;
225
+ justify-content: center;
186
226
  }
187
227
 
188
228
  /* Inside the turned frame the stage element must stop claiming its own 70vh, or the flex
@@ -322,6 +362,63 @@
322
362
  background: color-mix(in srgb, var(--accent-solid) 12%, transparent);
323
363
  }
324
364
 
365
+ /* ── The tile the STAGE is showing ───────────────────────────────────────────────────────
366
+ *
367
+ * Owner, `ideas/collections-09-14.md`: *"I want to … still see the playing video in the list
368
+ * and nothing about the list changes as I pick other videos."* The strip under the stage used
369
+ * to drop the open file; it keeps it now and marks it here.
370
+ *
371
+ * 🔴 `::before`, where the selection tint is `::after`, so a tile that is BOTH ticked and
372
+ * playing draws both marks instead of one silently replacing the other. Neither pseudo is a
373
+ * `:first-child`, so `.cbmg-selectable > :first-child { height: 100% }` above is untouched.
374
+ */
375
+ .cbmg-selectable.is-playing::before {
376
+ content: '';
377
+ position: absolute;
378
+ inset: 0;
379
+ /* guardrails-ignore no-raw-zindex: the same tile-local stacking the selection tint uses —
380
+ the mark over its own tile, inside the isolated `.cbmg-selectable` context. */
381
+ z-index: 1;
382
+ pointer-events: none;
383
+ border-radius: 8px;
384
+ /* Thicker than the selection's 2px: this one has to be findable while scrolling past it,
385
+ which is the whole reason the tile stays in the list. */
386
+ outline: 3px solid var(--accent-solid);
387
+ outline-offset: -3px;
388
+ background: color-mix(in srgb, var(--accent-solid) 10%, transparent);
389
+ }
390
+ /* A filled capsule with its own foreground token (DESIGN.md §3 — never a thin outline), at
391
+ the 12px floor (§4). `pointer-events: none` because the whole tile is one button and a
392
+ label that swallowed the click would make its corner dead. */
393
+ .cbmg-playing-chip {
394
+ position: absolute;
395
+ top: 6px;
396
+ left: 6px;
397
+ /* guardrails-ignore no-raw-zindex: tile-local stacking, over this tile's own mark. */
398
+ z-index: 2;
399
+ display: inline-flex;
400
+ align-items: center;
401
+ gap: 4px;
402
+ max-width: calc(100% - 12px);
403
+ padding: 3px 8px;
404
+ border-radius: 999px;
405
+ background: var(--accent-solid);
406
+ color: var(--accent-solid-foreground);
407
+ font-size: 12px;
408
+ font-weight: 600;
409
+ line-height: 1.2;
410
+ white-space: nowrap;
411
+ overflow: hidden;
412
+ text-overflow: ellipsis;
413
+ pointer-events: none;
414
+ }
415
+ /* The one corner collision: a document tile draws its type badge in this exact spot. The mark
416
+ wins for as long as it is there — the stage directly above is showing the file, and its
417
+ titlebar says what kind it is, so the badge is the redundant one of the two. */
418
+ .cbmg-selectable.is-playing .cbmg-doc-badge {
419
+ display: none;
420
+ }
421
+
325
422
  .cbmg-bulkbar {
326
423
  display: flex;
327
424
  align-items: center;
@@ -356,7 +453,7 @@
356
453
  frame grabber were each written inside ONE app's `detailActions`; these are the rules that
357
454
  came with them. Plain CSS under `.cbmg-`, like the rest of this file. */
358
455
 
359
- /* 🔴 The ☀ Picture adjustment reaches the stage through a custom property set on the stage
456
+ /* 🔴 The Picture adjustment reaches the stage through a custom property set on the stage
360
457
  anchor, NOT through a prop threaded into the player — the video stage is Vidstack's and is
361
458
  several components deep, so a prop would make every consumer of the player primitive learn
362
459
  about a control only the gallery offers.
@@ -544,6 +641,21 @@
544
641
  box-shadow: inset 3px 0 0 0 var(--accent);
545
642
  }
546
643
 
644
+ /* The row the stage is showing — the table's half of the grid tile's mark. Same shape as the
645
+ selected row so the two read as one family, and a step louder (the SOLID accent, a 4px bar)
646
+ because there is exactly one of these and it is the row the reader is looking for.
647
+ The name it is given is `.cbgd-sr-only` text in the name cell; `GalleryTable.tsx` says why a
648
+ visible capsule cannot live in a fixed-layout cell. */
649
+ .cbgt-row.is-playing {
650
+ background: color-mix(in srgb, var(--accent-solid) 12%, transparent);
651
+ box-shadow: inset 4px 0 0 0 var(--accent-solid);
652
+ }
653
+ /* …and it keeps that reading under the pointer, which the hover rule above would otherwise
654
+ take away by painting the plain hover fill over it. */
655
+ .cbgt-row.is-playing:hover {
656
+ background: color-mix(in srgb, var(--accent-solid) 18%, transparent);
657
+ }
658
+
547
659
  /* Fixed tracks, declared once. `table-layout: fixed` means these widths are the layout. */
548
660
  .cbgt-cell--check {
549
661
  width: 36px;
@@ -566,6 +678,14 @@
566
678
  .cbgt-cell--length {
567
679
  width: 78px;
568
680
  }
681
+ /* The exact byte count. Wider than the rounded Size beside it because the whole point of the
682
+ column is that nothing is dropped: a terabyte is 13 digits plus 4 separators, and a count
683
+ that truncated to "1,402,653,1…" would be exactly as useless for telling two files apart as
684
+ the rounded "1.4 GB" it exists to replace. `.cbgt-cell--num` already right-aligns it and
685
+ gives it tabular figures, which is what makes two counts comparable down a column. */
686
+ .cbgt-cell--bytes {
687
+ width: 132px;
688
+ }
569
689
  /* The date is the widest of the three numeric columns: "21 Aug 2026" at 12px does not fit
570
690
  the 96px the others use, and a DATE that truncates to "Aug 21, 2…" is a column that has
571
691
  stopped answering the question it exists for. Deliberately generous — every locale this
@@ -731,7 +851,14 @@
731
851
  /* ── Narrow screens ──────────────────────────────────────────────────────────────────────
732
852
  *
733
853
  * Kind and Length are the two columns a phone can lose without the row stopping being
734
- * useful — the name, the size and the date are what the owner is scanning for. */
854
+ * useful — the name, the size and the date are what the owner is scanning for.
855
+ *
856
+ * 🔴 The exact-bytes column goes with them, and it is the one that would hurt most to keep:
857
+ * it is the widest track in the table (132px), and these widths ARE the layout under
858
+ * `table-layout: fixed` — thumb + size + bytes + date already exceeds a 390px phone on their
859
+ * own, so keeping it would trade the file NAME for a digit count. The rounded Size stays, so
860
+ * the row still answers "how big"; comparing two counts to the byte is a desk instrument, and
861
+ * the desk is where the column is. */
735
862
  @media (max-width: 720px) {
736
863
  .cbgt-head th:first-child,
737
864
  .cbgt-row td:first-child {
@@ -742,6 +869,7 @@
742
869
  padding-right: 12px;
743
870
  }
744
871
  .cbgt-cell--kind,
872
+ .cbgt-cell--bytes,
745
873
  .cbgt-cell--length {
746
874
  display: none;
747
875
  }
@@ -782,3 +910,51 @@
782
910
  min-width: 0;
783
911
  min-height: 0;
784
912
  }
913
+
914
+ /*
915
+ * ── The DESCRIPTION, under the title on the stage ───────────────────────────────────────
916
+ *
917
+ * Owner, `ideas/collections-09-14.md`: *"In media galleries have an option for apps to tag
918
+ * media and aadd descriptions to media."*
919
+ *
920
+ * 🔴 It cannot ride the ` · ` facts line. `.cbgd-meta-line` is `white-space: nowrap` with an
921
+ * ellipsis because the SAME class captions every tile in the grid, so a sentence appended
922
+ * there is a sentence nobody can read past its first few words. These two rules are what let
923
+ * the titlebar's own wrapping flex row carry a second, wrapping line — the facts keep the
924
+ * nowrap behaviour they have always had, and the description is a block beneath them.
925
+ *
926
+ * The width cap is readability, not decoration: `DESIGN.md` §4 asks for a measure, and a
927
+ * description running the full width of a 1680px stage is one line of 200 characters.
928
+ */
929
+ .cbmg-stage-facts {
930
+ display: block;
931
+ }
932
+ .cbmg-stage-description {
933
+ display: block;
934
+ /* Overrides the nowrap/ellipsis inherited from `.cbgd-meta-line` — see above. */
935
+ white-space: normal;
936
+ max-width: 68ch;
937
+ margin-top: 2px;
938
+ font-size: 12px;
939
+ line-height: 1.45;
940
+ color: var(--muted-foreground);
941
+ }
942
+
943
+ /*
944
+ * The tag/description editor's body — see `MediaMetaEditor.tsx`. Plain CSS in this module's
945
+ * own stylesheet rather than utilities on the elements, for the reason the rest of this file
946
+ * records: the sheet is already loaded wherever the gallery is, and the dialog is otherwise
947
+ * built entirely from design-system components that bring their own chrome.
948
+ */
949
+ .cbmm {
950
+ display: flex;
951
+ flex-direction: column;
952
+ gap: 14px;
953
+ }
954
+ .cbmm-count {
955
+ display: flex;
956
+ align-items: center;
957
+ gap: 6px;
958
+ font-size: 13px;
959
+ color: var(--muted-foreground);
960
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ applyMetaChange,
4
+ cleanTags,
5
+ isEmptyMetaChange,
6
+ tagDiff,
7
+ tagsOn,
8
+ } from './mediaMeta';
9
+
10
+ describe('cleanTags', () => {
11
+ test('trims, drops empties and keeps the first spelling of a case-duplicate', () => {
12
+ expect(cleanTags([' holiday ', '', ' ', 'Holiday', 'beach'])).toEqual([
13
+ 'holiday',
14
+ 'beach',
15
+ ]);
16
+ });
17
+ });
18
+
19
+ describe('tagDiff', () => {
20
+ test('a single-item edit becomes the same two lists a bulk edit sends', () => {
21
+ expect(tagDiff(['holiday', 'beach'], ['beach', 'greece'])).toEqual({
22
+ addTags: ['greece'],
23
+ removeTags: ['holiday'],
24
+ });
25
+ });
26
+
27
+ test('🔴 re-typing a tag in a different case is not a change', () => {
28
+ // Otherwise every save mints a second spelling and the chips grow a duplicate of
29
+ // everything the owner ever re-typed.
30
+ expect(tagDiff(['Holiday'], ['holiday'])).toEqual({ addTags: [], removeTags: [] });
31
+ });
32
+
33
+ test('an item with no tags at all diffs against the empty list', () => {
34
+ expect(tagDiff(undefined, ['greece'])).toEqual({ addTags: ['greece'], removeTags: [] });
35
+ });
36
+ });
37
+
38
+ describe('isEmptyMetaChange', () => {
39
+ test('nothing added, nothing removed and no description is nothing to save', () => {
40
+ expect(isEmptyMetaChange({ addTags: [], removeTags: [] })).toBe(true);
41
+ });
42
+
43
+ test("clearing a description to '' is a change, not an absence", () => {
44
+ expect(isEmptyMetaChange({ addTags: [], removeTags: [], description: '' })).toBe(false);
45
+ });
46
+ });
47
+
48
+ describe('applyMetaChange', () => {
49
+ test('adds, removes and keeps the rest in order', () => {
50
+ expect(
51
+ applyMetaChange(
52
+ { tags: ['holiday', 'beach'] },
53
+ { addTags: ['greece'], removeTags: ['beach'] },
54
+ ),
55
+ ).toEqual({ tags: ['holiday', 'greece'] });
56
+ });
57
+
58
+ test('🔴 removal matches case-insensitively, so a re-cased tag is not left behind', () => {
59
+ expect(applyMetaChange({ tags: ['Holiday'] }, { addTags: [], removeTags: ['holiday'] })).toEqual(
60
+ { tags: [] },
61
+ );
62
+ });
63
+
64
+ test('🔴 adding a tag the item already has in another case changes nothing', () => {
65
+ expect(applyMetaChange({ tags: ['Holiday'] }, { addTags: ['holiday'], removeTags: [] })).toEqual(
66
+ { tags: ['Holiday'] },
67
+ );
68
+ });
69
+
70
+ test('a change with no description leaves the key off, so a spread cannot clear one', () => {
71
+ const next = applyMetaChange(
72
+ { tags: [], description: 'The one with the dog' },
73
+ { addTags: ['dog'], removeTags: [] },
74
+ );
75
+ expect('description' in next).toBe(false);
76
+ expect({ ...{ description: 'The one with the dog' }, ...next }).toEqual({
77
+ description: 'The one with the dog',
78
+ tags: ['dog'],
79
+ });
80
+ });
81
+
82
+ test("'' clears the description rather than leaving it", () => {
83
+ expect(
84
+ applyMetaChange({ tags: [], description: 'old' }, { addTags: [], removeTags: [], description: '' }),
85
+ ).toEqual({ tags: [], description: '' });
86
+ });
87
+ });
88
+
89
+ describe('tagsOn', () => {
90
+ test('the union across a selection, de-duplicated across items', () => {
91
+ expect(
92
+ tagsOn([{ tags: ['holiday', 'beach'] }, { tags: ['Beach', 'greece'] }, {}]),
93
+ ).toEqual(['holiday', 'beach', 'greece']);
94
+ });
95
+ });