cursedbelt-core 1.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 (307) hide show
  1. package/LICENSE +15 -0
  2. package/dist/client/wsWire.d.ts +49 -0
  3. package/dist/client/wsWire.js +57 -0
  4. package/dist/config/appConfig.d.ts +361 -0
  5. package/dist/config/appConfig.js +308 -0
  6. package/dist/core/activity/index.d.ts +8 -0
  7. package/dist/core/activity/index.js +7 -0
  8. package/dist/core/activity/model.d.ts +115 -0
  9. package/dist/core/activity/model.js +63 -0
  10. package/dist/core/analytics/index.d.ts +18 -0
  11. package/dist/core/analytics/index.js +18 -0
  12. package/dist/core/analytics/range.d.ts +186 -0
  13. package/dist/core/analytics/range.js +271 -0
  14. package/dist/core/analytics/schedule.d.ts +301 -0
  15. package/dist/core/analytics/schedule.js +372 -0
  16. package/dist/core/category/index.d.ts +3 -0
  17. package/dist/core/category/index.js +6 -0
  18. package/dist/core/category/memoryStore.d.ts +21 -0
  19. package/dist/core/category/memoryStore.js +97 -0
  20. package/dist/core/category/tree.d.ts +26 -0
  21. package/dist/core/category/tree.js +113 -0
  22. package/dist/core/category/types.d.ts +48 -0
  23. package/dist/core/category/types.js +8 -0
  24. package/dist/core/color.d.ts +14 -0
  25. package/dist/core/color.js +32 -0
  26. package/dist/core/colorScheme/colorSchemeStore.d.ts +70 -0
  27. package/dist/core/colorScheme/colorSchemeStore.js +123 -0
  28. package/dist/core/colorScheme/index.d.ts +7 -0
  29. package/dist/core/colorScheme/index.js +7 -0
  30. package/dist/core/domainOwners.d.ts +19 -0
  31. package/dist/core/domainOwners.js +15 -0
  32. package/dist/core/download.d.ts +14 -0
  33. package/dist/core/download.js +37 -0
  34. package/dist/core/events/ccEventBus.d.ts +44 -0
  35. package/dist/core/events/ccEventBus.js +70 -0
  36. package/dist/core/file-tree/fileTreeModel.d.ts +276 -0
  37. package/dist/core/file-tree/fileTreeModel.js +312 -0
  38. package/dist/core/folder-tree/actions.d.ts +84 -0
  39. package/dist/core/folder-tree/actions.js +119 -0
  40. package/dist/core/folder-tree/index.d.ts +3 -0
  41. package/dist/core/folder-tree/index.js +20 -0
  42. package/dist/core/folder-tree/path.d.ts +76 -0
  43. package/dist/core/folder-tree/path.js +146 -0
  44. package/dist/core/folder-tree/tree.d.ts +86 -0
  45. package/dist/core/folder-tree/tree.js +184 -0
  46. package/dist/core/layout/index.d.ts +1 -0
  47. package/dist/core/layout/index.js +7 -0
  48. package/dist/core/master-lock/index.d.ts +14 -0
  49. package/dist/core/master-lock/index.js +14 -0
  50. package/dist/core/master-lock/kdf.d.ts +63 -0
  51. package/dist/core/master-lock/kdf.js +117 -0
  52. package/dist/core/master-lock/policy.d.ts +32 -0
  53. package/dist/core/master-lock/policy.js +52 -0
  54. package/dist/core/master-lock/presence.d.ts +52 -0
  55. package/dist/core/master-lock/presence.js +61 -0
  56. package/dist/core/master-lock/wire.d.ts +139 -0
  57. package/dist/core/master-lock/wire.js +81 -0
  58. package/dist/core/media/bgRemoval.d.ts +27 -0
  59. package/dist/core/media/bgRemoval.js +101 -0
  60. package/dist/core/media/ffmpeg.d.ts +57 -0
  61. package/dist/core/media/ffmpeg.js +126 -0
  62. package/dist/core/media/format.d.ts +20 -0
  63. package/dist/core/media/format.js +54 -0
  64. package/dist/core/media/index.d.ts +12 -0
  65. package/dist/core/media/index.js +18 -0
  66. package/dist/core/media/mediaItem.d.ts +15 -0
  67. package/dist/core/media/mediaItem.js +34 -0
  68. package/dist/core/media/renderPlan.d.ts +63 -0
  69. package/dist/core/media/renderPlan.js +232 -0
  70. package/dist/core/media/segment.d.ts +79 -0
  71. package/dist/core/media/segment.js +247 -0
  72. package/dist/core/media/size.d.ts +1 -0
  73. package/dist/core/media/size.js +3 -0
  74. package/dist/core/media/trim.d.ts +21 -0
  75. package/dist/core/media/trim.js +40 -0
  76. package/dist/core/media/types.d.ts +148 -0
  77. package/dist/core/media/types.js +11 -0
  78. package/dist/core/media/uploadLimits.d.ts +7 -0
  79. package/dist/core/media/uploadLimits.js +16 -0
  80. package/dist/core/media/validate.d.ts +9 -0
  81. package/dist/core/media/validate.js +121 -0
  82. package/dist/core/media/videoCuts.d.ts +152 -0
  83. package/dist/core/media/videoCuts.js +284 -0
  84. package/dist/core/navigation/breadcrumbCompat.d.ts +24 -0
  85. package/dist/core/navigation/breadcrumbCompat.js +21 -0
  86. package/dist/core/navigation/coverageTest.d.ts +24 -0
  87. package/dist/core/navigation/coverageTest.js +48 -0
  88. package/dist/core/navigation/index.d.ts +6 -0
  89. package/dist/core/navigation/index.js +10 -0
  90. package/dist/core/navigation/migrateNavPreferences.d.ts +55 -0
  91. package/dist/core/navigation/migrateNavPreferences.js +63 -0
  92. package/dist/core/navigation/navigationModel.d.ts +165 -0
  93. package/dist/core/navigation/navigationModel.js +14 -0
  94. package/dist/core/navigation/navigationSelectors.d.ts +63 -0
  95. package/dist/core/navigation/navigationSelectors.js +167 -0
  96. package/dist/core/navigation/searchCatalogue.d.ts +23 -0
  97. package/dist/core/navigation/searchCatalogue.js +76 -0
  98. package/dist/core/net/reconnectSchedule.d.ts +46 -0
  99. package/dist/core/net/reconnectSchedule.js +38 -0
  100. package/dist/core/palette/ShortcutRegistry.d.ts +33 -0
  101. package/dist/core/palette/ShortcutRegistry.js +52 -0
  102. package/dist/core/palette/fuzzyRank.d.ts +18 -0
  103. package/dist/core/palette/fuzzyRank.js +73 -0
  104. package/dist/core/palette/hotkeys.d.ts +28 -0
  105. package/dist/core/palette/hotkeys.js +118 -0
  106. package/dist/core/palette/index.d.ts +3 -0
  107. package/dist/core/palette/index.js +8 -0
  108. package/dist/core/schema/projection.d.ts +84 -0
  109. package/dist/core/schema/projection.js +184 -0
  110. package/dist/core/session/claims.d.ts +145 -0
  111. package/dist/core/session/claims.js +157 -0
  112. package/dist/core/sharing/index.d.ts +5 -0
  113. package/dist/core/sharing/index.js +4 -0
  114. package/dist/core/sharing/model.d.ts +195 -0
  115. package/dist/core/sharing/model.js +150 -0
  116. package/dist/core/site/siteCopy.d.ts +92 -0
  117. package/dist/core/site/siteCopy.js +91 -0
  118. package/dist/core/slots.d.ts +22 -0
  119. package/dist/core/slots.js +31 -0
  120. package/dist/core/styles/constructableSheet.d.ts +51 -0
  121. package/dist/core/styles/constructableSheet.js +80 -0
  122. package/dist/core/tokens/registry.d.ts +119 -0
  123. package/dist/core/tokens/registry.js +447 -0
  124. package/dist/core/uiScale/index.d.ts +8 -0
  125. package/dist/core/uiScale/index.js +8 -0
  126. package/dist/core/uiScale/scale.d.ts +36 -0
  127. package/dist/core/uiScale/scale.js +47 -0
  128. package/dist/core/uiScale/uiScaleStore.d.ts +47 -0
  129. package/dist/core/uiScale/uiScaleStore.js +63 -0
  130. package/dist/core/wire/__fixtures__/rowsMarshalWorker.d.ts +1 -0
  131. package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +23 -0
  132. package/dist/core/wire/index.d.ts +17 -0
  133. package/dist/core/wire/index.js +17 -0
  134. package/dist/core/wire/ingress.d.ts +44 -0
  135. package/dist/core/wire/ingress.js +134 -0
  136. package/dist/core/wire/marshalWorker.d.ts +70 -0
  137. package/dist/core/wire/marshalWorker.js +273 -0
  138. package/dist/core/wire/marshalling.d.ts +122 -0
  139. package/dist/core/wire/marshalling.js +126 -0
  140. package/dist/core/wire/wireHub.d.ts +80 -0
  141. package/dist/core/wire/wireHub.js +116 -0
  142. package/dist/ctgr/base64url.d.ts +8 -0
  143. package/dist/ctgr/base64url.js +39 -0
  144. package/dist/ctgr/chunkAccumulator.d.ts +214 -0
  145. package/dist/ctgr/chunkAccumulator.js +307 -0
  146. package/dist/ctgr/chunkStore.d.ts +24 -0
  147. package/dist/ctgr/chunkStore.js +125 -0
  148. package/dist/ctgr/codec.d.ts +44 -0
  149. package/dist/ctgr/codec.js +153 -0
  150. package/dist/ctgr/compress.d.ts +2 -0
  151. package/dist/ctgr/compress.js +20 -0
  152. package/dist/ctgr/crc32.d.ts +2 -0
  153. package/dist/ctgr/crc32.js +23 -0
  154. package/dist/ctgr/digest.d.ts +1 -0
  155. package/dist/ctgr/digest.js +15 -0
  156. package/dist/ctgr/index.d.ts +9 -0
  157. package/dist/ctgr/index.js +17 -0
  158. package/dist/ctgr/types.d.ts +64 -0
  159. package/dist/ctgr/types.js +21 -0
  160. package/dist/ctgr/v0compat.d.ts +27 -0
  161. package/dist/ctgr/v0compat.js +128 -0
  162. package/dist/index.d.ts +15 -0
  163. package/dist/index.js +17 -0
  164. package/dist/shared/errors.d.ts +101 -0
  165. package/dist/shared/errors.js +99 -0
  166. package/dist/testing/bytes.d.ts +32 -0
  167. package/dist/testing/bytes.js +33 -0
  168. package/dist/testing/domInspect.d.ts +59 -0
  169. package/dist/testing/domInspect.js +115 -0
  170. package/dist/testing/fakeFetch.d.ts +23 -0
  171. package/dist/testing/fakeFetch.js +21 -0
  172. package/dist/testing/index.d.ts +3 -0
  173. package/dist/testing/index.js +11 -0
  174. package/package.json +373 -0
  175. package/src/client/wsWire.ts +83 -0
  176. package/src/config/appConfig.spec.ts +158 -0
  177. package/src/config/appConfig.ts +372 -0
  178. package/src/core/activity/index.ts +21 -0
  179. package/src/core/activity/model.ts +160 -0
  180. package/src/core/analytics/index.ts +18 -0
  181. package/src/core/analytics/range.spec.ts +280 -0
  182. package/src/core/analytics/range.ts +383 -0
  183. package/src/core/analytics/schedule.spec.ts +311 -0
  184. package/src/core/analytics/schedule.ts +624 -0
  185. package/src/core/category/category.spec.ts +144 -0
  186. package/src/core/category/index.ts +19 -0
  187. package/src/core/category/memoryStore.ts +104 -0
  188. package/src/core/category/tree.ts +118 -0
  189. package/src/core/category/types.ts +59 -0
  190. package/src/core/color.spec.ts +35 -0
  191. package/src/core/color.ts +35 -0
  192. package/src/core/colorScheme/colorScheme.spec.ts +127 -0
  193. package/src/core/colorScheme/colorSchemeStore.ts +184 -0
  194. package/src/core/colorScheme/index.ts +23 -0
  195. package/src/core/domainOwners.ts +32 -0
  196. package/src/core/download.spec.ts +38 -0
  197. package/src/core/download.ts +35 -0
  198. package/src/core/events/ccEventBus.spec.ts +90 -0
  199. package/src/core/events/ccEventBus.ts +120 -0
  200. package/src/core/file-tree/fileTreeModel.spec.ts +462 -0
  201. package/src/core/file-tree/fileTreeModel.ts +511 -0
  202. package/src/core/folder-tree/actions.spec.ts +103 -0
  203. package/src/core/folder-tree/actions.ts +153 -0
  204. package/src/core/folder-tree/index.ts +46 -0
  205. package/src/core/folder-tree/path.spec.ts +113 -0
  206. package/src/core/folder-tree/path.ts +148 -0
  207. package/src/core/folder-tree/tree.spec.ts +157 -0
  208. package/src/core/folder-tree/tree.ts +244 -0
  209. package/src/core/layout/index.ts +7 -0
  210. package/src/core/master-lock/index.ts +50 -0
  211. package/src/core/master-lock/kdf.spec.ts +95 -0
  212. package/src/core/master-lock/kdf.ts +145 -0
  213. package/src/core/master-lock/policy.ts +55 -0
  214. package/src/core/master-lock/presence.spec.ts +73 -0
  215. package/src/core/master-lock/presence.ts +71 -0
  216. package/src/core/master-lock/wire.ts +155 -0
  217. package/src/core/media/bgRemoval.spec.ts +120 -0
  218. package/src/core/media/bgRemoval.ts +123 -0
  219. package/src/core/media/ffmpeg.spec.ts +135 -0
  220. package/src/core/media/ffmpeg.ts +162 -0
  221. package/src/core/media/format.spec.ts +41 -0
  222. package/src/core/media/format.ts +58 -0
  223. package/src/core/media/index.ts +105 -0
  224. package/src/core/media/mediaItem.spec.ts +46 -0
  225. package/src/core/media/mediaItem.ts +40 -0
  226. package/src/core/media/renderPlan.spec.ts +140 -0
  227. package/src/core/media/renderPlan.ts +318 -0
  228. package/src/core/media/segment.spec.ts +159 -0
  229. package/src/core/media/segment.ts +352 -0
  230. package/src/core/media/size.spec.ts +19 -0
  231. package/src/core/media/size.ts +3 -0
  232. package/src/core/media/trim.spec.ts +75 -0
  233. package/src/core/media/trim.ts +60 -0
  234. package/src/core/media/types.ts +147 -0
  235. package/src/core/media/uploadLimits.ts +18 -0
  236. package/src/core/media/validate.spec.ts +106 -0
  237. package/src/core/media/validate.ts +131 -0
  238. package/src/core/media/videoCuts.spec.ts +327 -0
  239. package/src/core/media/videoCuts.ts +355 -0
  240. package/src/core/navigation/breadcrumbCompat.ts +44 -0
  241. package/src/core/navigation/coverageTest.ts +54 -0
  242. package/src/core/navigation/index.ts +50 -0
  243. package/src/core/navigation/migrateNavPreferences.ts +119 -0
  244. package/src/core/navigation/navigation.spec.ts +388 -0
  245. package/src/core/navigation/navigationModel.ts +185 -0
  246. package/src/core/navigation/navigationSelectors.ts +206 -0
  247. package/src/core/navigation/searchCatalogue.ts +89 -0
  248. package/src/core/net/reconnectSchedule.spec.ts +49 -0
  249. package/src/core/net/reconnectSchedule.ts +78 -0
  250. package/src/core/palette/ShortcutRegistry.ts +69 -0
  251. package/src/core/palette/fuzzyRank.ts +78 -0
  252. package/src/core/palette/hotkeys.ts +132 -0
  253. package/src/core/palette/index.ts +22 -0
  254. package/src/core/palette/palette.spec.ts +160 -0
  255. package/src/core/schema/projection.spec.ts +122 -0
  256. package/src/core/schema/projection.ts +225 -0
  257. package/src/core/session/claims.ts +181 -0
  258. package/src/core/sharing/index.ts +33 -0
  259. package/src/core/sharing/model.spec.ts +104 -0
  260. package/src/core/sharing/model.ts +296 -0
  261. package/src/core/site/siteCopy.ts +115 -0
  262. package/src/core/slots.ts +38 -0
  263. package/src/core/styles/constructableSheet.ts +86 -0
  264. package/src/core/tokens/registry.spec.ts +160 -0
  265. package/src/core/tokens/registry.ts +536 -0
  266. package/src/core/uiScale/index.ts +32 -0
  267. package/src/core/uiScale/scale.ts +63 -0
  268. package/src/core/uiScale/uiScale.spec.ts +77 -0
  269. package/src/core/uiScale/uiScaleStore.ts +100 -0
  270. package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +24 -0
  271. package/src/core/wire/index.ts +56 -0
  272. package/src/core/wire/ingress.spec.ts +276 -0
  273. package/src/core/wire/ingress.ts +182 -0
  274. package/src/core/wire/marshalWorker.spec.ts +196 -0
  275. package/src/core/wire/marshalWorker.ts +361 -0
  276. package/src/core/wire/marshalling.spec.ts +139 -0
  277. package/src/core/wire/marshalling.ts +238 -0
  278. package/src/core/wire/wireHub.spec.ts +137 -0
  279. package/src/core/wire/wireHub.ts +213 -0
  280. package/src/ctgr/base64url.ts +44 -0
  281. package/src/ctgr/chunkAccumulator.ts +403 -0
  282. package/src/ctgr/chunkStore.spec.ts +101 -0
  283. package/src/ctgr/chunkStore.ts +168 -0
  284. package/src/ctgr/codec.spec.ts +208 -0
  285. package/src/ctgr/codec.ts +202 -0
  286. package/src/ctgr/compress.ts +21 -0
  287. package/src/ctgr/crc32.ts +23 -0
  288. package/src/ctgr/digest.ts +14 -0
  289. package/src/ctgr/index.ts +39 -0
  290. package/src/ctgr/types.ts +80 -0
  291. package/src/ctgr/v0compat.spec.ts +73 -0
  292. package/src/ctgr/v0compat.ts +156 -0
  293. package/src/declaredDepsAreImported.spec.ts +87 -0
  294. package/src/index.ts +25 -0
  295. package/src/leafSubpathsImportNothing.spec.ts +287 -0
  296. package/src/lockfileIsTracked.spec.ts +50 -0
  297. package/src/namedSubpathsResolve.spec.ts +122 -0
  298. package/src/noPathDeps.spec.ts +24 -0
  299. package/src/publishShape.spec.ts +89 -0
  300. package/src/shared/errors.ts +153 -0
  301. package/src/skippedTestsAreAnswered.spec.ts +221 -0
  302. package/src/testTmpRoot.spec.ts +102 -0
  303. package/src/testing/bytes.ts +38 -0
  304. package/src/testing/domInspect.spec.ts +79 -0
  305. package/src/testing/domInspect.ts +127 -0
  306. package/src/testing/fakeFetch.ts +26 -0
  307. package/src/testing/index.ts +11 -0
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Marking parts of a video to REMOVE — the model, with no browser and no ffmpeg in it.
3
+ *
4
+ * ── The owner's ask, 2026-08-25 ────────────────────────────────────────────────────────
5
+ * *"We will use a unique strategy and not have live video changes in the UI. What I want is
6
+ * to be able to mark points in the video to indicate where to cut out sections. The browser
7
+ * is not great at actually removing segments in the UI even with the cc editor for video. So
8
+ * I will just mark them and send them to binary-server to handle it with the video software
9
+ * it uses."*
10
+ *
11
+ * 🔴 That is a decision about WHERE the work happens, and it is the right one for a reason
12
+ * worth writing down: `ffmpeg.wasm` decodes a multi-gigabyte clip inside a tab, at a few
13
+ * frames a second, holding the whole thing in memory — and this library's files are phone
14
+ * and camera masters. binary-server already runs native ffmpeg on this Mac with measured
15
+ * encoder settings (`ffmpegJobs.ts`), so the browser's only job is to say WHERE, which is
16
+ * exactly what a browser is good at.
17
+ *
18
+ * So a cut list is DATA. It is produced here, travels as JSON, and is executed somewhere
19
+ * else — which is why every rule about what makes a list valid lives in this file rather
20
+ * than being re-derived by the editor, by the app's route and by the ffmpeg arg builder. A
21
+ * cut list the UI thinks is fine and the encoder thinks is empty is the failure mode this
22
+ * shape exists to make impossible.
23
+ *
24
+ * ── 🔴 Why it lives in `core/`, not beside the editor in `react/media/` ────────────────
25
+ * Because the SERVER needs it. `apps/collections`' route refuses a cut list with the same
26
+ * function the dialog's Send button was disabled by, and a second definition of "a cut list
27
+ * that cannot be executed" is exactly the drift this module exists to prevent. Importing it
28
+ * from `cursedbelt/react/media` would pull the mask painter, the picture editor and React
29
+ * itself into a Hono server — so the model is `cursedbelt-core/media`, which is framework-free
30
+ * by construction, and `react/media` re-exports it for the dialog's convenience.
31
+ *
32
+ * ── Half-open, in seconds ──────────────────────────────────────────────────────────────
33
+ * A cut is `[start, end)`. Adjacent cuts therefore MERGE without leaving a frame between
34
+ * them, and a cut ending exactly where the next begins is one cut rather than two with an
35
+ * invisible sliver of footage in the middle.
36
+ */
37
+
38
+ /*
39
+ * ── 🔴 The MARKING model (owner, 2026-08-26) ──────────────────────────────────────────
40
+ *
41
+ * *"I could also cut many sections from the middle instead of just one … There are always
42
+ * start and end points needed for mid section removals to be valid. I think there could be
43
+ * ambiguity about which side to cut in some cases if the same type of marker is used for
44
+ * every cut marker. You can make it allow clicking 'x' on a section to indicate remove it. If
45
+ * I make one cut in the middle then I can choose the left of it and click 'x' to indicate the
46
+ * left should be the deleted side."*
47
+ *
48
+ * He identified the defect in the first design exactly. Marks that PAIR — an in point and an
49
+ * out point — are ambiguous the moment there is more than one pair, because nothing in a row
50
+ * of identical markers says which gap is a cut and which is footage. `-[--|-|-]-` has to be
51
+ * read, and two people read it differently.
52
+ *
53
+ * So the model is his: a CUT is a split point, and splits divide the clip into SEGMENTS. A
54
+ * segment is either kept or removed, and you say which by clicking it. There is no pairing,
55
+ * no ordering rule, and no "which side" question left to ask — every point in the video
56
+ * belongs to exactly one segment, and that segment is either in or out.
57
+ *
58
+ * It also makes the two things he listed as separate cases the same case: one cut and remove
59
+ * the left is `[X][ keep ]`, and cutting several sections out of the middle is
60
+ * `[keep][X][keep][X][keep]`. Nothing special-cases the head or the tail.
61
+ *
62
+ * `VideoCut[]` remains the wire format — the encoder still receives spans to remove — so the
63
+ * server, binary-server and every test below are unchanged by this. What changed is only how
64
+ * a person produces one.
65
+ */
66
+
67
+ /** One removed span. Seconds from the start of the source, half-open: `[start, end)`. */
68
+ export interface VideoCut {
69
+ start: number;
70
+ end: number;
71
+ }
72
+
73
+ /**
74
+ * The shortest span worth cutting, in seconds.
75
+ *
76
+ * 🔴 Not zero, and not "whatever the person dragged". Below roughly a frame the request is
77
+ * indistinguishable from a mis-click, and it is a request ffmpeg cannot honour anyway — a
78
+ * cut has to land on a frame boundary, so a 4 ms span asks the encoder to remove nothing and
79
+ * charges a full re-encode of the file for it. 40 ms is one frame at 25fps, the slowest rate
80
+ * anything in this library was shot at.
81
+ */
82
+ export const MIN_CUT_SECONDS = 0.04;
83
+
84
+ const clamp = (value: number, low: number, high: number): number =>
85
+ Math.min(high, Math.max(low, value));
86
+
87
+ /**
88
+ * Put a raw list into the one canonical form everything downstream may assume: inside the
89
+ * clip, ordered, non-overlapping, and free of spans too short to mean anything.
90
+ *
91
+ * 🔴 Overlaps are MERGED rather than refused. They are not a mistake to report — they are
92
+ * what marking naturally produces: you cut a passage, watch it back, and cut a span that
93
+ * happens to include part of what you already removed. Refusing that would make the editor
94
+ * argue with the person using it about an intention that is perfectly clear.
95
+ *
96
+ * A non-finite bound (a `NaN` duration from a video element that has not loaded yet is the
97
+ * usual source) drops its cut instead of poisoning the sort — every comparison against `NaN`
98
+ * is false, which silently produces an unsorted "sorted" array and a merge pass that leaves
99
+ * overlaps in.
100
+ */
101
+ export function normalizeCuts(cuts: readonly VideoCut[], duration: number): VideoCut[] {
102
+ if (!Number.isFinite(duration) || duration <= 0) return [];
103
+ const clean = cuts
104
+ .filter((cut) => Number.isFinite(cut.start) && Number.isFinite(cut.end))
105
+ .map((cut) => ({
106
+ start: clamp(Math.min(cut.start, cut.end), 0, duration),
107
+ end: clamp(Math.max(cut.start, cut.end), 0, duration),
108
+ }))
109
+ .filter((cut) => cut.end - cut.start >= MIN_CUT_SECONDS)
110
+ .sort((a, b) => a.start - b.start);
111
+
112
+ const merged: VideoCut[] = [];
113
+ for (const cut of clean) {
114
+ const last = merged[merged.length - 1];
115
+ // `>=` not `>`: half-open spans that touch are one span, not two with a gap of zero
116
+ // seconds between them that no frame can live in.
117
+ if (last && cut.start <= last.end) last.end = Math.max(last.end, cut.end);
118
+ else merged.push({ ...cut });
119
+ }
120
+ return merged;
121
+ }
122
+
123
+ /**
124
+ * What SURVIVES — the complement of the cut list, which is what the encoder actually
125
+ * concatenates.
126
+ *
127
+ * The editor shows this and the encoder builds from it, from one function, because the
128
+ * alternative is two independent readings of "the parts you keep" and a preview that
129
+ * disagrees with the file that comes back.
130
+ */
131
+ export function keptSegments(cuts: readonly VideoCut[], duration: number): VideoCut[] {
132
+ const removed = normalizeCuts(cuts, duration);
133
+ if (!Number.isFinite(duration) || duration <= 0) return [];
134
+ const kept: VideoCut[] = [];
135
+ let at = 0;
136
+ for (const cut of removed) {
137
+ if (cut.start - at >= MIN_CUT_SECONDS) kept.push({ start: at, end: cut.start });
138
+ at = Math.max(at, cut.end);
139
+ }
140
+ if (duration - at >= MIN_CUT_SECONDS) kept.push({ start: at, end: duration });
141
+ return kept;
142
+ }
143
+
144
+ /** How much of the clip the marks remove. */
145
+ export function cutSeconds(cuts: readonly VideoCut[], duration: number): number {
146
+ return normalizeCuts(cuts, duration).reduce((total, cut) => total + (cut.end - cut.start), 0);
147
+ }
148
+
149
+ /** How long the result will be. */
150
+ export function keptSeconds(cuts: readonly VideoCut[], duration: number): number {
151
+ return keptSegments(cuts, duration).reduce((total, seg) => total + (seg.end - seg.start), 0);
152
+ }
153
+
154
+ /**
155
+ * Why this list cannot be sent, or `null` when it can.
156
+ *
157
+ * 🔴 A SENTENCE, not a boolean, and the same sentence in the editor and on the server. The
158
+ * two refusals below are the only ones, and both are things a person can reach by accident
159
+ * in a few seconds of marking:
160
+ *
161
+ * · nothing marked — pressing Send with no cuts would queue a full re-encode that produces
162
+ * a byte-for-byte-pointless second copy of a multi-gigabyte file, and then ask the owner
163
+ * to choose between two identical videos.
164
+ * · everything marked — the result is an empty file. ffmpeg's `concat` with zero segments
165
+ * does not fail loudly; it writes a valid container with no frames in it, which arrives
166
+ * as a "finished" edit that plays nothing.
167
+ */
168
+ export function cutListProblem(cuts: readonly VideoCut[], duration: number): string | null {
169
+ if (!Number.isFinite(duration) || duration <= 0)
170
+ return "This video's length is not known yet, so a cut cannot be placed in it.";
171
+ if (normalizeCuts(cuts, duration).length === 0)
172
+ return "Nothing is marked for removal yet — mark at least one section first.";
173
+ if (keptSegments(cuts, duration).length === 0)
174
+ return "That removes the whole video. Leave at least one section to keep.";
175
+ return null;
176
+ }
177
+
178
+ /**
179
+ * Seconds as `m:ss.s` / `h:mm:ss.s` — a TENTH of a second, unlike the timecode the player's
180
+ * scrub bar shows.
181
+ *
182
+ * 🔴 The extra digit is the difference between a readable clock and a usable cut point. At
183
+ * whole seconds two marks a few frames apart render identically, so a list of cuts around
184
+ * one moment reads as the same row repeated — and there is no way to tell, from the list,
185
+ * which of them is the one you meant to delete.
186
+ */
187
+ export function cutTimecode(seconds: number): string {
188
+ if (!Number.isFinite(seconds) || seconds < 0) return '0:00.0';
189
+ /*
190
+ * 🔴 Integer tenths, with an epsilon, rather than `Math.floor((seconds % 1) * 10)`.
191
+ *
192
+ * A playhead is a float from a decoder, and the fractional part of one is not the number
193
+ * you wrote down: `3725.2 % 1` is 0.19999999999981810, which floors to ONE tenth — the
194
+ * timecode reads 1:02:05.1 for a mark placed at 1:02:05.2, so a person nudging a cut watches
195
+ * the clock disagree with the frame. Truncating in tenths (and nudging past the
196
+ * representation error first) keeps the readout monotonic with the playhead.
197
+ */
198
+ const totalTenths = Math.floor(seconds * 10 + 1e-6);
199
+ const tenths = totalTenths % 10;
200
+ const whole = Math.floor(totalTenths / 10);
201
+ const s = whole % 60;
202
+ const m = Math.floor(whole / 60) % 60;
203
+ const h = Math.floor(whole / 3600);
204
+ const mm = h > 0 ? String(m).padStart(2, '0') : String(m);
205
+ return `${h > 0 ? `${h}:` : ''}${mm}:${String(s).padStart(2, '0')}.${tenths}`;
206
+ }
207
+
208
+
209
+ // ── The plan: split points, and which pieces are out ────────────────────────────────────
210
+
211
+ /**
212
+ * What the editor holds: where the scissors have been, and which pieces are marked out.
213
+ *
214
+ * 🔴 `removed` names segments by their BOUNDS, not by an index. Indices shift the moment a
215
+ * new split lands anywhere to their left, so a plan stored as `removed: [2]` silently comes
216
+ * to mean a different piece of footage — and it would do so during the one gesture people use
217
+ * most, which is cutting again after looking at what they have. Bounds cannot drift: a
218
+ * segment either still exists with those bounds or it has been divided, and dividing one is
219
+ * handled explicitly below.
220
+ */
221
+ export interface CutPlan {
222
+ /** Split points in seconds, sorted, unique, strictly inside `(0, duration)`. */
223
+ splits: number[];
224
+ /** The pieces marked for removal, by their `[start, end)` bounds. */
225
+ removed: VideoCut[];
226
+ }
227
+
228
+ export const EMPTY_PLAN: CutPlan = { splits: [], removed: [] };
229
+
230
+ /** A segment of the clip, and whether it survives. */
231
+ export interface PlanSegment extends VideoCut {
232
+ removed: boolean;
233
+ /** Stable across re-renders and safe as a React key — bounds, not position. */
234
+ key: string;
235
+ }
236
+
237
+ const segmentKey = (start: number, end: number): string => `${start.toFixed(3)}-${end.toFixed(3)}`;
238
+
239
+ /** Sorted, unique, inside the clip, and never closer together than a frame. */
240
+ function tidySplits(splits: readonly number[], duration: number): number[] {
241
+ if (!Number.isFinite(duration) || duration <= 0) return [];
242
+ const out: number[] = [];
243
+ for (const at of [...splits].filter(Number.isFinite).sort((a, b) => a - b)) {
244
+ if (at <= MIN_CUT_SECONDS || at >= duration - MIN_CUT_SECONDS) continue;
245
+ // A split a frame away from the previous one makes a segment no frame can live in — and
246
+ // an unclickable sliver on the strip, which is worse than refusing the cut.
247
+ if (out.length > 0 && at - (out[out.length - 1] as number) < MIN_CUT_SECONDS) continue;
248
+ out.push(at);
249
+ }
250
+ return out;
251
+ }
252
+
253
+ /** The clip, divided. Always covers `[0, duration)` exactly, with no gaps. */
254
+ export function planSegments(plan: CutPlan, duration: number): PlanSegment[] {
255
+ if (!Number.isFinite(duration) || duration <= 0) return [];
256
+ const splits = tidySplits(plan.splits, duration);
257
+ const bounds = [0, ...splits, duration];
258
+ const out: PlanSegment[] = [];
259
+ for (let i = 0; i < bounds.length - 1; i++) {
260
+ const start = bounds[i] as number;
261
+ const end = bounds[i + 1] as number;
262
+ out.push({
263
+ start,
264
+ end,
265
+ key: segmentKey(start, end),
266
+ // A segment is removed when the plan holds a removal that COVERS it. Covers, not
267
+ // equals: a segment that was marked out and has since been split in two is two removed
268
+ // segments, and the alternative — dropping the mark on a re-split — silently restores
269
+ // footage somebody had already decided against.
270
+ removed: plan.removed.some((cut) => cut.start <= start + 1e-6 && cut.end >= end - 1e-6),
271
+ });
272
+ }
273
+ return out;
274
+ }
275
+
276
+ /** Cut here. A split inside a removed piece divides it into two removed pieces. */
277
+ export function addSplit(plan: CutPlan, at: number, duration: number): CutPlan {
278
+ const splits = tidySplits([...plan.splits, at], duration);
279
+ if (splits.length === tidySplits(plan.splits, duration).length) return plan;
280
+ return { splits, removed: plan.removed };
281
+ }
282
+
283
+ /**
284
+ * Undo a cut, merging the two pieces either side.
285
+ *
286
+ * 🔴 The merged piece is removed only if BOTH halves were. Anything else invents an
287
+ * intention: promoting a half-removed merge to "removed" deletes footage nobody marked, and
288
+ * demoting it to "kept" restores footage somebody did mark. Requiring agreement means the
289
+ * only way to lose a decision is to have made two conflicting ones, and then the safe
290
+ * reading — keep it — is the one that costs nothing but another click.
291
+ */
292
+ export function removeSplit(plan: CutPlan, at: number, duration: number): CutPlan {
293
+ const splits = tidySplits(plan.splits, duration);
294
+ const index = splits.findIndex((split) => Math.abs(split - at) < 1e-6);
295
+ if (index === -1) return plan;
296
+ const before = planSegments(plan, duration);
297
+ const left = before.find((segment) => Math.abs(segment.end - at) < 1e-6);
298
+ const right = before.find((segment) => Math.abs(segment.start - at) < 1e-6);
299
+ const nextSplits = [...splits.slice(0, index), ...splits.slice(index + 1)];
300
+ const nextPlan: CutPlan = { splits: nextSplits, removed: plan.removed };
301
+ if (!left || !right) return nextPlan;
302
+ const merged = { start: left.start, end: right.end };
303
+ const kept = plan.removed.filter(
304
+ (cut) => !(cut.start >= merged.start - 1e-6 && cut.end <= merged.end + 1e-6),
305
+ );
306
+ return {
307
+ splits: nextSplits,
308
+ removed: left.removed && right.removed ? [...kept, merged] : kept,
309
+ };
310
+ }
311
+
312
+ /** Mark a piece out, or put it back — the ✗ on a segment. */
313
+ export function toggleSegment(plan: CutPlan, segment: VideoCut, duration: number): CutPlan {
314
+ const current = planSegments(plan, duration).find(
315
+ (candidate) => candidate.key === segmentKey(segment.start, segment.end),
316
+ );
317
+ if (!current) return plan;
318
+ if (current.removed) {
319
+ return {
320
+ splits: plan.splits,
321
+ // Drop every removal this segment sits inside, then put back the parts of a WIDER
322
+ // removal that are still meant to go — un-marking one piece of a span that was marked
323
+ // before it was split must not resurrect its neighbours.
324
+ removed: plan.removed.flatMap((cut) => {
325
+ if (!(cut.start <= current.start + 1e-6 && cut.end >= current.end - 1e-6)) return [cut];
326
+ const rest: VideoCut[] = [];
327
+ if (current.start - cut.start >= MIN_CUT_SECONDS)
328
+ rest.push({ start: cut.start, end: current.start });
329
+ if (cut.end - current.end >= MIN_CUT_SECONDS) rest.push({ start: current.end, end: cut.end });
330
+ return rest;
331
+ }),
332
+ };
333
+ }
334
+ return { splits: plan.splits, removed: [...plan.removed, { start: current.start, end: current.end }] };
335
+ }
336
+
337
+ /**
338
+ * The plan as the wire format — the spans to remove, normalized.
339
+ *
340
+ * Adjacent removed segments merge here, which is why the editor can let somebody mark three
341
+ * pieces in a row without producing three cuts the encoder has to join back up.
342
+ */
343
+ export function planCuts(plan: CutPlan, duration: number): VideoCut[] {
344
+ return normalizeCuts(
345
+ planSegments(plan, duration)
346
+ .filter((segment) => segment.removed)
347
+ .map(({ start, end }) => ({ start, end })),
348
+ duration,
349
+ );
350
+ }
351
+
352
+ /** Why this plan cannot be sent, or `null`. The same two refusals, asked of a plan. */
353
+ export function planProblem(plan: CutPlan, duration: number): string | null {
354
+ return cutListProblem(planCuts(plan, duration), duration);
355
+ }
@@ -0,0 +1,44 @@
1
+ // Backward-compatible breadcrumb helpers for apps that maintain a flat node map
2
+ // (key → label + optional parent key) rather than deriving the trail from a full
3
+ // NavigationModel. Apps that have migrated to NavigationModel should use
4
+ // deriveBreadcrumbTrail (navigationSelectors) instead.
5
+
6
+ /** A node in a flat breadcrumb hierarchy map. */
7
+ export interface BreadcrumbNode {
8
+ /** The stable key for this node (usually its path). */
9
+ key: string;
10
+ /** Human-readable label. */
11
+ label: string;
12
+ /** Key of the parent node, if any. Absent for root nodes. */
13
+ parent?: string;
14
+ }
15
+
16
+ /** A resolved breadcrumb crumb ready to render. */
17
+ export interface BreadcrumbItem {
18
+ /** Stable key (used as the React list key). */
19
+ key: string;
20
+ /** Human-readable label. */
21
+ label: string;
22
+ /** Navigation target — omit for a non-interactive (current page) leaf. */
23
+ href?: string;
24
+ }
25
+
26
+ /**
27
+ * Walk `nodes` upward from `path` to the root and return the ancestor chain
28
+ * ordered root-first (Home → … → `path`). Returns `[]` when `path` is absent
29
+ * from the map.
30
+ */
31
+ export function buildBreadcrumbTrail(
32
+ nodes: Record<string, BreadcrumbNode>,
33
+ path: string,
34
+ ): BreadcrumbItem[] {
35
+ const chain: BreadcrumbItem[] = [];
36
+ let cur: string | undefined = path;
37
+ while (cur) {
38
+ const node: BreadcrumbNode | undefined = nodes[cur];
39
+ if (!node) break;
40
+ chain.unshift({ key: node.key, label: node.label, href: node.key });
41
+ cur = node.parent;
42
+ }
43
+ return chain;
44
+ }
@@ -0,0 +1,54 @@
1
+ // The anti-drift guarantee. `createCoverageTest` returns an assertion the consuming
2
+ // app runs in its own `navigationModel.spec.ts`: it fails the build the instant a
3
+ // route exists in `routes.tsx` that no one registered in the navigation model. Since
4
+ // the side nav / hub tiles / breadcrumbs / search / palette ALL derive from that one
5
+ // model, a registered route is automatically discoverable — and an unregistered one
6
+ // can never silently bury. Framework-agnostic (throws a plain Error; works under
7
+ // bun:test, vitest, jest) so the same factory drops into any app's spec file.
8
+
9
+ import type { NavigationModel } from './navigationModel';
10
+ import { deriveAllPaths, normalizeNavPath } from './navigationSelectors';
11
+
12
+ /**
13
+ * Build the coverage assertion for `model` against `allRoutePaths` (the app's full
14
+ * route list, e.g. derived from `routes.tsx`). The returned `() => void` throws —
15
+ * listing every gap — when a route path is neither registered in the model nor
16
+ * registered as `deepLinkOnly` (both forms appear in {@link deriveAllPaths}).
17
+ *
18
+ * ```ts
19
+ * // ca/ru navigationModel.spec.ts
20
+ * import { test } from 'bun:test';
21
+ * test('every route is registered in the navigation model',
22
+ * createCoverageTest(caNavigationModel, caAllRoutePaths));
23
+ * ```
24
+ *
25
+ * Route matching is by {@link normalizeNavPath} on both sides, so a `#tab=…` model
26
+ * entry covers its base route and a trailing slash is ignored.
27
+ */
28
+ export function createCoverageTest(model: NavigationModel, allRoutePaths: string[]): () => void {
29
+ return () => {
30
+ const covered = new Set(deriveAllPaths(model).map(normalizeNavPath));
31
+ const missing = [...new Set(allRoutePaths.map((p) => p.trim()).filter(Boolean))]
32
+ .filter((p) => !covered.has(normalizeNavPath(p)))
33
+ .sort();
34
+ if (missing.length > 0) {
35
+ throw new Error(
36
+ `navigationModel coverage gap — ${missing.length} route path(s) are not registered.\n` +
37
+ 'Add each to a domain group (or mark it deepLinkOnly) so it can never silently bury:\n' +
38
+ missing.map((p) => ` • ${p}`).join('\n'),
39
+ );
40
+ }
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Non-throwing companion — the raw set of route paths missing from the model. Useful
46
+ * during a migration (N1 of the IA overhaul) to *enumerate* the discovery gaps
47
+ * before fixing them, rather than failing on the first one.
48
+ */
49
+ export function findCoverageGaps(model: NavigationModel, allRoutePaths: string[]): string[] {
50
+ const covered = new Set(deriveAllPaths(model).map(normalizeNavPath));
51
+ return [...new Set(allRoutePaths.map((p) => p.trim()).filter(Boolean))]
52
+ .filter((p) => !covered.has(normalizeNavPath(p)))
53
+ .sort();
54
+ }
@@ -0,0 +1,50 @@
1
+ // `cursedbelt-core/navigation` — the navigation contract + pure selectors. React-free and
2
+ // routing-free: the single source of truth (the IA tree) plus every derivation off
3
+ // it (side nav, hub tiles, breadcrumbs, search catalogue, coverage test, prefs
4
+ // migration). The React components in `cursedbelt/react` consume exactly this.
5
+
6
+ export {
7
+ type BreadcrumbItem,
8
+ type BreadcrumbNode,
9
+ buildBreadcrumbTrail,
10
+ } from './breadcrumbCompat';
11
+ export {
12
+ createCoverageTest,
13
+ findCoverageGaps,
14
+ } from './coverageTest';
15
+ export {
16
+ CURRENT_NAV_PREFS_VERSION,
17
+ type LegacyNavPrefs,
18
+ type MigrateNavPrefsOptions,
19
+ migrateNavPreferences,
20
+ type NavPreferences,
21
+ type NavTilePrefs,
22
+ } from './migrateNavPreferences';
23
+ export {
24
+ allowAllGate,
25
+ type GateFn,
26
+ type NavDomain,
27
+ type NavGate,
28
+ type NavGroup,
29
+ type NavHostLimit,
30
+ type NavItem,
31
+ type NavigationModel,
32
+ } from './navigationModel';
33
+ export {
34
+ deriveAllPaths,
35
+ deriveBreadcrumbTrail,
36
+ deriveDomain,
37
+ deriveDomainGroups,
38
+ deriveDomainItems,
39
+ deriveSearchCatalogue,
40
+ deriveSideNavDomains,
41
+ type NavCrumb,
42
+ normalizeNavPath,
43
+ type SearchCatalogueEntry,
44
+ } from './navigationSelectors';
45
+ export {
46
+ type CatalogueHit,
47
+ type CatalogueResultGroup,
48
+ groupCatalogueHits,
49
+ rankCatalogue,
50
+ } from './searchCatalogue';
@@ -0,0 +1,119 @@
1
+ // One-time, idempotent preference migration. The IA overhaul re-groups the old
2
+ // 8-hub layout into 6 domains; this remaps the user's saved nav/hub customizations
3
+ // (order, hidden, per-row colors) onto the new ids so nothing resets. The *mapping*
4
+ // (old key → new domain id) is app-supplied data (only the app knows its old hubs);
5
+ // the runner is the framework's. Idempotent via a version stamp — it runs on every
6
+ // cold load but no-ops once `version` is current, so colors/hidden the user set
7
+ // AFTER migrating are never clobbered.
8
+
9
+ import type { NavigationModel } from './navigationModel';
10
+
11
+ /** Bump when the migration logic or the prefs shape changes. */
12
+ export const CURRENT_NAV_PREFS_VERSION = 1;
13
+
14
+ /** Per-hub tile customization (keyed by item path/id within a domain). */
15
+ export interface NavTilePrefs {
16
+ /** Saved tile order (item ids). */
17
+ order?: string[];
18
+ /** Hidden tile ids. */
19
+ hidden?: string[];
20
+ /** Per-tile accent override (item id → hex). */
21
+ colors?: Record<string, string>;
22
+ }
23
+
24
+ /** The post-migration navigation preferences. */
25
+ export interface NavPreferences {
26
+ /** Stamp — equals {@link CURRENT_NAV_PREFS_VERSION} after a successful migration. */
27
+ version: number;
28
+ /** Side-nav domain order (domain ids). */
29
+ order?: string[];
30
+ /** Hidden domain ids (removed from the side-nav rail; restorable). */
31
+ hidden?: string[];
32
+ /** Per-domain accent override (domain id → hex). */
33
+ colors?: Record<string, string>;
34
+ /** Collapsed rail section labels (see `NavDomain.section`). */
35
+ collapsedSections?: string[];
36
+ /** Per-domain hub-tile customization (domain id → {@link NavTilePrefs}). */
37
+ hubs?: Record<string, NavTilePrefs>;
38
+ }
39
+
40
+ /** The legacy (pre-overhaul) shape: a top-level nav block + per-hub tile prefs. */
41
+ export interface LegacyNavPrefs {
42
+ version?: number;
43
+ /** Old side-nav customization, keyed by route id. */
44
+ nav?: { order?: string[]; colors?: Record<string, string> };
45
+ /** Old hidden side-nav routes (route ids). */
46
+ hiddenNavRoutes?: string[];
47
+ /** Old per-hub tile prefs, keyed by the OLD hub id. */
48
+ hubs?: Record<string, NavTilePrefs>;
49
+ }
50
+
51
+ export interface MigrateNavPrefsOptions {
52
+ /**
53
+ * Old key → new domain id. Applied to side-nav route ids AND hub keys; unmapped
54
+ * keys pass through unchanged. When two old hubs map to one domain (an 8→6
55
+ * consolidation), their tile prefs are merged.
56
+ */
57
+ keyMap?: Record<string, string>;
58
+ }
59
+
60
+ const uniq = (xs: string[]): string[] => [...new Set(xs)];
61
+
62
+ /** Merge two tile-pref blocks (union hidden, concat+dedupe order, later colors win). */
63
+ function mergeTilePrefs(a: NavTilePrefs | undefined, b: NavTilePrefs): NavTilePrefs {
64
+ return {
65
+ order: uniq([...(a?.order ?? []), ...(b.order ?? [])]),
66
+ hidden: uniq([...(a?.hidden ?? []), ...(b.hidden ?? [])]),
67
+ colors: { ...(a?.colors ?? {}), ...(b.colors ?? {}) },
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Migrate legacy nav prefs onto the new model. Idempotent: returns `old` untouched
73
+ * when it's already at {@link CURRENT_NAV_PREFS_VERSION}. Otherwise remaps via
74
+ * `options.keyMap`, drops domain-level keys whose target isn't a real domain in
75
+ * `model`, merges consolidated hubs, and stamps the version.
76
+ */
77
+ export function migrateNavPreferences(
78
+ old: LegacyNavPrefs | NavPreferences | null | undefined,
79
+ model: NavigationModel,
80
+ options: MigrateNavPrefsOptions = {},
81
+ ): NavPreferences {
82
+ // Already migrated → no-op (don't reset user choices made post-migration).
83
+ if (old && typeof old.version === 'number' && old.version >= CURRENT_NAV_PREFS_VERSION) {
84
+ return old as NavPreferences;
85
+ }
86
+
87
+ const keyMap = options.keyMap ?? {};
88
+ const remap = (k: string) => keyMap[k] ?? k;
89
+ const domainIds = new Set((model?.domains ?? []).map((d) => d.id));
90
+ const legacy = (old ?? {}) as LegacyNavPrefs;
91
+
92
+ // Domain order: remap each old route id → domain id, dedupe, keep only real domains.
93
+ const order = uniq((legacy.nav?.order ?? []).map(remap)).filter((id) => domainIds.has(id));
94
+
95
+ // Hidden domains: remap, dedupe, keep only real domains.
96
+ const hidden = uniq((legacy.hiddenNavRoutes ?? []).map(remap)).filter((id) => domainIds.has(id));
97
+
98
+ // Domain colors: remap keys, keep only real domains (last write wins on collision).
99
+ const colors: Record<string, string> = {};
100
+ for (const [k, v] of Object.entries(legacy.nav?.colors ?? {})) {
101
+ const id = remap(k);
102
+ if (domainIds.has(id)) colors[id] = v;
103
+ }
104
+
105
+ // Hub tile prefs: remap hub key → domain id, merging when two hubs consolidate.
106
+ const hubs: Record<string, NavTilePrefs> = {};
107
+ for (const [oldHub, prefs] of Object.entries(legacy.hubs ?? {})) {
108
+ const id = remap(oldHub);
109
+ if (!domainIds.has(id)) continue; // a hub with no surviving domain home is dropped
110
+ hubs[id] = mergeTilePrefs(hubs[id], prefs);
111
+ }
112
+
113
+ const result: NavPreferences = { version: CURRENT_NAV_PREFS_VERSION };
114
+ if (order.length) result.order = order;
115
+ if (hidden.length) result.hidden = hidden;
116
+ if (Object.keys(colors).length) result.colors = colors;
117
+ if (Object.keys(hubs).length) result.hubs = hubs;
118
+ return result;
119
+ }