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,63 @@
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
+ /** Bump when the migration logic or the prefs shape changes. */
9
+ export const CURRENT_NAV_PREFS_VERSION = 1;
10
+ const uniq = (xs) => [...new Set(xs)];
11
+ /** Merge two tile-pref blocks (union hidden, concat+dedupe order, later colors win). */
12
+ function mergeTilePrefs(a, b) {
13
+ return {
14
+ order: uniq([...(a?.order ?? []), ...(b.order ?? [])]),
15
+ hidden: uniq([...(a?.hidden ?? []), ...(b.hidden ?? [])]),
16
+ colors: { ...(a?.colors ?? {}), ...(b.colors ?? {}) },
17
+ };
18
+ }
19
+ /**
20
+ * Migrate legacy nav prefs onto the new model. Idempotent: returns `old` untouched
21
+ * when it's already at {@link CURRENT_NAV_PREFS_VERSION}. Otherwise remaps via
22
+ * `options.keyMap`, drops domain-level keys whose target isn't a real domain in
23
+ * `model`, merges consolidated hubs, and stamps the version.
24
+ */
25
+ export function migrateNavPreferences(old, model, options = {}) {
26
+ // Already migrated → no-op (don't reset user choices made post-migration).
27
+ if (old && typeof old.version === 'number' && old.version >= CURRENT_NAV_PREFS_VERSION) {
28
+ return old;
29
+ }
30
+ const keyMap = options.keyMap ?? {};
31
+ const remap = (k) => keyMap[k] ?? k;
32
+ const domainIds = new Set((model?.domains ?? []).map((d) => d.id));
33
+ const legacy = (old ?? {});
34
+ // Domain order: remap each old route id → domain id, dedupe, keep only real domains.
35
+ const order = uniq((legacy.nav?.order ?? []).map(remap)).filter((id) => domainIds.has(id));
36
+ // Hidden domains: remap, dedupe, keep only real domains.
37
+ const hidden = uniq((legacy.hiddenNavRoutes ?? []).map(remap)).filter((id) => domainIds.has(id));
38
+ // Domain colors: remap keys, keep only real domains (last write wins on collision).
39
+ const colors = {};
40
+ for (const [k, v] of Object.entries(legacy.nav?.colors ?? {})) {
41
+ const id = remap(k);
42
+ if (domainIds.has(id))
43
+ colors[id] = v;
44
+ }
45
+ // Hub tile prefs: remap hub key → domain id, merging when two hubs consolidate.
46
+ const hubs = {};
47
+ for (const [oldHub, prefs] of Object.entries(legacy.hubs ?? {})) {
48
+ const id = remap(oldHub);
49
+ if (!domainIds.has(id))
50
+ continue; // a hub with no surviving domain home is dropped
51
+ hubs[id] = mergeTilePrefs(hubs[id], prefs);
52
+ }
53
+ const result = { version: CURRENT_NAV_PREFS_VERSION };
54
+ if (order.length)
55
+ result.order = order;
56
+ if (hidden.length)
57
+ result.hidden = hidden;
58
+ if (Object.keys(colors).length)
59
+ result.colors = colors;
60
+ if (Object.keys(hubs).length)
61
+ result.hubs = hubs;
62
+ return result;
63
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Visibility / eligibility gate carried by a domain or item. The model only stores
3
+ * the *flags*; the consuming app injects a {@link GateFn} that decides what they
4
+ * mean (is the user authed, do they hold the role, is the GET-only "ctgr" mode on).
5
+ * Gates are inherited: an item inside a gated-out domain never surfaces.
6
+ */
7
+ export interface NavGate {
8
+ /** Only visible when signed in. */
9
+ requiresAuth?: boolean;
10
+ /** Only visible when signed out (public/marketing routes). */
11
+ requiresNoAuth?: boolean;
12
+ /** Visible only to users holding ONE of these roles (e.g. `['admin','owner']`). */
13
+ requiredRoles?: string[];
14
+ /** Hidden while the GET-only ("ctgr") session mode is active (write-heavy tools). */
15
+ hiddenInCtgr?: boolean;
16
+ }
17
+ /**
18
+ * A navigable leaf. `path` references a route (a `routeMeta` path in ca); use a
19
+ * `#tab=…` suffix for an in-page tab target (`/tools#tab=curl`) — the selectors
20
+ * strip the hash for route matching but keep it for navigation.
21
+ */
22
+ export interface NavItem {
23
+ /** Stable unique key — the React key AND the key prefs (order/hide/color) store by. */
24
+ id: string;
25
+ /** Route destination. `#tab=…` addresses an in-page tab. */
26
+ path: string;
27
+ /** Display label (side nav row, hub tile title, breadcrumb leaf, search hit). */
28
+ label: string;
29
+ /** Icon *name* from the app's icon set (resolved to a node by the React layer). */
30
+ icon?: string;
31
+ /** Short description — hub-tile subtitle + search snippet. */
32
+ description?: string;
33
+ /** Auth / role / ctgr gate (see {@link NavGate}). */
34
+ gate?: NavGate;
35
+ /**
36
+ * Registered for coverage + deep-link reachability, but **never surfaced** in the
37
+ * side nav, hub tiles, search, or palette. For routes reachable only by a shared
38
+ * link, a notification, or a WS invite (Shared Space, `/watch/:type/:id`, auth
39
+ * routes, `/set-initial-password`, admin deep-links). Use deliberately — it is not
40
+ * a way to dodge the coverage test for a real, discoverable feature.
41
+ */
42
+ deepLinkOnly?: true;
43
+ /** Extra match terms for search / palette (synonyms, old names, abbreviations). */
44
+ keywords?: string[];
45
+ }
46
+ /** A labeled cluster of items inside a domain (a sub-section of a hub landing). */
47
+ export interface NavGroup {
48
+ /** Stable unique key (within the model). */
49
+ id: string;
50
+ /** Section heading shown on the hub landing; omit-able visually when there's one. */
51
+ label: string;
52
+ items: NavItem[];
53
+ }
54
+ /**
55
+ * A top-level **place** — the browsable backbone (the UX north-star's small fixed
56
+ * place-set: Today / Library / People / Calendar / Play + a role-gated Operate; see
57
+ * `react/components/SHELL.md`). It is itself navigable (its `path` is the place
58
+ * landing) and owns the groups of items shown there. The shell renders these as the
59
+ * slim desktop place rail (`SideNav`) and the mobile capture-forward `PlaceBar`.
60
+ */
61
+ /**
62
+ * A place that exists in the IA but CANNOT do its job on the host currently
63
+ * serving the page.
64
+ *
65
+ * The worked example is the station's "Vault" row: the prod mirror offers it
66
+ * identically to the local one, and clicking it there reaches a machine that does
67
+ * not hold the data. The owner's ruling (`owner-uis-are-one-ui`, 2026-08-13) is
68
+ * that the answer is never a second UI and never a hidden item:
69
+ *
70
+ * *"Keeping two versions of UIs … is not a good long-term approach. I'd rather
71
+ * a button that literally can't work in prod just be disabled but in the same
72
+ * UI and with a message about why when I hover."*
73
+ *
74
+ * A hidden row teaches the reader the feature does not exist; a disabled row with
75
+ * a reason teaches them where to go. `UX_STANDARDS.md` §8.6 states it as a rule
76
+ * and no component carried it until unit 11.
77
+ *
78
+ * 🔴 "Too dangerous to expose" is NOT a host limit — Cloudflare Access already
79
+ * enforces owner-only at the edge. Only three things qualify: the destination
80
+ * points at the Mac, the bytes are not on this host, or it drives local
81
+ * hardware/the desktop.
82
+ */
83
+ export interface NavHostLimit {
84
+ /** Why it cannot work here, in a sentence the owner would write. Shown on hover. */
85
+ reason: string;
86
+ /** Where it DOES work, if anywhere — "Open it on the Mac at localhost:4747". */
87
+ worksAt?: string;
88
+ /** Still let it be clicked (it degrades rather than failing). Default `false`. */
89
+ navigable?: boolean;
90
+ }
91
+ export interface NavDomain {
92
+ /** Stable unique key — prefs (order/hide/color) for the side-nav rail store by it. */
93
+ id: string;
94
+ /** Display label (side-nav row + hub landing title + breadcrumb root). */
95
+ label: string;
96
+ /** Icon *name* (required — domains always show an icon in the rail). */
97
+ icon: string;
98
+ /** The hub landing page path. */
99
+ path: string;
100
+ /** Domain-wide gate — inherited by every group/item under it. */
101
+ gate?: NavGate;
102
+ /**
103
+ * Short description — the hub-tile subtitle and the search/palette snippet, the
104
+ * same role it plays on a {@link NavItem}.
105
+ */
106
+ description?: string;
107
+ /**
108
+ * Extra match terms for search / palette (synonyms, old names, abbreviations) —
109
+ * again the same as on a {@link NavItem}.
110
+ *
111
+ * Both of these exist because a domain IS a destination: `deriveSearchCatalogue`
112
+ * emits one catalogue entry per domain, so a rail of leaf-shaped domains (an app
113
+ * whose places have no sub-items) had no way to be found by synonym while an
114
+ * otherwise identical rail of items did. That asymmetry was in the data model,
115
+ * not in the ranker.
116
+ */
117
+ keywords?: string[];
118
+ /**
119
+ * Optional rail section label. When any domain carries one, `SideNav` renders the
120
+ * rail as labeled, collapsible groups instead of one flat list — the fix for the
121
+ * "wall of 20+ flat items" rail (owner UX teardown 2026-07-15 §2). Domains without
122
+ * a section render first, ungrouped. Section order follows first appearance.
123
+ */
124
+ section?: string;
125
+ /**
126
+ * Mark this place as belonging in the mobile `PlaceBar`'s **thumb zone** — the
127
+ * flat row of tabs a phone shows, as opposed to the "More" bottom-sheet behind it.
128
+ * When ANY domain in the model is pinned, the `PlaceBar` fills the thumb zone from
129
+ * the pinned set (in declaration order, up to its `max` budget) and overflows the
130
+ * *unpinned* places to More — so the important destinations survive onto a phone
131
+ * regardless of where they sit in the rail order.
132
+ *
133
+ * The default (no domain pinned) is the historical behavior: the first `max - 1`
134
+ * places by declaration order ride the bar and the rest go to More. Reach for a pin
135
+ * when an app has more places than fit AND the ones that matter on a phone are not
136
+ * the first few — e.g. an Inbox declared after Media/Prompts (owner UX report
137
+ * 2026-08-04: the Inbox fell into More, and it is a place the owner opens).
138
+ *
139
+ * `SideNav` (the desktop rail) ignores this — the rail shows every place.
140
+ */
141
+ mobilePinned?: boolean;
142
+ /** Declare that this place cannot do its job on THIS host. It stays visible and
143
+ * says why on hover, rather than vanishing. @see NavHostLimit */
144
+ hostLimit?: NavHostLimit;
145
+ groups: NavGroup[];
146
+ }
147
+ /**
148
+ * The complete IA tree. `domains` are the browsable backbone; `ungrouped` items are
149
+ * registered + searchable (and reachable by palette) but live under no domain hub —
150
+ * use it for cross-cutting destinations (Settings, Profile) or for `deepLinkOnly`
151
+ * routes that need coverage without a hub home.
152
+ */
153
+ export interface NavigationModel {
154
+ domains: NavDomain[];
155
+ ungrouped?: NavItem[];
156
+ }
157
+ /**
158
+ * Injected by the app: decides whether a gated node is visible *right now* (auth
159
+ * state, the user's roles, the ctgr GET-only mode). Pure — the selectors call it for
160
+ * every domain/item, so it must be cheap and side-effect-free. An undefined gate
161
+ * (no restrictions) is always visible.
162
+ */
163
+ export type GateFn = (gate?: NavGate) => boolean;
164
+ /** The trivial gate — everything is visible. Default for selectors + unguarded apps. */
165
+ export declare const allowAllGate: GateFn;
@@ -0,0 +1,14 @@
1
+ // The single navigation source of truth — a declarative Information-Architecture
2
+ // tree (Domain → Group → Item) every nav surface derives from: the side nav, hub
3
+ // landing tiles, breadcrumbs, the universal-search catalogue, and the command
4
+ // palette. Authoring a feature into the model is the *only* way to surface it, so
5
+ // the historical "four hand-maintained lists drift out of sync → features get
6
+ // buried" bug is structurally impossible (the coverage test enforces it in CI).
7
+ //
8
+ // This module is **React-free and routing-free** (it ships from `cursedbelt-core/navigation`):
9
+ // items carry icon *names* (strings) and *paths* (strings), never components or
10
+ // `<Link>`s, so the same model is consumed by client components, server tests, and
11
+ // the coverage test alike. The React layer (`cursedbelt/react`) resolves icon names
12
+ // to nodes and paths to navigation.
13
+ /** The trivial gate — everything is visible. Default for selectors + unguarded apps. */
14
+ export const allowAllGate = () => true;
@@ -0,0 +1,63 @@
1
+ import { type GateFn, type NavDomain, type NavGroup, type NavItem, type NavigationModel } from './navigationModel';
2
+ /**
3
+ * Normalize a path for route *matching* — drop the query string, the hash (the
4
+ * `#tab=…` in-page target), and a trailing slash. `'/'` stays `'/'`. Used so a tab
5
+ * variant (`/tools#tab=curl`) matches the base route (`/tools`) and `/lists/`
6
+ * matches `/lists`.
7
+ */
8
+ export declare function normalizeNavPath(path: string): string;
9
+ /** A flattened, gated catalogue entry — a leaf plus where it lives (for grouping). */
10
+ export interface SearchCatalogueEntry extends NavItem {
11
+ /** Owning domain id (absent for ungrouped items). */
12
+ domainId?: string;
13
+ /** Owning domain label — groups search results + is itself matched. */
14
+ domainLabel?: string;
15
+ /** Owning group id (absent for domain landings + ungrouped items). */
16
+ groupId?: string;
17
+ /** Owning group label. */
18
+ groupLabel?: string;
19
+ }
20
+ /** One link in a breadcrumb trail — a coerced domain or item (always navigable). */
21
+ export interface NavCrumb {
22
+ id: string;
23
+ label: string;
24
+ path: string;
25
+ icon?: string;
26
+ }
27
+ /** The ordered domains visible under `gate` — the side-nav rail backbone. */
28
+ export declare function deriveSideNavDomains(model: NavigationModel, gate?: GateFn): NavDomain[];
29
+ /**
30
+ * Every visible (gated, non-`deepLinkOnly`) item of `domain`, flattened across its
31
+ * groups — for a simple HubTileGrid. Returns `[]` if the domain itself is gated out.
32
+ */
33
+ export declare function deriveDomainItems(domain: NavDomain, gate?: GateFn): NavItem[];
34
+ /**
35
+ * The visible groups of `domain` (each with its visible items; empty groups
36
+ * dropped) — for a *grouped* hub landing that shows section headings. Returns `[]`
37
+ * if the domain is gated out.
38
+ */
39
+ export declare function deriveDomainGroups(domain: NavDomain, gate?: GateFn): NavGroup[];
40
+ /** Look up a domain by id (gated). */
41
+ export declare function deriveDomain(model: NavigationModel, domainId: string, gate?: GateFn): NavDomain | undefined;
42
+ /**
43
+ * The ancestor chain for `path` — for Breadcrumbs. A domain landing yields
44
+ * `[domain]`; an item yields `[domain, item]`; an ungrouped item yields `[item]`.
45
+ * Matches the raw path first (so a `#tab=…` variant resolves to its own leaf), then
46
+ * falls back to the normalized base path. Returns `[]` when nothing matches.
47
+ */
48
+ export declare function deriveBreadcrumbTrail(model: NavigationModel, path: string, gate?: GateFn): NavCrumb[];
49
+ /**
50
+ * The flat list of searchable destinations — every visible (gated,
51
+ * non-`deepLinkOnly`) domain landing + item, each tagged with its domain/group so
52
+ * results can be grouped. Drives HeaderSearch's instant pages layer *and* the
53
+ * command palette; replaces every hand-listed search catalogue (so a feature is
54
+ * findable the moment it's registered).
55
+ */
56
+ export declare function deriveSearchCatalogue(model: NavigationModel, gate?: GateFn): SearchCatalogueEntry[];
57
+ /**
58
+ * Every path registered in the model (domain landings + all items + ungrouped),
59
+ * INCLUDING `deepLinkOnly` items — that's the point: a `deepLinkOnly` route is still
60
+ * "registered", so it satisfies coverage. De-duplicated. Consumed by the coverage
61
+ * test ({@link createCoverageTest}).
62
+ */
63
+ export declare function deriveAllPaths(model: NavigationModel): string[];
@@ -0,0 +1,167 @@
1
+ // Pure derivations off the {@link NavigationModel}. Every nav surface (side nav,
2
+ // hub tiles, breadcrumbs, search catalogue, palette, the coverage test) reads the
3
+ // model exclusively through these — there is no second list to keep in sync. They
4
+ // import nothing from React, so they run identically in the client, in a server
5
+ // test, and in the CI coverage check.
6
+ import { allowAllGate, } from './navigationModel';
7
+ /**
8
+ * Normalize a path for route *matching* — drop the query string, the hash (the
9
+ * `#tab=…` in-page target), and a trailing slash. `'/'` stays `'/'`. Used so a tab
10
+ * variant (`/tools#tab=curl`) matches the base route (`/tools`) and `/lists/`
11
+ * matches `/lists`.
12
+ */
13
+ export function normalizeNavPath(path) {
14
+ // Null-safe: a nav selector must never throw (a breadcrumb derivation crashing
15
+ // white-screens the whole page). Coerce a missing/non-string path to ''.
16
+ const s = typeof path === 'string' ? path : '';
17
+ let p = (s.split('#')[0] ?? '').split('?')[0]?.trim() ?? '';
18
+ if (p.length > 1 && p.endsWith('/'))
19
+ p = p.slice(0, -1);
20
+ return p || '/';
21
+ }
22
+ const domainCrumb = (d) => ({
23
+ id: d.id,
24
+ label: d.label,
25
+ path: d.path,
26
+ icon: d.icon,
27
+ });
28
+ const itemCrumb = (it) => ({
29
+ id: it.id,
30
+ label: it.label,
31
+ path: it.path,
32
+ icon: it.icon,
33
+ });
34
+ /** The ordered domains visible under `gate` — the side-nav rail backbone. */
35
+ export function deriveSideNavDomains(model, gate = allowAllGate) {
36
+ return (model?.domains ?? []).filter((d) => gate(d.gate));
37
+ }
38
+ /**
39
+ * Every visible (gated, non-`deepLinkOnly`) item of `domain`, flattened across its
40
+ * groups — for a simple HubTileGrid. Returns `[]` if the domain itself is gated out.
41
+ */
42
+ export function deriveDomainItems(domain, gate = allowAllGate) {
43
+ if (!gate(domain.gate))
44
+ return [];
45
+ return domain.groups.flatMap((g) => g.items).filter((it) => !it.deepLinkOnly && gate(it.gate));
46
+ }
47
+ /**
48
+ * The visible groups of `domain` (each with its visible items; empty groups
49
+ * dropped) — for a *grouped* hub landing that shows section headings. Returns `[]`
50
+ * if the domain is gated out.
51
+ */
52
+ export function deriveDomainGroups(domain, gate = allowAllGate) {
53
+ if (!gate(domain.gate))
54
+ return [];
55
+ return domain.groups
56
+ .map((g) => ({ ...g, items: g.items.filter((it) => !it.deepLinkOnly && gate(it.gate)) }))
57
+ .filter((g) => g.items.length > 0);
58
+ }
59
+ /** Look up a domain by id (gated). */
60
+ export function deriveDomain(model, domainId, gate = allowAllGate) {
61
+ const d = (model?.domains ?? []).find((x) => x.id === domainId);
62
+ return d && gate(d.gate) ? d : undefined;
63
+ }
64
+ /**
65
+ * The ancestor chain for `path` — for Breadcrumbs. A domain landing yields
66
+ * `[domain]`; an item yields `[domain, item]`; an ungrouped item yields `[item]`.
67
+ * Matches the raw path first (so a `#tab=…` variant resolves to its own leaf), then
68
+ * falls back to the normalized base path. Returns `[]` when nothing matches.
69
+ */
70
+ export function deriveBreadcrumbTrail(model, path, gate = allowAllGate) {
71
+ // Null-safe (see normalizeNavPath): a consumer that hasn't wired the model/path yet
72
+ // must get an empty trail, never a crash — a breadcrumb derivation throwing
73
+ // white-screens the whole page. Degrade gracefully on a missing model or path.
74
+ if (!model || !Array.isArray(model?.domains ?? []))
75
+ return [];
76
+ const safe = typeof path === 'string' ? path : '';
77
+ const raw = safe.trim();
78
+ const target = normalizeNavPath(safe);
79
+ // Two passes so an EXACT path wins over a merely-normalized one: `/tools#tab=curl`
80
+ // must resolve to the Curl tab leaf, not be shadowed by the `/tools` domain landing
81
+ // (which also normalizes to `/tools`). Within a pass, the domain landing is checked
82
+ // before its items so the bare `/tools` route yields `[Tools]`, not a tab leaf.
83
+ const find = (match) => {
84
+ for (const domain of model?.domains ?? []) {
85
+ if (!gate(domain.gate))
86
+ continue;
87
+ if (match(domain.path))
88
+ return [domainCrumb(domain)];
89
+ for (const group of domain.groups) {
90
+ for (const item of group.items) {
91
+ if (gate(item.gate) && match(item.path))
92
+ return [domainCrumb(domain), itemCrumb(item)];
93
+ }
94
+ }
95
+ }
96
+ for (const item of model.ungrouped ?? []) {
97
+ if (gate(item.gate) && match(item.path))
98
+ return [itemCrumb(item)];
99
+ }
100
+ return null;
101
+ };
102
+ return find((p) => p === raw) ?? find((p) => normalizeNavPath(p) === target) ?? [];
103
+ }
104
+ /**
105
+ * The flat list of searchable destinations — every visible (gated,
106
+ * non-`deepLinkOnly`) domain landing + item, each tagged with its domain/group so
107
+ * results can be grouped. Drives HeaderSearch's instant pages layer *and* the
108
+ * command palette; replaces every hand-listed search catalogue (so a feature is
109
+ * findable the moment it's registered).
110
+ */
111
+ export function deriveSearchCatalogue(model, gate = allowAllGate) {
112
+ const out = [];
113
+ for (const domain of model?.domains ?? []) {
114
+ if (!gate(domain.gate))
115
+ continue;
116
+ out.push({
117
+ id: domain.id,
118
+ path: domain.path,
119
+ label: domain.label,
120
+ icon: domain.icon,
121
+ // A domain landing is a destination like any other, so it carries the same
122
+ // search metadata an item does — without these, an app whose places are
123
+ // leaf-shaped domains could only ever be found by exact label.
124
+ ...(domain.description === undefined ? {} : { description: domain.description }),
125
+ ...(domain.keywords === undefined ? {} : { keywords: domain.keywords }),
126
+ domainId: domain.id,
127
+ domainLabel: domain.label,
128
+ });
129
+ for (const group of domain.groups) {
130
+ for (const item of group.items) {
131
+ if (item.deepLinkOnly || !gate(item.gate))
132
+ continue;
133
+ out.push({
134
+ ...item,
135
+ domainId: domain.id,
136
+ domainLabel: domain.label,
137
+ groupId: group.id,
138
+ groupLabel: group.label,
139
+ });
140
+ }
141
+ }
142
+ }
143
+ for (const item of model.ungrouped ?? []) {
144
+ if (item.deepLinkOnly || !gate(item.gate))
145
+ continue;
146
+ out.push({ ...item });
147
+ }
148
+ return out;
149
+ }
150
+ /**
151
+ * Every path registered in the model (domain landings + all items + ungrouped),
152
+ * INCLUDING `deepLinkOnly` items — that's the point: a `deepLinkOnly` route is still
153
+ * "registered", so it satisfies coverage. De-duplicated. Consumed by the coverage
154
+ * test ({@link createCoverageTest}).
155
+ */
156
+ export function deriveAllPaths(model) {
157
+ const paths = new Set();
158
+ for (const domain of model?.domains ?? []) {
159
+ paths.add(domain.path);
160
+ for (const group of domain.groups)
161
+ for (const item of group.items)
162
+ paths.add(item.path);
163
+ }
164
+ for (const item of model.ungrouped ?? [])
165
+ paths.add(item.path);
166
+ return [...paths];
167
+ }
@@ -0,0 +1,23 @@
1
+ import type { SearchCatalogueEntry } from './navigationSelectors';
2
+ /** A query hit with its computed relevance score (higher = better). */
3
+ export interface CatalogueHit extends SearchCatalogueEntry {
4
+ score: number;
5
+ }
6
+ /** Hits bucketed under a domain (or under "" for ungrouped), preserving rank order. */
7
+ export interface CatalogueResultGroup {
8
+ /** Domain id, or `''` for ungrouped destinations. */
9
+ key: string;
10
+ /** Domain label, or `'Pages'` for ungrouped. */
11
+ label: string;
12
+ items: CatalogueHit[];
13
+ }
14
+ /**
15
+ * Filter + rank a catalogue by `query`. Empty/blank query → `[]` (the instant layer
16
+ * shows nothing until the user types). Stable: equal scores keep catalogue order.
17
+ */
18
+ export declare function rankCatalogue(catalogue: SearchCatalogueEntry[], query: string): CatalogueHit[];
19
+ /**
20
+ * Rank + bucket hits by owning domain (ungrouped under a "Pages" group), capping the
21
+ * total to `limit`. Groups appear in first-hit order; within a group, by rank.
22
+ */
23
+ export declare function groupCatalogueHits(catalogue: SearchCatalogueEntry[], query: string, limit?: number): CatalogueResultGroup[];
@@ -0,0 +1,76 @@
1
+ // Pure matcher + grouper for the instant client-side "pages" search layer. Ranks a
2
+ // derived catalogue ({@link deriveSearchCatalogue}) against a query and buckets the
3
+ // hits by their owning domain. Kept pure + framework-free so it's unit-testable and
4
+ // shared by HeaderSearch and (task 14) the command palette. The palette upgrades the
5
+ // *ranking* to uFuzzy; this is the dependency-free baseline both can fall back to.
6
+ const norm = (s) => s.toLowerCase().trim();
7
+ /**
8
+ * Score one entry against a normalized query. Returns `0` for no match. Ranking
9
+ * favors, in order: exact label, label prefix, label word-boundary, label
10
+ * substring, then keyword / group / domain / path / description hits — so "env"
11
+ * surfaces "Env Sets" above a page that merely mentions environments.
12
+ */
13
+ function scoreEntry(entry, q) {
14
+ const label = norm(entry.label);
15
+ if (label === q)
16
+ return 100;
17
+ if (label.startsWith(q))
18
+ return 80;
19
+ // word-boundary: a query that starts any word in the label ("col" → "Format **Col**…")
20
+ if (label.split(/[\s/&·-]+/).some((w) => w.startsWith(q)))
21
+ return 65;
22
+ if (label.includes(q))
23
+ return 50;
24
+ for (const kw of entry.keywords ?? []) {
25
+ const k = norm(kw);
26
+ if (k === q || k.startsWith(q))
27
+ return 45;
28
+ if (k.includes(q))
29
+ return 35;
30
+ }
31
+ if (entry.groupLabel && norm(entry.groupLabel).includes(q))
32
+ return 25;
33
+ if (entry.domainLabel && norm(entry.domainLabel).includes(q))
34
+ return 20;
35
+ if (norm(entry.path).includes(q))
36
+ return 15;
37
+ if (entry.description && norm(entry.description).includes(q))
38
+ return 10;
39
+ return 0;
40
+ }
41
+ /**
42
+ * Filter + rank a catalogue by `query`. Empty/blank query → `[]` (the instant layer
43
+ * shows nothing until the user types). Stable: equal scores keep catalogue order.
44
+ */
45
+ export function rankCatalogue(catalogue, query) {
46
+ const q = norm(query);
47
+ if (!q)
48
+ return [];
49
+ const scored = [];
50
+ for (const entry of catalogue) {
51
+ const score = scoreEntry(entry, q);
52
+ if (score > 0)
53
+ scored.push({ ...entry, score });
54
+ }
55
+ // Stable sort by score desc — Array.prototype.sort is stable in modern engines, so
56
+ // equal scores retain catalogue (authoring) order.
57
+ return scored.sort((a, b) => b.score - a.score);
58
+ }
59
+ /**
60
+ * Rank + bucket hits by owning domain (ungrouped under a "Pages" group), capping the
61
+ * total to `limit`. Groups appear in first-hit order; within a group, by rank.
62
+ */
63
+ export function groupCatalogueHits(catalogue, query, limit = 20) {
64
+ const hits = rankCatalogue(catalogue, query).slice(0, limit);
65
+ const groups = new Map();
66
+ for (const hit of hits) {
67
+ const key = hit.domainId ?? '';
68
+ let bucket = groups.get(key);
69
+ if (!bucket) {
70
+ bucket = { key, label: hit.domainLabel ?? 'Pages', items: [] };
71
+ groups.set(key, bucket);
72
+ }
73
+ bucket.items.push(hit);
74
+ }
75
+ return [...groups.values()];
76
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Reconnect discipline for every persistent client connection (03 §6.5, J5-2).
3
+ *
4
+ * A server restart or a laptop resume wakes every client at once. With a fixed
5
+ * retry interval they all reconnect in the same millisecond and the app
6
+ * self-DDoSes its own Hono listener — the more clients, the harder the stampede.
7
+ *
8
+ * So: exponential backoff with FULL jitter — `delay = random(0, min(cap, base ·
9
+ * 2^attempt))`. Full jitter (rather than "±10%") is what actually spreads a
10
+ * synchronized wake across the window. A stable connection resets the attempt
11
+ * counter, so a flaky link does not inherit yesterday's backoff.
12
+ */
13
+ export interface ReconnectScheduleOptions {
14
+ baseMs?: number;
15
+ capMs?: number;
16
+ /** Connected for this long ⇒ the next drop starts from attempt 0 again. */
17
+ stableResetMs?: number;
18
+ /** Injected for tests; defaults to Math.random. */
19
+ random?: () => number;
20
+ }
21
+ export declare const RECONNECT_BASE_MS = 500;
22
+ export declare const RECONNECT_CAP_MS = 30000;
23
+ export declare const RECONNECT_STABLE_RESET_MS = 60000;
24
+ export interface ReconnectSchedule {
25
+ /** Delay for the next attempt, and advance the attempt counter. */
26
+ nextDelayMs(): number;
27
+ /** Call when a connection has been up; resets the counter once it has been stable. */
28
+ onConnected(uptimeMs: number): void;
29
+ /**
30
+ * Drop the accumulated backoff outright — for a DELIBERATE re-open (a
31
+ * credential change), which is not a failure and must not inherit the penalty
32
+ * the last outage earned. Never call this on a retry path: that is exactly how
33
+ * a fixed interval creeps back in.
34
+ */
35
+ reset(): void;
36
+ attempt(): number;
37
+ /** The deterministic ceiling for the current attempt — what the jitter is drawn from. */
38
+ currentCeilingMs(): number;
39
+ }
40
+ export declare function createReconnectSchedule(options?: ReconnectScheduleOptions): ReconnectSchedule;
41
+ /**
42
+ * Did a set of observed retry timestamps actually spread? Used by V7.11: clients
43
+ * clustered inside one 50ms bucket beyond chance means someone reintroduced a
44
+ * fixed interval.
45
+ */
46
+ export declare function retriesAreJittered(timestampsMs: readonly number[], bucketMs?: number): boolean;
@@ -0,0 +1,38 @@
1
+ export const RECONNECT_BASE_MS = 500;
2
+ export const RECONNECT_CAP_MS = 30_000;
3
+ export const RECONNECT_STABLE_RESET_MS = 60_000;
4
+ export function createReconnectSchedule(options = {}) {
5
+ const base = options.baseMs ?? RECONNECT_BASE_MS;
6
+ const cap = options.capMs ?? RECONNECT_CAP_MS;
7
+ const stableReset = options.stableResetMs ?? RECONNECT_STABLE_RESET_MS;
8
+ const random = options.random ?? Math.random;
9
+ let attempt = 0;
10
+ const ceiling = (n) => Math.min(cap, base * 2 ** n);
11
+ return {
12
+ nextDelayMs() {
13
+ const delay = random() * ceiling(attempt);
14
+ attempt += 1;
15
+ return delay;
16
+ },
17
+ onConnected(uptimeMs) {
18
+ if (uptimeMs >= stableReset)
19
+ attempt = 0;
20
+ },
21
+ reset() {
22
+ attempt = 0;
23
+ },
24
+ attempt: () => attempt,
25
+ currentCeilingMs: () => ceiling(attempt),
26
+ };
27
+ }
28
+ /**
29
+ * Did a set of observed retry timestamps actually spread? Used by V7.11: clients
30
+ * clustered inside one 50ms bucket beyond chance means someone reintroduced a
31
+ * fixed interval.
32
+ */
33
+ export function retriesAreJittered(timestampsMs, bucketMs = 50) {
34
+ if (timestampsMs.length < 2)
35
+ return true;
36
+ const buckets = new Set(timestampsMs.map((t) => Math.floor(t / bucketMs)));
37
+ return buckets.size > 1;
38
+ }