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,101 @@
1
+ import type { MediaGalleryItem } from './types';
2
+
3
+ /**
4
+ * Tags and a description, expressed as a CHANGE rather than as a new value.
5
+ *
6
+ * ── Why a change and not a pair of fields ──────────────────────────────────────────────
7
+ * The owner's ask, `ideas/collections-09-14.md`, 2026-09-14: *"In media galleries have an
8
+ * option for apps to tag media and aadd descriptions to media."* The case he will actually
9
+ * hit is forty holiday photographs at once, and forty items do not share one tag list — each
10
+ * one has its own, and "the new tags" cannot be a single array without wiping thirty-nine
11
+ * lists that were never on screen.
12
+ *
13
+ * So the plural verb carries `addTags`/`removeTags`, which mean the same thing for one item
14
+ * as for forty, and a consumer writes ONE handler rather than a singular one plus a loop.
15
+ * That is the rule `tasks/Completed/240-*.md` states for the file tree's multi-select in as
16
+ * many words — *"One request for N ids, not N requests: the tree deliberately does not loop
17
+ * `onMove`, because a tree re-derived between each write is how a batch move half-lands."*
18
+ *
19
+ * 🔴 Everything here is case-INSENSITIVE and spelling-preserving. A vocabulary that mints
20
+ * `Bank`, `bank` and `BANK` as three tags is a vocabulary nothing can filter by, and the
21
+ * gallery's own chips would show all three. The first spelling seen wins; a tag that differs
22
+ * only in case from one already present is not an addition at all.
23
+ */
24
+ export interface MediaMetaChange {
25
+ /** Tags to add to EVERY item in the set. Already trimmed, de-duplicated, never empty strings. */
26
+ addTags: string[];
27
+ /** Tags to remove from EVERY item in the set. Matched case-insensitively. */
28
+ removeTags: string[];
29
+ /**
30
+ * The new description, or `''` to clear it. `undefined` means "not part of this change" —
31
+ * which is what a bulk tag always sends, because one sentence cannot describe forty files
32
+ * and writing it onto all of them would delete thirty-nine that were never read.
33
+ */
34
+ description?: string;
35
+ }
36
+
37
+ /** Trim, drop the empties, drop case-insensitive duplicates keeping the first spelling. */
38
+ export function cleanTags(tags: readonly string[]): string[] {
39
+ const seen = new Set<string>();
40
+ const out: string[] = [];
41
+ for (const raw of tags) {
42
+ const tag = raw.trim();
43
+ if (!tag) continue;
44
+ const key = tag.toLowerCase();
45
+ if (seen.has(key)) continue;
46
+ seen.add(key);
47
+ out.push(tag);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ /**
53
+ * What it takes to get from `before` to `after` — the single-item editor's save, expressed in
54
+ * the same two lists the bulk one sends, so both paths arrive at a consumer as one shape.
55
+ */
56
+ export function tagDiff(
57
+ before: readonly string[] | undefined,
58
+ after: readonly string[],
59
+ ): { addTags: string[]; removeTags: string[] } {
60
+ const was = cleanTags(before ?? []);
61
+ const now = cleanTags(after);
62
+ const wasKeys = new Set(was.map((t) => t.toLowerCase()));
63
+ const nowKeys = new Set(now.map((t) => t.toLowerCase()));
64
+ return {
65
+ addTags: now.filter((tag) => !wasKeys.has(tag.toLowerCase())),
66
+ removeTags: was.filter((tag) => !nowKeys.has(tag.toLowerCase())),
67
+ };
68
+ }
69
+
70
+ /** Nothing to save. The editor's Save is disabled on this, so a no-op never reaches an app. */
71
+ export function isEmptyMetaChange(change: MediaMetaChange): boolean {
72
+ return (
73
+ change.addTags.length === 0 &&
74
+ change.removeTags.length === 0 &&
75
+ change.description === undefined
76
+ );
77
+ }
78
+
79
+ /**
80
+ * The merge a consumer would otherwise write itself — one item's row after the change lands.
81
+ *
82
+ * Exported for the same reason `fileTreeSelection.ts`'s rules are: the gallery persists
83
+ * nothing, so every app has to do this arithmetic, and four apps re-deriving "add these,
84
+ * remove those, keep the spelling" is four chances to mint a duplicate tag. `tags` comes back
85
+ * cleaned; `description` is omitted from the result when the change did not carry one, so a
86
+ * caller can spread the result over its row without clearing a description it never touched.
87
+ */
88
+ export function applyMetaChange(
89
+ item: Pick<MediaGalleryItem, 'tags' | 'description'>,
90
+ change: MediaMetaChange,
91
+ ): { tags: string[]; description?: string } {
92
+ const removed = new Set(change.removeTags.map((t) => t.toLowerCase()));
93
+ const kept = cleanTags(item.tags ?? []).filter((tag) => !removed.has(tag.toLowerCase()));
94
+ const tags = cleanTags([...kept, ...change.addTags]);
95
+ return change.description === undefined ? { tags } : { tags, description: change.description };
96
+ }
97
+
98
+ /** Every tag on any of these items, cleaned and in first-seen order — the "Remove" vocabulary. */
99
+ export function tagsOn(items: readonly Pick<MediaGalleryItem, 'tags'>[]): string[] {
100
+ return cleanTags(items.flatMap((item) => item.tags ?? []));
101
+ }
@@ -0,0 +1,172 @@
1
+ import { describe, expect, spyOn, test } from 'bun:test';
2
+ import {
3
+ DEFAULT_SESSION_VOLUME,
4
+ PLAYER_VOLUME_KEY,
5
+ readSessionVolume,
6
+ STAGE_VOLUME_KEY,
7
+ writePlaybackEnd,
8
+ writeSessionVolume,
9
+ } from './playbackPreferences';
10
+
11
+ /**
12
+ * 🔴 The volume he set stays set for the PAGE — and is gone on the next one.
13
+ *
14
+ * Owner, `ideas/collections-09-14.md`: *"Keep the media player volume where I set it after
15
+ * changing it during a session except if I refresh or first log in then continue starting it
16
+ * at mute. Then if I unmute it, keep that value as I change to other videos."*
17
+ *
18
+ * Three rules, and every one of them is asserted below, because each fails silently:
19
+ *
20
+ * · a fresh page starts MUTED — a stored level would have to be noticed to be undone
21
+ * · a level set on one clip reaches the next — the whole ask
22
+ * · a refresh forgets it — which is why the value is held in memory, and is proven here as
23
+ * "neither web store is touched". 🔴 That is the load-bearing assertion in this file:
24
+ * `sessionStorage` sounds like the right home and is not, because it is cleared when the
25
+ * TAB closes rather than when the page loads (measured in this repo's own Chromium:
26
+ * write, reload, the value is still there). Moving this onto either store brings his
27
+ * volume back loud across exactly the gesture he asked to return to silence, and nothing
28
+ * else in the suite would see it.
29
+ *
30
+ * The starting values the player derives from these live in `media/VideoPlayer.tsx`
31
+ * (`resolveAutoplay`) and are asserted in its own spec — a Vidstack player under happy-dom is
32
+ * an inert custom element, so the decision has to be readable without one.
33
+ *
34
+ * Every test below writes under a key of its own rather than under the two real ones, for the
35
+ * reason the map exists at all: a page-lifetime value has no teardown, so tests that shared a
36
+ * key would be an ordering dependency in a file about remembering things.
37
+ */
38
+
39
+ describe('the session volume', () => {
40
+ test('🔴 a surface nobody has touched has nothing set — so it starts at mute', () => {
41
+ // `null`, NOT the default value: the player has to be able to tell "he has not touched
42
+ // it" from "he set it to 1.0 unmuted", or every click-to-play consumer in the fleet gets
43
+ // silently muted on upgrade. See `readSessionVolume`.
44
+ expect(readSessionVolume('test:untouched')).toBeNull();
45
+ // And the fallback the player uses in that case is silent.
46
+ expect(DEFAULT_SESSION_VOLUME.muted).toBe(true);
47
+ });
48
+
49
+ test('a level set on one item is what the next item reads', () => {
50
+ writeSessionVolume('test:carries-over', { volume: 0.4, muted: false });
51
+ // The next clip is a fresh mount of the player, which reads exactly this.
52
+ expect(readSessionVolume('test:carries-over')).toEqual({ volume: 0.4, muted: false });
53
+ });
54
+
55
+ test('🔴 muting keeps the level underneath, so unmuting is not a jump to full', () => {
56
+ // *"if I unmute it, keep that value"* — a mute stored as `volume: 0` would satisfy every
57
+ // other test here and return him to silence on the press that asks for sound.
58
+ writeSessionVolume('test:muted-level', { volume: 0.4, muted: false });
59
+ writeSessionVolume('test:muted-level', { volume: 0.4, muted: true });
60
+ expect(readSessionVolume('test:muted-level')).toEqual({ volume: 0.4, muted: true });
61
+ });
62
+
63
+ test('a reader gets a copy — a live player cannot mutate the next one’s starting value', () => {
64
+ writeSessionVolume('test:copy', { volume: 0.4, muted: false });
65
+ const held = readSessionVolume('test:copy');
66
+ if (held) held.volume = 0.99;
67
+ expect(readSessionVolume('test:copy')?.volume).toBe(0.4);
68
+ });
69
+
70
+ test('the two real surfaces do not fight over one key', () => {
71
+ // The gallery stage and a `VideoPlayer` an app mounts on a page of its own are two
72
+ // players — the same reason `media/stageResize.ts` keys the remembered height. One
73
+ // shared key would make setting the level on either of them reach into the other.
74
+ expect(STAGE_VOLUME_KEY).not.toBe(PLAYER_VOLUME_KEY);
75
+ writeSessionVolume('test:surface-a', { volume: 0.2, muted: false });
76
+ expect(readSessionVolume('test:surface-b')).toBeNull();
77
+ });
78
+
79
+ test('a level outside 0–1 is clamped rather than handed to an element that would throw', () => {
80
+ // `HTMLMediaElement.volume` throws `IndexSizeError` outside 0–1, and the value arrives
81
+ // from a player's own event — clamping at the store is the one place every path passes.
82
+ writeSessionVolume('test:clamp', { volume: 4, muted: false });
83
+ expect(readSessionVolume('test:clamp')?.volume).toBe(1);
84
+ writeSessionVolume('test:clamp', { volume: -2, muted: false });
85
+ expect(readSessionVolume('test:clamp')?.volume).toBe(0);
86
+ });
87
+ });
88
+
89
+ /**
90
+ * 🔴 Nothing reaches a web store — that is what makes a refresh silent.
91
+ *
92
+ * `localStorage` is the obvious wrong home and `sessionStorage` is the subtle one: it is
93
+ * cleared when the tab closes, NOT when the page reloads, so a "session" volume put there
94
+ * survives the F5 he asked to return him to mute. Both are asserted, because the point of a
95
+ * check is to red on the change a later reader would consider an improvement.
96
+ */
97
+ describe('a refresh forgets it, by construction', () => {
98
+ test('a write touches neither `localStorage` nor `sessionStorage`', () => {
99
+ const persisted = spyOn(globalThis.localStorage, 'setItem');
100
+ const sessioned = spyOn(globalThis.sessionStorage, 'setItem');
101
+ try {
102
+ writeSessionVolume(STAGE_VOLUME_KEY, { volume: 0.55, muted: false });
103
+ expect(persisted).not.toHaveBeenCalled();
104
+ expect(sessioned).not.toHaveBeenCalled();
105
+ } finally {
106
+ persisted.mockRestore();
107
+ sessioned.mockRestore();
108
+ }
109
+ // Belt and braces: neither store has an entry under that key for a reload to find.
110
+ expect(globalThis.localStorage.getItem(STAGE_VOLUME_KEY)).toBeNull();
111
+ expect(globalThis.sessionStorage.getItem(STAGE_VOLUME_KEY)).toBeNull();
112
+ });
113
+
114
+ test('…while the sibling preferences beside it still DO persist', () => {
115
+ // The guard above must not be read as "this module stopped remembering things", which is
116
+ // how a later reader could "fix" the inconsistency in the wrong direction. The end
117
+ // behavior is a way of WORKING and survives a reload on purpose; only loudness was asked
118
+ // to be forgotten, and the two live in one file.
119
+ try {
120
+ writePlaybackEnd('all');
121
+ expect(globalThis.localStorage.getItem('cbmg:playback-end')).toBe('all');
122
+ } finally {
123
+ globalThis.localStorage.removeItem('cbmg:playback-end');
124
+ }
125
+ });
126
+ });
127
+
128
+ /**
129
+ * 🔴 A read cannot throw, and that is not an accident of the implementation.
130
+ *
131
+ * Every other reader in this module is wrapped because `localStorage` THROWS rather than
132
+ * returning null in a sandboxed iframe and in Safari's private mode, and an unguarded read
133
+ * takes the whole gallery render down over a remembered preference. This one reaches for no
134
+ * storage at all, so there is nothing to be denied — asserted against a hostile environment
135
+ * rather than assumed, so that moving it onto a web store without a guard fails here.
136
+ */
137
+ describe('a hostile storage environment', () => {
138
+ function withHostileStorage<T>(run: () => T): T {
139
+ const originals = (['sessionStorage', 'localStorage'] as const).map(
140
+ (name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const,
141
+ );
142
+ for (const [name] of originals) {
143
+ Object.defineProperty(globalThis, name, {
144
+ configurable: true,
145
+ get() {
146
+ throw new Error(`SecurityError: access to ${name} is denied`);
147
+ },
148
+ });
149
+ }
150
+ try {
151
+ return run();
152
+ } finally {
153
+ for (const [name, descriptor] of originals) {
154
+ if (descriptor) Object.defineProperty(globalThis, name, descriptor);
155
+ }
156
+ }
157
+ }
158
+
159
+ test('a read still answers instead of taking the render down with it', () => {
160
+ writeSessionVolume('test:hostile', { volume: 0.25, muted: false });
161
+ expect(withHostileStorage(() => readSessionVolume('test:hostile'))).toEqual({
162
+ volume: 0.25,
163
+ muted: false,
164
+ });
165
+ });
166
+
167
+ test('a write is not an error either — an unremembered volume still works this clip', () => {
168
+ expect(() =>
169
+ withHostileStorage(() => writeSessionVolume('test:hostile', { volume: 0.3, muted: false })),
170
+ ).not.toThrow();
171
+ });
172
+ });
@@ -257,3 +257,103 @@ export function writeViewMode(mode: GalleryViewMode): void {
257
257
  // Unremembered is still usable this session.
258
258
  }
259
259
  }
260
+
261
+ // ── How loud, for THIS SESSION and no longer ──────────────────────────────────────────────
262
+
263
+ /**
264
+ * The level a player opens at, and whether it opens silent.
265
+ *
266
+ * ── The owner's spec, verbatim (`ideas/collections-09-14.md`, 2026-09-14) ───────────────
267
+ * *"Keep the media player volume where I set it after changing it during a session except if
268
+ * I refresh or first log in then continue starting it at mute. Then if I unmute it, keep that
269
+ * value as I change to other videos."*
270
+ *
271
+ * Three rules, and the middle one is the whole design:
272
+ *
273
+ * | fresh page load / first sign-in | muted, exactly as it is today |
274
+ * | he changes it, then opens another clip | that level, for the rest of the session |
275
+ * | he refreshes | muted again |
276
+ *
277
+ * ── 🔴 It is kept IN MEMORY. Not `localStorage`, and not `sessionStorage` either ────────
278
+ * The neighbours above are `localStorage` because they are ways of WORKING — how a folder
279
+ * plays through, how a stage is pinned — and a way of working survives a reload. Loudness is
280
+ * not: *"except if I refresh … continue starting it at mute"* is a request for this to be
281
+ * FORGOTTEN, so the only correct store is one that cannot outlive the page.
282
+ *
283
+ * 🔴 `sessionStorage` is NOT that store, however much its name suggests it. It is cleared
284
+ * when the TAB closes, not when the page loads — MDN, in as many words: *"the data survives
285
+ * page reloads and restores"*. Measured here 2026-09-16 in the repo's own Chromium (write,
286
+ * `page.reload()`, read: the value is still there), because the difference is invisible in a
287
+ * unit test and is precisely the row of the table above that he called out. A volume put
288
+ * there comes back LOUD on the one gesture he named as the one that must return to silence.
289
+ *
290
+ * A module-level `Map` dies with the document, which is what a person means by "this
291
+ * session". It is also why nothing here needs the `try` wrapping every reader above carries:
292
+ * there is no web storage to be denied by a sandboxed iframe or by Safari's private mode, and
293
+ * therefore no read that can throw the gallery render away over a remembered volume. The
294
+ * specs assert both stores stay untouched, so a later move onto one of them — which would
295
+ * silently restore loudness across a refresh — fails rather than passes.
296
+ *
297
+ * SSR-safe for the same reason: writes only ever happen in a browser event handler, so the
298
+ * map a server renders against is always empty and every server-rendered player starts muted.
299
+ *
300
+ * ── 🔴 Muted and the level are TWO facts ────────────────────────────────────────────────
301
+ * *"if I unmute it, keep that value"* — so a mute may not be stored as `volume: 0`. Pressing
302
+ * mute and pressing unmute has to leave the level where he put it, which it only does if the
303
+ * level survives the mute independently. Every media element already models these two
304
+ * separately; collapsing them here is what would break it.
305
+ */
306
+ export interface SessionVolume {
307
+ /** 0–1: what it plays at, and what UNMUTING returns to. */
308
+ volume: number;
309
+ /** Silent — whatever level is underneath. */
310
+ muted: boolean;
311
+ }
312
+
313
+ /**
314
+ * What a player opens at when the person has not touched the volume this session: full level,
315
+ * no sound. It is the level that matters — a restored `volume: 1` under a mute is what makes
316
+ * the control bar's unmute button behave the way it did before any of this existed.
317
+ */
318
+ export const DEFAULT_SESSION_VOLUME: SessionVolume = { volume: 1, muted: true };
319
+
320
+ /**
321
+ * Where a session volume is kept — the CALLER names it, for the same reason
322
+ * `media/stageResize.ts` makes the remembered HEIGHT a keyed value: there is more than one
323
+ * player in this package, and one shared key would make setting the level on one of them
324
+ * silently reach into the other. The gallery stage and the shared `VideoPlayer` primitive an
325
+ * app mounts on a page of its own are two players.
326
+ */
327
+ export const PLAYER_VOLUME_KEY = 'cbvp:volume';
328
+
329
+ /** The gallery stage's own — both transports of it (Vidstack and the native `<video>`). */
330
+ export const STAGE_VOLUME_KEY = 'cbmg:stage-volume';
331
+
332
+ /** Per key, for this document only. See {@link SessionVolume} for why it is not a web store. */
333
+ const sessionVolumes = new Map<string, SessionVolume>();
334
+
335
+ /**
336
+ * What he set this session, or `null` for "he has not touched it".
337
+ *
338
+ * 🔴 `null` rather than {@link DEFAULT_SESSION_VOLUME}, and the distinction is load-bearing:
339
+ * a player that was never going to make a sound anyway (`autoPlay={false}`, which mutes
340
+ * nothing today) must keep behaving exactly as it did, and it only can if the caller can tell
341
+ * "nothing set" apart from "set to the default". Handing back the default here would silence
342
+ * every click-to-play consumer in the fleet on upgrade.
343
+ */
344
+ export function readSessionVolume(key: string): SessionVolume | null {
345
+ const held = sessionVolumes.get(key);
346
+ // A COPY: the caller hands this to a player that will go on changing it, and a shared
347
+ // object would let one surface's live state leak into the next one's starting value.
348
+ return held ? { ...held } : null;
349
+ }
350
+
351
+ /** Remember it for the rest of this PAGE. Never longer — see {@link SessionVolume}. */
352
+ export function writeSessionVolume(key: string, value: SessionVolume): void {
353
+ sessionVolumes.set(key, {
354
+ // Clamped here rather than at every call site: this lands on `HTMLMediaElement.volume`,
355
+ // which throws `IndexSizeError` outside 0–1.
356
+ volume: Math.min(1, Math.max(0, value.volume)),
357
+ muted: value.muted,
358
+ });
359
+ }
@@ -1,5 +1,6 @@
1
1
  import type { FileCategory } from 'cwip/file-kind';
2
2
  import type { ProcessingStatus } from 'cursedbelt-core/media';
3
+ import type { VideoChapter } from '../media/videoChapters';
3
4
 
4
5
  /**
5
6
  * Sort orders the gallery header offers.
@@ -91,6 +92,25 @@ export interface MediaGalleryItem extends MediaGalleryItemMedia {
91
92
  durationSeconds?: number;
92
93
  /** Free-form tags — shown in the meta line and as filter chips when any item has them. */
93
94
  tags?: string[];
95
+ /**
96
+ * What this file IS, in the owner's own words — shown under the title on the stage.
97
+ *
98
+ * 🔴 The owner's ask, `ideas/collections-09-14.md`, 2026-09-14: *"In media galleries have an
99
+ * option for apps to tag media and aadd descriptions to media."* Tags were already readable
100
+ * here and there was no description field at all, so half of that sentence had nowhere to
101
+ * land even for an app that stored one.
102
+ *
103
+ * Like {@link tags} it is READ freely: an item that carries one renders it whether or not
104
+ * the app can write one. Authoring both is a capability — pass
105
+ * {@link MediaGalleryProps.onSaveMeta} and the control appears.
106
+ *
107
+ * 🔴 Deliberately NOT on the tile caption. That caption is two lines by design (see
108
+ * `galleryTileLine`) and is already contested between the tags and the measurement; a
109
+ * sentence there would either be an ellipsis or a third line on every tile in the grid.
110
+ * The stage has the room, and the stage is where somebody is actually reading about one
111
+ * file rather than scanning forty.
112
+ */
113
+ description?: string;
94
114
  /** Download href when it differs from `url` (e.g. `?download=1` variants). */
95
115
  downloadUrl?: string;
96
116
  /**
@@ -109,6 +129,20 @@ export interface MediaGalleryItem extends MediaGalleryItemMedia {
109
129
  * cannot render differently. An app that bakes its edits simply never sets this.
110
130
  */
111
131
  orientation?: { turns: 0 | 1 | 2 | 3; flipX?: boolean; flipY?: boolean };
132
+ /**
133
+ * The named sections of a video — YouTube chapters, stored by the app on its own row.
134
+ *
135
+ * 🔴 The owner's ask, 2026-09-15: *"allow putting markers on videos at points in time and
136
+ * giving them a title for the section for knowing what that part of the video is about, like
137
+ * youtube chapters."* Like {@link orientation}, this is metadata the app keeps beside the
138
+ * file rather than anything baked into it — a chapter costs no encode, applies the instant it
139
+ * is saved, and is free to be wrong.
140
+ *
141
+ * The stage passes them to the player, which draws the dividers on the scrub bar and fills
142
+ * its chapters menu (see `stagePlayableVideo`). Authoring them is a separate capability:
143
+ * pass {@link MediaGalleryProps.onSaveChapters} and the Sections control appears.
144
+ */
145
+ chapters?: VideoChapter[];
112
146
  /**
113
147
  * Lazily resolve this item's media URLs the first time a tile or stage needs them.
114
148
  * Called once per item id per gallery mount (cached); return `null` when the bytes
@@ -0,0 +1,69 @@
1
+ /**
2
+ * 🔴 `bun publish` packs the WORKING DIRECTORY, not the git index — so a package can
3
+ * ship a file the repo does not have.
4
+ *
5
+ * ── What this cost, 2026-09-15 ─────────────────────────────────────────────────────
6
+ * The cursedbelt split (task 148) moved two integration specs into this package from
7
+ * `cursedbelt`. `git add -u .` stages only files git already knows about, so both were
8
+ * committed nowhere — and `bun run verify` was green *because they were present on disk*,
9
+ * which is the same reason nobody noticed. `1.0.2` went to the registry carrying two
10
+ * `src/` files that existed in no commit, and `git status` said `tracked-dirty=0`.
11
+ *
12
+ * "The tree is clean" and "everything that ships is committed" are different claims, and
13
+ * only the first one is what people actually check. npm versions are immutable, so the
14
+ * window to notice closes at publish time.
15
+ *
16
+ * ── Why this shape ─────────────────────────────────────────────────────────────────
17
+ * It asks git, for exactly the directories `package.json#files` promises to ship, which
18
+ * of those paths git does not track. That is narrower than "any untracked file" on
19
+ * purpose: scratch files, `.agent.noindex/`, a half-written script and an uncommitted
20
+ * lockfile are all legitimate states for a working tree and reddening on them would teach
21
+ * people to skip this. A file INSIDE the published surface is the only case where
22
+ * untracked means "about to ship something no one can review or revert".
23
+ *
24
+ * 🔴 It must run from a real checkout to mean anything, so it refuses rather than passes
25
+ * when `git` cannot answer — a check that silently degrades to green is worse than none.
26
+ */
27
+ import { describe, expect, test } from 'bun:test';
28
+ import { spawnSync } from 'node:child_process';
29
+ import { existsSync } from 'node:fs';
30
+ import { fileURLToPath } from 'node:url';
31
+ import pkg from '../package.json';
32
+
33
+ const repoRoot = fileURLToPath(new URL('..', import.meta.url));
34
+
35
+ /** The directories `files` promises to ship, minus build output nobody commits. */
36
+ const shippedDirs = (): string[] =>
37
+ ((pkg as { files?: string[] }).files ?? []).filter((f) => f !== 'dist' && existsSync(`${repoRoot}/${f}`));
38
+
39
+ const git = (...args: string[]): { ok: boolean; out: string } => {
40
+ const r = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8' });
41
+ return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
42
+ };
43
+
44
+ describe('every file inside the published surface is tracked', () => {
45
+ test('git can answer at all — a degraded check must refuse, not pass', () => {
46
+ expect(git('rev-parse', '--is-inside-work-tree').ok, 'not a git checkout, so this check proves nothing').toBe(true);
47
+ });
48
+
49
+ test('package.json names a shippable directory to scan', () => {
50
+ // Vacuity guard: an empty `files`, or one naming only `dist`, makes the assertion
51
+ // below pass while scanning nothing at all.
52
+ expect(shippedDirs().length, '`files` names no committed directory — nothing would be scanned').toBeGreaterThan(0);
53
+ });
54
+
55
+ test('no untracked file sits inside a directory `files` ships', () => {
56
+ // --others = untracked; --exclude-standard honours .gitignore, so deliberately
57
+ // ignored paths (.agent.noindex, node_modules) are not offences.
58
+ const { ok, out } = git('ls-files', '--others', '--exclude-standard', '--', ...shippedDirs());
59
+ expect(ok, 'git ls-files failed').toBe(true);
60
+ const untracked = out === '' ? [] : out.split('\n');
61
+ expect(
62
+ untracked,
63
+ 'these would be PUBLISHED but exist in no commit — `bun publish` packs the working directory, not the index,\n' +
64
+ 'so the registry would carry code nobody can review, diff or revert, and npm versions are immutable.\n' +
65
+ 'Commit them (or add them to .gitignore if they genuinely must not ship):\n ' +
66
+ untracked.join('\n '),
67
+ ).toEqual([]);
68
+ });
69
+ });
@@ -0,0 +1,30 @@
1
+ /*
2
+ * cursedbelt — the `activity-bar` area. GENERATED BUILD ARTIFACT. Never hand-edit.
3
+ *
4
+ * the `cursedbelt/react/activity-bar` rail — app chrome most apps do not mount.
5
+ *
6
+ * Every Tailwind utility the modules in this area's import closure use, MINUS the
7
+ * ones `styles-areas/core.css` already ships. 🔴 Import ALL of these, in
8
+ * this order — this file alone is incomplete:
9
+ *
10
+ * @import "tailwindcss";
11
+ * @import "cursedbelt/styles-static.css";
12
+ * @import "cursedbelt/styles-areas/core.css";
13
+ * @import "cursedbelt/styles-areas/activity-bar.css";
14
+ * @import "cursedbelt/theme.css";
15
+ *
16
+ * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
+ * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
+ * when this file is stale. 72 candidate(s) scanned.
19
+ *
20
+ * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
+ * `bun run build` runs. The map of what is in which area, and why the areas are
22
+ * import closures rather than a hand-written list, is scripts/styleAreas.ts.
23
+ */
24
+ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
25
+ @layer theme, base, components, utilities;
26
+ @layer utilities {
27
+ .collapse {
28
+ visibility: collapse;
29
+ }
30
+ }