cursedbelt 3.0.2 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/dist/react/components/ListToolbar.d.ts +94 -0
  2. package/dist/react/components/ListToolbar.d.ts.map +1 -0
  3. package/dist/react/components/ListToolbar.js +66 -0
  4. package/dist/react/components/ListToolbar.js.map +1 -0
  5. package/dist/react/file-tree/FileTree.d.ts +282 -3
  6. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  7. package/dist/react/file-tree/FileTree.js +799 -131
  8. package/dist/react/file-tree/FileTree.js.map +1 -1
  9. package/dist/react/file-tree/fileTree.css +151 -0
  10. package/dist/react/file-tree/fileTreeSelection.d.ts +187 -0
  11. package/dist/react/file-tree/fileTreeSelection.d.ts.map +1 -0
  12. package/dist/react/file-tree/fileTreeSelection.js +264 -0
  13. package/dist/react/file-tree/fileTreeSelection.js.map +1 -0
  14. package/dist/react/file-tree/index.d.ts +1 -0
  15. package/dist/react/file-tree/index.d.ts.map +1 -1
  16. package/dist/react/file-tree/index.js +8 -0
  17. package/dist/react/file-tree/index.js.map +1 -1
  18. package/dist/react/index.d.ts +1 -0
  19. package/dist/react/index.d.ts.map +1 -1
  20. package/dist/react/index.js +1 -0
  21. package/dist/react/index.js.map +1 -1
  22. package/dist/react/lib/form.d.ts +3 -0
  23. package/dist/react/lib/form.d.ts.map +1 -1
  24. package/dist/react/lib/form.js +43 -5
  25. package/dist/react/lib/form.js.map +1 -1
  26. package/dist/react/media/DenseGalleryShell.d.ts +19 -3
  27. package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
  28. package/dist/react/media/DenseGalleryShell.js +55 -15
  29. package/dist/react/media/DenseGalleryShell.js.map +1 -1
  30. package/dist/react/media/FocusViewer.d.ts.map +1 -1
  31. package/dist/react/media/FocusViewer.js +14 -14
  32. package/dist/react/media/FocusViewer.js.map +1 -1
  33. package/dist/react/media/FrameGrabber.d.ts +23 -0
  34. package/dist/react/media/FrameGrabber.d.ts.map +1 -1
  35. package/dist/react/media/FrameGrabber.js +67 -1
  36. package/dist/react/media/FrameGrabber.js.map +1 -1
  37. package/dist/react/media/MediaResize.d.ts +51 -19
  38. package/dist/react/media/MediaResize.d.ts.map +1 -1
  39. package/dist/react/media/MediaResize.js +20 -13
  40. package/dist/react/media/MediaResize.js.map +1 -1
  41. package/dist/react/media/VideoChapterEditor.d.ts +67 -0
  42. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -0
  43. package/dist/react/media/VideoChapterEditor.js +148 -0
  44. package/dist/react/media/VideoChapterEditor.js.map +1 -0
  45. package/dist/react/media/VideoPlayer.d.ts +46 -4
  46. package/dist/react/media/VideoPlayer.d.ts.map +1 -1
  47. package/dist/react/media/VideoPlayer.js +124 -11
  48. package/dist/react/media/VideoPlayer.js.map +1 -1
  49. package/dist/react/media/VideoTurner.d.ts +15 -2
  50. package/dist/react/media/VideoTurner.d.ts.map +1 -1
  51. package/dist/react/media/VideoTurner.js +29 -2
  52. package/dist/react/media/VideoTurner.js.map +1 -1
  53. package/dist/react/media/denseGallery.css +104 -46
  54. package/dist/react/media/hlsSource.d.ts +57 -0
  55. package/dist/react/media/hlsSource.d.ts.map +1 -1
  56. package/dist/react/media/hlsSource.js +177 -1
  57. package/dist/react/media/hlsSource.js.map +1 -1
  58. package/dist/react/media/index.d.ts +2 -0
  59. package/dist/react/media/index.d.ts.map +1 -1
  60. package/dist/react/media/index.js +6 -0
  61. package/dist/react/media/index.js.map +1 -1
  62. package/dist/react/media/mediaResize.css +127 -54
  63. package/dist/react/media/pictureEditor.css +52 -6
  64. package/dist/react/media/videoChapterEditor.css +203 -0
  65. package/dist/react/media/videoChapters.d.ts +117 -0
  66. package/dist/react/media/videoChapters.d.ts.map +1 -0
  67. package/dist/react/media/videoChapters.js +95 -0
  68. package/dist/react/media/videoChapters.js.map +1 -0
  69. package/dist/react/media-gallery/GalleryTable.d.ts +52 -1
  70. package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
  71. package/dist/react/media-gallery/GalleryTable.js +40 -9
  72. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  73. package/dist/react/media-gallery/MediaGallery.d.ts +107 -4
  74. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  75. package/dist/react/media-gallery/MediaGallery.js +484 -104
  76. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  77. package/dist/react/media-gallery/MediaMetaEditor.d.ts +48 -0
  78. package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -0
  79. package/dist/react/media-gallery/MediaMetaEditor.js +66 -0
  80. package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -0
  81. package/dist/react/media-gallery/index.d.ts +2 -0
  82. package/dist/react/media-gallery/index.d.ts.map +1 -1
  83. package/dist/react/media-gallery/index.js +5 -0
  84. package/dist/react/media-gallery/index.js.map +1 -1
  85. package/dist/react/media-gallery/mediaGallery.css +180 -4
  86. package/dist/react/media-gallery/mediaMeta.d.ts +62 -0
  87. package/dist/react/media-gallery/mediaMeta.d.ts.map +1 -0
  88. package/dist/react/media-gallery/mediaMeta.js +56 -0
  89. package/dist/react/media-gallery/mediaMeta.js.map +1 -0
  90. package/dist/react/media-gallery/playbackPreferences.d.ts +77 -0
  91. package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
  92. package/dist/react/media-gallery/playbackPreferences.js +42 -0
  93. package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
  94. package/dist/react/media-gallery/types.d.ts +34 -0
  95. package/dist/react/media-gallery/types.d.ts.map +1 -1
  96. package/dist/scripts/guardrailsEnforce.d.ts +29 -0
  97. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -0
  98. package/dist/styles-areas/activity-bar.css +30 -0
  99. package/dist/styles-areas/analytics.css +672 -0
  100. package/dist/styles-areas/auth.css +154 -0
  101. package/dist/styles-areas/calendar.css +416 -0
  102. package/dist/styles-areas/chart-table.css +41 -0
  103. package/dist/styles-areas/charts.css +89 -0
  104. package/dist/styles-areas/chat.css +364 -0
  105. package/dist/styles-areas/clipboard.css +26 -0
  106. package/dist/styles-areas/code-editor.css +130 -0
  107. package/dist/styles-areas/companion-link.css +70 -0
  108. package/dist/styles-areas/core.css +2503 -0
  109. package/dist/styles-areas/dashboard-grid.css +84 -0
  110. package/dist/styles-areas/data-table.css +657 -0
  111. package/dist/styles-areas/deep-link.css +26 -0
  112. package/dist/styles-areas/diff-viewer.css +30 -0
  113. package/dist/styles-areas/disk-usage.css +262 -0
  114. package/dist/styles-areas/emoji.css +154 -0
  115. package/dist/styles-areas/fields.css +582 -0
  116. package/dist/styles-areas/file-tree.css +33 -0
  117. package/dist/styles-areas/filter-rail.css +235 -0
  118. package/dist/styles-areas/folder-tree.css +219 -0
  119. package/dist/styles-areas/keep-awake.css +26 -0
  120. package/dist/styles-areas/layout-engine.css +239 -0
  121. package/dist/styles-areas/markdown.css +87 -0
  122. package/dist/styles-areas/master-detail.css +239 -0
  123. package/dist/styles-areas/master-lock.css +26 -0
  124. package/dist/styles-areas/media-gallery.css +351 -0
  125. package/dist/styles-areas/media.css +658 -0
  126. package/dist/styles-areas/nav.css +488 -0
  127. package/dist/styles-areas/notifications.css +245 -0
  128. package/dist/styles-areas/overlays.css +133 -0
  129. package/dist/styles-areas/palette.css +140 -0
  130. package/dist/styles-areas/pdf-viewer.css +47 -0
  131. package/dist/styles-areas/problem-detail.css +97 -0
  132. package/dist/styles-areas/rich-text.css +167 -0
  133. package/dist/styles-areas/sharing.css +153 -0
  134. package/dist/styles-areas/spreadsheet.css +37 -0
  135. package/dist/styles-areas/stats.css +281 -0
  136. package/dist/styles-areas/test-report.css +133 -0
  137. package/dist/styles-areas/virtual.css +30 -0
  138. package/dist/styles-areas/wizard.css +381 -0
  139. package/dist/styles-areas/workbench.css +543 -0
  140. package/dist/styles-areas/workbook-viewer.css +44 -0
  141. package/dist/styles-static.css +34 -23
  142. package/dist/styles.css +34 -23
  143. package/package.json +130 -32
  144. package/scripts/checkDistExports.ts +67 -0
  145. package/scripts/cssRules.ts +125 -0
  146. package/scripts/fixtureAppCss.ts +344 -0
  147. package/scripts/generateAreaStyles.ts +253 -0
  148. package/scripts/guardrailsEnforce.spec.ts +62 -0
  149. package/scripts/guardrailsEnforce.ts +67 -3
  150. package/scripts/paths.ts +106 -0
  151. package/scripts/styleAreas.ts +585 -0
  152. package/scripts/verify.ts +5 -0
  153. package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
  154. package/src/docsMatchTheSplit.spec.ts +98 -0
  155. package/src/fixtureAppCss.spec.ts +177 -0
  156. package/src/namedSubpathsResolve.spec.ts +35 -0
  157. package/src/publishShape.spec.ts +67 -0
  158. package/src/react/components/ListToolbar.spec.tsx +172 -0
  159. package/src/react/components/ListToolbar.tsx +151 -0
  160. package/src/react/file-tree/FileTree.spec.tsx +1009 -0
  161. package/src/react/file-tree/FileTree.tsx +1459 -309
  162. package/src/react/file-tree/fileTree.css +151 -0
  163. package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
  164. package/src/react/file-tree/fileTreeSelection.ts +321 -0
  165. package/src/react/file-tree/index.ts +21 -0
  166. package/src/react/index.ts +1 -0
  167. package/src/react/lib/form.ts +51 -8
  168. package/src/react/markdown/MarkdownDoc.spec.tsx +1 -1
  169. package/src/react/media/DenseGalleryShell.tsx +108 -36
  170. package/src/react/media/FocusViewer.tsx +15 -8
  171. package/src/react/media/FrameGrabber.spec.tsx +175 -0
  172. package/src/react/media/FrameGrabber.tsx +67 -0
  173. package/src/react/media/MediaResize.spec.tsx +84 -14
  174. package/src/react/media/MediaResize.tsx +51 -19
  175. package/src/react/media/VideoChapterEditor.tsx +428 -0
  176. package/src/react/media/VideoPlayer.spec.tsx +111 -1
  177. package/src/react/media/VideoPlayer.tsx +158 -11
  178. package/src/react/media/VideoTurner.spec.tsx +67 -0
  179. package/src/react/media/VideoTurner.tsx +70 -15
  180. package/src/react/media/denseGallery.css +104 -46
  181. package/src/react/media/hlsSource.recovery.spec.ts +213 -0
  182. package/src/react/media/hlsSource.spec.ts +8 -0
  183. package/src/react/media/hlsSource.ts +189 -1
  184. package/src/react/media/index.ts +14 -0
  185. package/src/react/media/mediaResize.css +127 -54
  186. package/src/react/media/pictureEditor.css +52 -6
  187. package/src/react/media/videoChapterEditor.css +203 -0
  188. package/src/react/media/videoChapters.spec.ts +176 -0
  189. package/src/react/media/videoChapters.ts +181 -0
  190. package/src/react/media-gallery/GalleryTable.tsx +93 -3
  191. package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
  192. package/src/react/media-gallery/MediaGallery.tsx +813 -95
  193. package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
  194. package/src/react/media-gallery/galleryTable.spec.ts +41 -1
  195. package/src/react/media-gallery/index.ts +7 -0
  196. package/src/react/media-gallery/mediaGallery.css +180 -4
  197. package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
  198. package/src/react/media-gallery/mediaMeta.ts +101 -0
  199. package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
  200. package/src/react/media-gallery/playbackPreferences.ts +100 -0
  201. package/src/react/media-gallery/types.ts +34 -0
  202. package/src/shippedFilesAreTracked.spec.ts +69 -0
  203. package/src/styles-areas/activity-bar.css +30 -0
  204. package/src/styles-areas/analytics.css +672 -0
  205. package/src/styles-areas/auth.css +154 -0
  206. package/src/styles-areas/calendar.css +416 -0
  207. package/src/styles-areas/chart-table.css +41 -0
  208. package/src/styles-areas/charts.css +89 -0
  209. package/src/styles-areas/chat.css +364 -0
  210. package/src/styles-areas/clipboard.css +26 -0
  211. package/src/styles-areas/code-editor.css +130 -0
  212. package/src/styles-areas/companion-link.css +70 -0
  213. package/src/styles-areas/core.css +2503 -0
  214. package/src/styles-areas/dashboard-grid.css +84 -0
  215. package/src/styles-areas/data-table.css +657 -0
  216. package/src/styles-areas/deep-link.css +26 -0
  217. package/src/styles-areas/diff-viewer.css +30 -0
  218. package/src/styles-areas/disk-usage.css +262 -0
  219. package/src/styles-areas/emoji.css +154 -0
  220. package/src/styles-areas/fields.css +582 -0
  221. package/src/styles-areas/file-tree.css +33 -0
  222. package/src/styles-areas/filter-rail.css +235 -0
  223. package/src/styles-areas/folder-tree.css +219 -0
  224. package/src/styles-areas/keep-awake.css +26 -0
  225. package/src/styles-areas/layout-engine.css +239 -0
  226. package/src/styles-areas/markdown.css +87 -0
  227. package/src/styles-areas/master-detail.css +239 -0
  228. package/src/styles-areas/master-lock.css +26 -0
  229. package/src/styles-areas/media-gallery.css +351 -0
  230. package/src/styles-areas/media.css +658 -0
  231. package/src/styles-areas/nav.css +488 -0
  232. package/src/styles-areas/notifications.css +245 -0
  233. package/src/styles-areas/overlays.css +133 -0
  234. package/src/styles-areas/palette.css +140 -0
  235. package/src/styles-areas/pdf-viewer.css +47 -0
  236. package/src/styles-areas/problem-detail.css +97 -0
  237. package/src/styles-areas/rich-text.css +167 -0
  238. package/src/styles-areas/sharing.css +153 -0
  239. package/src/styles-areas/spreadsheet.css +37 -0
  240. package/src/styles-areas/stats.css +281 -0
  241. package/src/styles-areas/test-report.css +133 -0
  242. package/src/styles-areas/virtual.css +30 -0
  243. package/src/styles-areas/wizard.css +381 -0
  244. package/src/styles-areas/workbench.css +543 -0
  245. package/src/styles-areas/workbook-viewer.css +44 -0
  246. package/src/styles-static.css +34 -23
  247. package/src/styles.css +34 -23
  248. package/src/stylesAreas.spec.ts +247 -0
  249. package/src/stylesUtilitiesMatches.spec.ts +6 -77
  250. package/src/testFilesRunInParallel.spec.ts +79 -0
  251. package/src/typecheckCachesAreSeparate.spec.ts +128 -0
  252. package/src/verifyGraph.spec.ts +6 -0
@@ -0,0 +1,321 @@
1
+ /**
2
+ * The arithmetic of a MULTI-selection: which rows a range covers, what a ⌘-click leaves
3
+ * behind, which of N selected rows are the ones actually being carried, and whether a drop
4
+ * is legal for all of them at once — and, since 2026-09-16, where a folder made AROUND a
5
+ * selection goes and what it is called ({@link planWrap}).
6
+ *
7
+ * No DOM, so all of it is unit-tested in `fileTreeSelection.spec.ts` — the same split
8
+ * `resolveDrop`/`resolveKeyboardMove` already make against `FileTree.tsx`, and for the same
9
+ * reason: happy-dom computes no layout, so the pointer half of the gesture cannot be
10
+ * asserted anywhere and the RULES have to be reachable without it.
11
+ *
12
+ * ── Where this came from ────────────────────────────────────────────────────────────────
13
+ * Owner, 2026-09-14, `ideas/file-tree.md` item 2: *"If I click on a file in the sidebar file
14
+ * tree with the folders then hold shift and click another item in the folder, I want it to
15
+ * select all the files from the first file to the one I clicked next while holding shift.
16
+ * Then with all of those selected I want to be able to click and hold on any of them to drag
17
+ * and drop them to another folder or to the root outside of any folder. Another key shortcut
18
+ * functionality I need is selecting multiple files by holding the command key on my mac and
19
+ * clicking each file I want to select then dragging them to another folder. For both of these
20
+ * new selection functionalities I also want to be able to hold command and click delete to
21
+ * delete all the selected files. I also want to be able to cut and paste to move them with
22
+ * command + x and command + v, and copy them with command + c and command + v."*
23
+ *
24
+ * ── 🔴 Why this is here and not in `cursedbelt-core/file-tree` ───────────────────────────
25
+ * `fileTreeModel.ts` — `resolveDrop`, `flattenFileTree`, the node types — lives in the
26
+ * `cursedbelt-core` PACKAGE, which this one consumes by published version. Nothing in this
27
+ * repo can change that file: a `file:` or `link:` dependency is refused outright by
28
+ * `src/noPathDeps.spec.ts`, so the model's other half would have to be published there first
29
+ * and picked up here by a version bump — two packages, two publishes, for one feature.
30
+ *
31
+ * So the SPLIT the model half exists for is honoured exactly (no DOM, unit-tested, the drop
32
+ * rules reachable without a renderer) and the package boundary is honoured too. Everything
33
+ * here is built out of `fileTreeModel`'s own primitives rather than re-deriving them, so
34
+ * there is one definition of "legal drop" and this composes it N times.
35
+ */
36
+ import {
37
+ type DropOffer,
38
+ descendantIdsOf,
39
+ type FileTreeNode,
40
+ type FileTreeRow,
41
+ parentIdOf,
42
+ type ResolveDropOptions,
43
+ resolveDrop,
44
+ walkFileTree,
45
+ } from 'cursedbelt-core/file-tree/file-tree-model';
46
+
47
+ /**
48
+ * Every row from the anchor to the one just clicked, inclusive — shift-click's answer.
49
+ *
50
+ * 🔴 Over the rows ON SCREEN, never over the whole tree. A shut folder between the two ends
51
+ * contributes its own row and none of its children, which is what Finder and VS Code both
52
+ * do: a range is what you can see yourself covering. Selecting rows nobody drew and then
53
+ * deleting them on ⌘⌫ is the version of this that loses files.
54
+ *
55
+ * `anchorId` unknown (a row the tree has since re-derived away, or nothing clicked yet)
56
+ * degenerates to just the clicked row rather than to the whole list — the honest reading of
57
+ * "from where?" when there is no where.
58
+ */
59
+ export function selectionRange(
60
+ rows: readonly FileTreeRow[],
61
+ anchorId: string | null,
62
+ focusId: string,
63
+ ): string[] {
64
+ const focus = rows.findIndex((row) => row.node.id === focusId);
65
+ if (focus < 0) return [];
66
+ const anchor = anchorId === null ? -1 : rows.findIndex((row) => row.node.id === anchorId);
67
+ if (anchor < 0) return [focusId];
68
+ const from = Math.min(anchor, focus);
69
+ const to = Math.max(anchor, focus);
70
+ return rows.slice(from, to + 1).map((row) => row.node.id);
71
+ }
72
+
73
+ /**
74
+ * ⌘-click: in if it was out, out if it was in — and the ORDER of the rest is kept.
75
+ *
76
+ * The order is what the drag chip counts and what a consumer's own "first selected" reads,
77
+ * so re-sorting on every toggle would shuffle a selection under the person building it.
78
+ */
79
+ export function toggleSelected(ids: readonly string[], id: string): string[] {
80
+ return ids.includes(id) ? ids.filter((other) => other !== id) : [...ids, id];
81
+ }
82
+
83
+ /**
84
+ * The selected rows with any row that is INSIDE another selected row dropped.
85
+ *
86
+ * 🔴 The trap this exists for. Shift-click a folder and the file inside it, drag both into
87
+ * `Archive`, and the naive answer moves the folder into `Archive` **and** the file into
88
+ * `Archive` — so a file that was two levels deep comes out one level deep, next to its own
89
+ * former parent. The person asked for one move and got their tree quietly flattened.
90
+ *
91
+ * Moving a folder moves what is in it by construction, so a descendant in the same gesture
92
+ * is not a second thing to move: it is the same thing, named twice. Finder resolves it the
93
+ * same way and says nothing about it, which is the right amount to say.
94
+ *
95
+ * Order is preserved, so this is safe to run over a selection on its way to a callback.
96
+ */
97
+ export function topmostSelected(
98
+ nodes: readonly FileTreeNode[],
99
+ ids: readonly string[],
100
+ ): string[] {
101
+ const chosen = new Set(ids);
102
+ return ids.filter((id) => {
103
+ // `parentIdOf` answers null both at the top level and for a row this tree no longer
104
+ // holds, so the walk always terminates — see its own header for why the two are one
105
+ // answer.
106
+ let at = parentIdOf(nodes, id);
107
+ while (at !== null) {
108
+ if (chosen.has(at)) return false;
109
+ at = parentIdOf(nodes, at);
110
+ }
111
+ return true;
112
+ });
113
+ }
114
+
115
+ export interface ResolveMultiDropOptions extends Omit<ResolveDropOptions, 'dragId'> {
116
+ /**
117
+ * Everything being carried. Empty means an EXTERNAL payload — the same thing
118
+ * `resolveDrop` means by `dragId: null`.
119
+ */
120
+ dragIds: readonly string[];
121
+ }
122
+
123
+ /**
124
+ * Where a drag carrying N rows would land — {@link resolveDrop} asked once per row, and the
125
+ * strictest answer wins.
126
+ *
127
+ * 🔴 **One refusal refuses the whole drop.** A gesture that landed four of five rows and
128
+ * silently kept the fifth where it was is the worst possible outcome of a multi-drag: the
129
+ * tree looks like it obeyed, and the row that did not move is the one nobody checks. The
130
+ * rule the single-row drag is built around — *a drop that cannot happen is never offered* —
131
+ * only survives the plural if "can happen" means "for all of them".
132
+ *
133
+ * The first refusal's WORDS are what comes back, so the chip says which rule was broken
134
+ * rather than "some of these cannot go there".
135
+ *
136
+ * Every per-row rule is therefore honoured N times over, out of one definition: the
137
+ * descendant rule (a folder cannot go inside itself, and now also cannot go inside a folder
138
+ * being dragged beside it), `acceptsDrop`, and the consumer's `canDrop` — which is called
139
+ * once per dragged id, so a veto that depends on WHICH row is moving still gets asked about
140
+ * each one.
141
+ */
142
+ export function resolveMultiDrop({ dragIds, ...rest }: ResolveMultiDropOptions): DropOffer {
143
+ if (dragIds.length === 0) return resolveDrop({ ...rest, dragId: null });
144
+ let agreed: DropOffer | null = null;
145
+ for (const dragId of dragIds) {
146
+ const offer = resolveDrop({ ...rest, dragId });
147
+ if (offer.refused) return offer;
148
+ // The target does not vary with the dragged id — only the refusals do, because the row
149
+ // under the pointer and the ratio are the same question for all of them. So the first
150
+ // non-refusing answer IS the answer; the rest of the loop is only asking permission.
151
+ agreed ??= offer;
152
+ }
153
+ return agreed ?? { target: null, refused: false, reason: null };
154
+ }
155
+
156
+ /**
157
+ * Whether a set of rows may be copied, in the consumer's words — `null` when it may.
158
+ *
159
+ * 🔴 Copy is the one clipboard verb that is NOT universal, and `apps/music` is why. A folder
160
+ * there is derived from the `folder` column of the tracks in it, so "copy this folder" would
161
+ * mean duplicating audio the owner has exactly one copy of — a gesture with no meaning, and
162
+ * an expensive one to offer by accident. So the tree never assumes it can paste; the app
163
+ * says, per row, the same shape `canDrop` already uses.
164
+ *
165
+ * The first row that refuses is the one that speaks, for the same reason as above.
166
+ */
167
+ export function refuseCopy(
168
+ nodes: readonly FileTreeNode[],
169
+ ids: readonly string[],
170
+ canCopy: ((node: FileTreeNode) => string | null) | undefined,
171
+ ): string | null {
172
+ return firstRefusal(nodes, ids, canCopy);
173
+ }
174
+
175
+ /**
176
+ * Whether a set of rows may be WRAPPED in a new folder — `refuseCopy`'s twin, for the same
177
+ * reason and in the same shape.
178
+ *
179
+ * 🔴 A tree whose folders are DERIVED cannot wrap anything. `apps/music` builds its folders
180
+ * out of the `folder` column of the tracks in them, so "put these three into a new folder"
181
+ * is not a thing that host can do at all — there is no folder to make. The rule this tree is
182
+ * built around is *a gesture that cannot happen is never offered*, so the veto is asked
183
+ * BEFORE the draft row opens, never after a name has been typed into it: a refusal at commit
184
+ * is a refusal aimed at somebody who has already done the work.
185
+ *
186
+ * The first row that refuses is the one that speaks, as everywhere else here.
187
+ */
188
+ export function refuseWrap(
189
+ nodes: readonly FileTreeNode[],
190
+ ids: readonly string[],
191
+ canWrap: ((node: FileTreeNode) => string | null) | undefined,
192
+ ): string | null {
193
+ return firstRefusal(nodes, ids, canWrap);
194
+ }
195
+
196
+ /** The shared body of the two vetoes above — one definition of "the first refusal wins". */
197
+ function firstRefusal(
198
+ nodes: readonly FileTreeNode[],
199
+ ids: readonly string[],
200
+ veto: ((node: FileTreeNode) => string | null) | undefined,
201
+ ): string | null {
202
+ if (!veto) return null;
203
+ for (const id of ids) {
204
+ const node = nodeById(nodes, id);
205
+ const reason = node ? veto(node) : null;
206
+ if (reason) return reason;
207
+ }
208
+ return null;
209
+ }
210
+
211
+ /**
212
+ * A file's name with its extension taken off — the folder name a wrap opens with.
213
+ *
214
+ * `photo.jpg` → `photo`, `Notes` → `Notes`, and 🔴 `.gitignore` → `.gitignore` rather than
215
+ * the empty string: a name that is ALL extension has no basename to offer, and pre-filling a
216
+ * field with nothing is worse than pre-filling it with the name it came from. Same
217
+ * `lastIndexOf('.')` rule `MediaGallery`'s `extLabel` uses, so one tree and one gallery
218
+ * looking at the same file agree about where its name ends.
219
+ */
220
+ export function wrapFolderName(name: string): string {
221
+ const dot = name.lastIndexOf('.');
222
+ return dot > 0 ? name.slice(0, dot) : name;
223
+ }
224
+
225
+ /** What a wrap would do, worked out before anything is drawn or created. */
226
+ export interface WrapPlan {
227
+ /**
228
+ * What goes into the new folder: the chosen rows, in TREE order, with any row inside
229
+ * another chosen row dropped (see {@link topmostSelected}) and any row this tree no longer
230
+ * holds forgotten.
231
+ */
232
+ ids: string[];
233
+ /** Where the folder is made — the parent of the first of them. `null` is the top level. */
234
+ parentId: string | null;
235
+ /** The name the field opens with: the first row's, minus its extension. */
236
+ name: string;
237
+ }
238
+
239
+ /**
240
+ * "New Folder with Selection", worked out — the whole of the gesture that is not DOM.
241
+ *
242
+ * Owner, 2026-09-13: *"allow make dir from file where dir wraps file location and gets name
243
+ * of file"*. Three requirements, and this function is where each of them is decided:
244
+ *
245
+ * 1. **Where the file already is.** The folder is made in the first row's OWN parent, not at
246
+ * the root and not inside the selection.
247
+ * 2. **Named after the file**, extension dropped — {@link wrapFolderName}.
248
+ * 3. **The rows move into it**, which is what *wraps* means. That is `ids`, and it is handed
249
+ * to the consumer in the SAME call that creates the folder — see
250
+ * `FileTreeProps.onDraftCommit`.
251
+ *
252
+ * 🔴 Tree order, not click order. A selection is built by clicking, so `['b', 'a']` is an
253
+ * ordinary thing for a consumer to hold — and Finder names the new folder after the FIRST
254
+ * item, meaning the first one in the list, not the first one you happened to click.
255
+ *
256
+ * A set spread across several parents is named and placed by its first row and the rest come
257
+ * to it, which is the only answer to "where?" that is not arbitrary. `null` when nothing in
258
+ * `ids` is in the tree any more: a wrap with nothing left to wrap is a plain new folder.
259
+ */
260
+ export function planWrap(
261
+ nodes: readonly FileTreeNode[],
262
+ ids: readonly string[],
263
+ ): WrapPlan | null {
264
+ const chosen = new Set(topmostSelected(nodes, ids));
265
+ const ordered = walkFileTree(nodes)
266
+ .filter((node) => chosen.has(node.id))
267
+ .map((node) => node.id);
268
+ const first = ordered[0];
269
+ if (first === undefined) return null;
270
+ return {
271
+ ids: ordered,
272
+ parentId: parentIdOf(nodes, first),
273
+ name: wrapFolderName(nodeById(nodes, first)?.name ?? ''),
274
+ };
275
+ }
276
+
277
+ /**
278
+ * The nodes behind a list of ids, in the order the ids were given, skipping any the tree no
279
+ * longer holds.
280
+ *
281
+ * A selection outlives a refresh — the server re-derives the tree between a ⌘-click and a
282
+ * ⌘⌫ and one of the rows is gone — and a callback that handed back `undefined` for it would
283
+ * push that case into every consumer.
284
+ */
285
+ export function nodesByIds(
286
+ nodes: readonly FileTreeNode[],
287
+ ids: readonly string[],
288
+ ): FileTreeNode[] {
289
+ const out: FileTreeNode[] = [];
290
+ for (const id of ids) {
291
+ const node = nodeById(nodes, id);
292
+ if (node) out.push(node);
293
+ }
294
+ return out;
295
+ }
296
+
297
+ /** `findFileTreeNode` without the full depth-first materialisation it does per call. */
298
+ function nodeById(nodes: readonly FileTreeNode[], id: string): FileTreeNode | null {
299
+ for (const node of nodes) {
300
+ if (node.id === id) return node;
301
+ const found = node.children ? nodeById(node.children, id) : null;
302
+ if (found) return found;
303
+ }
304
+ return null;
305
+ }
306
+
307
+ /**
308
+ * Everything a multi-drag has lifted off the page — the carried rows and what is under them.
309
+ *
310
+ * The single-row drag draws `data-dragged` on `descendantIdsOf(drag.id)` so a folder in the
311
+ * air takes its contents with it visually. With N rows that is the union, computed once per
312
+ * pointer move rather than per row per move.
313
+ */
314
+ export function draggedIdsOf(
315
+ nodes: readonly FileTreeNode[],
316
+ ids: readonly string[],
317
+ ): Set<string> {
318
+ const out = new Set<string>();
319
+ for (const id of ids) for (const under of descendantIdsOf(nodes, id)) out.add(under);
320
+ return out;
321
+ }
@@ -31,3 +31,24 @@ export {
31
31
  walkFileTree,
32
32
  } from 'cursedbelt-core/file-tree/file-tree-model';
33
33
  export { FileTree, type FileTreeProps } from './FileTree';
34
+ /*
35
+ * The MULTI-selection half of the model — which rows a range covers, and whether a drop is
36
+ * legal for N dragged ids. Same split, same reason: no DOM, so it is unit-tested where the
37
+ * pointer gesture cannot be. It is here rather than in `cursedbelt-core` because this package
38
+ * consumes that one by published version and nothing in this repo may reach across — see the
39
+ * header of `fileTreeSelection.ts`.
40
+ */
41
+ export {
42
+ draggedIdsOf,
43
+ nodesByIds,
44
+ planWrap,
45
+ refuseCopy,
46
+ refuseWrap,
47
+ type ResolveMultiDropOptions,
48
+ resolveMultiDrop,
49
+ selectionRange,
50
+ toggleSelected,
51
+ topmostSelected,
52
+ type WrapPlan,
53
+ wrapFolderName,
54
+ } from './fileTreeSelection';
@@ -384,6 +384,7 @@ export {
384
384
  type JsonEditorProps,
385
385
  } from './components/JsonEditor';
386
386
  export { Lightbox, type LightboxProps } from './components/Lightbox';
387
+ export { ListToolbar, type ListToolbarProps } from './components/ListToolbar';
387
388
  export { Cluster, Divider, type Gap, Sidebar, Split, Stack } from './components/layout';
388
389
  export { Modal, type ModalProps, type ModalSize } from './components/Modal';
389
390
  export {
@@ -1,7 +1,37 @@
1
- import { zodResolver } from '@hookform/resolvers/zod';
2
- import type { DefaultValues, FieldValues, UseFormProps } from 'react-hook-form';
1
+ import type { DefaultValues, FieldValues, Resolver, UseFormProps } from 'react-hook-form';
3
2
  import type { z } from 'zod';
4
3
 
4
+ /**
5
+ * 🔴 `@hookform/resolvers` is an OPTIONAL peer, and this dynamic import is what makes it
6
+ * one (task 149). `cursedbelt/react` is the barrel all eight apps import; a static
7
+ * `import { zodResolver } from '@hookform/resolvers/zod'` at the top of this file put the
8
+ * package — and its own dependency tree — into every one of those installs, to serve the
9
+ * one function below. `src/barrelsReachNoOptionalPeer.spec.ts` is the check that keeps it
10
+ * out, and a `await import(...)` inside the function that needs it is the shape that spec
11
+ * blesses.
12
+ *
13
+ * Resolved once per process and memoized: RHF calls the resolver on every validation
14
+ * pass, and `zodResolver(schema)` is cheap but the module load is not.
15
+ */
16
+ let zodResolverModule: Promise<typeof import('@hookform/resolvers/zod')> | undefined;
17
+
18
+ const loadZodResolver = async (): Promise<typeof import('@hookform/resolvers/zod')> => {
19
+ zodResolverModule ??= import('@hookform/resolvers/zod');
20
+ try {
21
+ return await zodResolverModule;
22
+ } catch (cause) {
23
+ // 🔴 Loud and actionable, not an unhandled rejection three frames into RHF. A missing
24
+ // optional peer surfaces here as "the form never validates", which is the worst
25
+ // possible thing to debug without the package name in the message.
26
+ zodResolverModule = undefined;
27
+ throw new Error(
28
+ "cursedbelt: makeFormConfig() needs the optional peer '@hookform/resolvers' — " +
29
+ 'run `bun add @hookform/resolvers` in the app that renders this form.',
30
+ { cause },
31
+ );
32
+ }
33
+ };
34
+
5
35
  /**
6
36
  * The canonical react-hook-form config for every cursedbelt form (Wizard,
7
37
  * RecordPanel, modal forms). One zod schema drives validation; the validation mode
@@ -22,18 +52,31 @@ import type { z } from 'zod';
22
52
  * type Values = z.infer<typeof schema>;
23
53
  * const form = useForm<Values>(makeFormConfig(schema, { name: '', tags: [] }));
24
54
  * ```
55
+ *
56
+ * 🔴 Needs the optional peer `@hookform/resolvers` installed in the app — RHF resolvers
57
+ * are already async, so the load happens on the first validation rather than at import.
25
58
  */
26
59
  export function makeFormConfig<TFieldValues extends FieldValues>(
27
60
  schema: z.ZodType,
28
61
  defaults: TFieldValues,
29
62
  ): UseFormProps<TFieldValues> {
63
+ // Built once per form, then reused across every validation pass.
64
+ let resolved: Resolver<TFieldValues> | undefined;
65
+ const resolver: Resolver<TFieldValues> = async (values, context, options) => {
66
+ if (!resolved) {
67
+ const { zodResolver } = await loadZodResolver();
68
+ // zodResolver's generics don't line up with the caller's value type (the schema
69
+ // input/output may differ from TFieldValues); the runtime resolver is correct, so
70
+ // cast through the overload's param/return types rather than over-genericize.
71
+ resolved = zodResolver(
72
+ schema as Parameters<typeof zodResolver>[0],
73
+ ) as unknown as Resolver<TFieldValues>;
74
+ }
75
+ return resolved(values, context, options);
76
+ };
77
+
30
78
  return {
31
- // zodResolver's generics don't line up with the caller's value type (the schema
32
- // input/output may differ from TFieldValues); the runtime resolver is correct, so
33
- // cast through the overload's param/return types rather than over-genericize.
34
- resolver: zodResolver(
35
- schema as Parameters<typeof zodResolver>[0],
36
- ) as UseFormProps<TFieldValues>['resolver'],
79
+ resolver,
37
80
  defaultValues: defaults as DefaultValues<TFieldValues>,
38
81
  mode: 'onBlur',
39
82
  reValidateMode: 'onChange',
@@ -44,7 +44,7 @@ describe('MarkdownDoc', () => {
44
44
 
45
45
  test('javascript: and unknown-scheme links are neutralized to plain text', () => {
46
46
  const { container } = render(
47
- <MarkdownDoc markdown={'[bad](javascript:alert(1)) and [weird](vbscript:x) and [ok](docs/x.md)'} />,
47
+ <MarkdownDoc markdown={'[bad](javascript:alert(1)) and [weird](vbscript:x) and [ok](docs/x.md)'} />, // check-doc-citations:ignore markdown fixture for the link sanitiser
48
48
  );
49
49
  const anchors = Array.from(container.querySelectorAll('a'));
50
50
  expect(anchors).toHaveLength(1);