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,206 @@
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
+
7
+ import {
8
+ allowAllGate,
9
+ type GateFn,
10
+ type NavDomain,
11
+ type NavGroup,
12
+ type NavItem,
13
+ type NavigationModel,
14
+ } from './navigationModel';
15
+
16
+ /**
17
+ * Normalize a path for route *matching* — drop the query string, the hash (the
18
+ * `#tab=…` in-page target), and a trailing slash. `'/'` stays `'/'`. Used so a tab
19
+ * variant (`/tools#tab=curl`) matches the base route (`/tools`) and `/lists/`
20
+ * matches `/lists`.
21
+ */
22
+ export function normalizeNavPath(path: string): string {
23
+ // Null-safe: a nav selector must never throw (a breadcrumb derivation crashing
24
+ // white-screens the whole page). Coerce a missing/non-string path to ''.
25
+ const s = typeof path === 'string' ? path : '';
26
+ let p = (s.split('#')[0] ?? '').split('?')[0]?.trim() ?? '';
27
+ if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
28
+ return p || '/';
29
+ }
30
+
31
+ /** A flattened, gated catalogue entry — a leaf plus where it lives (for grouping). */
32
+ export interface SearchCatalogueEntry extends NavItem {
33
+ /** Owning domain id (absent for ungrouped items). */
34
+ domainId?: string;
35
+ /** Owning domain label — groups search results + is itself matched. */
36
+ domainLabel?: string;
37
+ /** Owning group id (absent for domain landings + ungrouped items). */
38
+ groupId?: string;
39
+ /** Owning group label. */
40
+ groupLabel?: string;
41
+ }
42
+
43
+ /** One link in a breadcrumb trail — a coerced domain or item (always navigable). */
44
+ export interface NavCrumb {
45
+ id: string;
46
+ label: string;
47
+ path: string;
48
+ icon?: string;
49
+ }
50
+
51
+ const domainCrumb = (d: NavDomain): NavCrumb => ({
52
+ id: d.id,
53
+ label: d.label,
54
+ path: d.path,
55
+ icon: d.icon,
56
+ });
57
+ const itemCrumb = (it: NavItem): NavCrumb => ({
58
+ id: it.id,
59
+ label: it.label,
60
+ path: it.path,
61
+ icon: it.icon,
62
+ });
63
+
64
+ /** The ordered domains visible under `gate` — the side-nav rail backbone. */
65
+ export function deriveSideNavDomains(
66
+ model: NavigationModel,
67
+ gate: GateFn = allowAllGate,
68
+ ): NavDomain[] {
69
+ return (model?.domains ?? []).filter((d) => gate(d.gate));
70
+ }
71
+
72
+ /**
73
+ * Every visible (gated, non-`deepLinkOnly`) item of `domain`, flattened across its
74
+ * groups — for a simple HubTileGrid. Returns `[]` if the domain itself is gated out.
75
+ */
76
+ export function deriveDomainItems(domain: NavDomain, gate: GateFn = allowAllGate): NavItem[] {
77
+ if (!gate(domain.gate)) return [];
78
+ return domain.groups.flatMap((g) => g.items).filter((it) => !it.deepLinkOnly && gate(it.gate));
79
+ }
80
+
81
+ /**
82
+ * The visible groups of `domain` (each with its visible items; empty groups
83
+ * dropped) — for a *grouped* hub landing that shows section headings. Returns `[]`
84
+ * if the domain is gated out.
85
+ */
86
+ export function deriveDomainGroups(domain: NavDomain, gate: GateFn = allowAllGate): NavGroup[] {
87
+ if (!gate(domain.gate)) return [];
88
+ return domain.groups
89
+ .map((g) => ({ ...g, items: g.items.filter((it) => !it.deepLinkOnly && gate(it.gate)) }))
90
+ .filter((g) => g.items.length > 0);
91
+ }
92
+
93
+ /** Look up a domain by id (gated). */
94
+ export function deriveDomain(
95
+ model: NavigationModel,
96
+ domainId: string,
97
+ gate: GateFn = allowAllGate,
98
+ ): NavDomain | undefined {
99
+ const d = (model?.domains ?? []).find((x) => x.id === domainId);
100
+ return d && gate(d.gate) ? d : undefined;
101
+ }
102
+
103
+ /**
104
+ * The ancestor chain for `path` — for Breadcrumbs. A domain landing yields
105
+ * `[domain]`; an item yields `[domain, item]`; an ungrouped item yields `[item]`.
106
+ * Matches the raw path first (so a `#tab=…` variant resolves to its own leaf), then
107
+ * falls back to the normalized base path. Returns `[]` when nothing matches.
108
+ */
109
+ export function deriveBreadcrumbTrail(
110
+ model: NavigationModel,
111
+ path: string,
112
+ gate: GateFn = allowAllGate,
113
+ ): NavCrumb[] {
114
+ // Null-safe (see normalizeNavPath): a consumer that hasn't wired the model/path yet
115
+ // must get an empty trail, never a crash — a breadcrumb derivation throwing
116
+ // white-screens the whole page. Degrade gracefully on a missing model or path.
117
+ if (!model || !Array.isArray(model?.domains ?? [])) return [];
118
+ const safe = typeof path === 'string' ? path : '';
119
+ const raw = safe.trim();
120
+ const target = normalizeNavPath(safe);
121
+
122
+ // Two passes so an EXACT path wins over a merely-normalized one: `/tools#tab=curl`
123
+ // must resolve to the Curl tab leaf, not be shadowed by the `/tools` domain landing
124
+ // (which also normalizes to `/tools`). Within a pass, the domain landing is checked
125
+ // before its items so the bare `/tools` route yields `[Tools]`, not a tab leaf.
126
+ const find = (match: (p: string) => boolean): NavCrumb[] | null => {
127
+ for (const domain of model?.domains ?? []) {
128
+ if (!gate(domain.gate)) continue;
129
+ if (match(domain.path)) return [domainCrumb(domain)];
130
+ for (const group of domain.groups) {
131
+ for (const item of group.items) {
132
+ if (gate(item.gate) && match(item.path)) return [domainCrumb(domain), itemCrumb(item)];
133
+ }
134
+ }
135
+ }
136
+ for (const item of model.ungrouped ?? []) {
137
+ if (gate(item.gate) && match(item.path)) return [itemCrumb(item)];
138
+ }
139
+ return null;
140
+ };
141
+
142
+ return find((p) => p === raw) ?? find((p) => normalizeNavPath(p) === target) ?? [];
143
+ }
144
+
145
+ /**
146
+ * The flat list of searchable destinations — every visible (gated,
147
+ * non-`deepLinkOnly`) domain landing + item, each tagged with its domain/group so
148
+ * results can be grouped. Drives HeaderSearch's instant pages layer *and* the
149
+ * command palette; replaces every hand-listed search catalogue (so a feature is
150
+ * findable the moment it's registered).
151
+ */
152
+ export function deriveSearchCatalogue(
153
+ model: NavigationModel,
154
+ gate: GateFn = allowAllGate,
155
+ ): SearchCatalogueEntry[] {
156
+ const out: SearchCatalogueEntry[] = [];
157
+ for (const domain of model?.domains ?? []) {
158
+ if (!gate(domain.gate)) continue;
159
+ out.push({
160
+ id: domain.id,
161
+ path: domain.path,
162
+ label: domain.label,
163
+ icon: domain.icon,
164
+ // A domain landing is a destination like any other, so it carries the same
165
+ // search metadata an item does — without these, an app whose places are
166
+ // leaf-shaped domains could only ever be found by exact label.
167
+ ...(domain.description === undefined ? {} : { description: domain.description }),
168
+ ...(domain.keywords === undefined ? {} : { keywords: domain.keywords }),
169
+ domainId: domain.id,
170
+ domainLabel: domain.label,
171
+ });
172
+ for (const group of domain.groups) {
173
+ for (const item of group.items) {
174
+ if (item.deepLinkOnly || !gate(item.gate)) continue;
175
+ out.push({
176
+ ...item,
177
+ domainId: domain.id,
178
+ domainLabel: domain.label,
179
+ groupId: group.id,
180
+ groupLabel: group.label,
181
+ });
182
+ }
183
+ }
184
+ }
185
+ for (const item of model.ungrouped ?? []) {
186
+ if (item.deepLinkOnly || !gate(item.gate)) continue;
187
+ out.push({ ...item });
188
+ }
189
+ return out;
190
+ }
191
+
192
+ /**
193
+ * Every path registered in the model (domain landings + all items + ungrouped),
194
+ * INCLUDING `deepLinkOnly` items — that's the point: a `deepLinkOnly` route is still
195
+ * "registered", so it satisfies coverage. De-duplicated. Consumed by the coverage
196
+ * test ({@link createCoverageTest}).
197
+ */
198
+ export function deriveAllPaths(model: NavigationModel): string[] {
199
+ const paths = new Set<string>();
200
+ for (const domain of model?.domains ?? []) {
201
+ paths.add(domain.path);
202
+ for (const group of domain.groups) for (const item of group.items) paths.add(item.path);
203
+ }
204
+ for (const item of model.ungrouped ?? []) paths.add(item.path);
205
+ return [...paths];
206
+ }
@@ -0,0 +1,89 @@
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
+
7
+ import type { SearchCatalogueEntry } from './navigationSelectors';
8
+
9
+ /** A query hit with its computed relevance score (higher = better). */
10
+ export interface CatalogueHit extends SearchCatalogueEntry {
11
+ score: number;
12
+ }
13
+
14
+ /** Hits bucketed under a domain (or under "" for ungrouped), preserving rank order. */
15
+ export interface CatalogueResultGroup {
16
+ /** Domain id, or `''` for ungrouped destinations. */
17
+ key: string;
18
+ /** Domain label, or `'Pages'` for ungrouped. */
19
+ label: string;
20
+ items: CatalogueHit[];
21
+ }
22
+
23
+ const norm = (s: string) => s.toLowerCase().trim();
24
+
25
+ /**
26
+ * Score one entry against a normalized query. Returns `0` for no match. Ranking
27
+ * favors, in order: exact label, label prefix, label word-boundary, label
28
+ * substring, then keyword / group / domain / path / description hits — so "env"
29
+ * surfaces "Env Sets" above a page that merely mentions environments.
30
+ */
31
+ function scoreEntry(entry: SearchCatalogueEntry, q: string): number {
32
+ const label = norm(entry.label);
33
+ if (label === q) return 100;
34
+ if (label.startsWith(q)) return 80;
35
+ // word-boundary: a query that starts any word in the label ("col" → "Format **Col**…")
36
+ if (label.split(/[\s/&·-]+/).some((w) => w.startsWith(q))) return 65;
37
+ if (label.includes(q)) return 50;
38
+
39
+ for (const kw of entry.keywords ?? []) {
40
+ const k = norm(kw);
41
+ if (k === q || k.startsWith(q)) return 45;
42
+ if (k.includes(q)) return 35;
43
+ }
44
+ if (entry.groupLabel && norm(entry.groupLabel).includes(q)) return 25;
45
+ if (entry.domainLabel && norm(entry.domainLabel).includes(q)) return 20;
46
+ if (norm(entry.path).includes(q)) return 15;
47
+ if (entry.description && norm(entry.description).includes(q)) return 10;
48
+ return 0;
49
+ }
50
+
51
+ /**
52
+ * Filter + rank a catalogue by `query`. Empty/blank query → `[]` (the instant layer
53
+ * shows nothing until the user types). Stable: equal scores keep catalogue order.
54
+ */
55
+ export function rankCatalogue(catalogue: SearchCatalogueEntry[], query: string): CatalogueHit[] {
56
+ const q = norm(query);
57
+ if (!q) return [];
58
+ const scored: CatalogueHit[] = [];
59
+ for (const entry of catalogue) {
60
+ const score = scoreEntry(entry, q);
61
+ if (score > 0) scored.push({ ...entry, score });
62
+ }
63
+ // Stable sort by score desc — Array.prototype.sort is stable in modern engines, so
64
+ // equal scores retain catalogue (authoring) order.
65
+ return scored.sort((a, b) => b.score - a.score);
66
+ }
67
+
68
+ /**
69
+ * Rank + bucket hits by owning domain (ungrouped under a "Pages" group), capping the
70
+ * total to `limit`. Groups appear in first-hit order; within a group, by rank.
71
+ */
72
+ export function groupCatalogueHits(
73
+ catalogue: SearchCatalogueEntry[],
74
+ query: string,
75
+ limit = 20,
76
+ ): CatalogueResultGroup[] {
77
+ const hits = rankCatalogue(catalogue, query).slice(0, limit);
78
+ const groups = new Map<string, CatalogueResultGroup>();
79
+ for (const hit of hits) {
80
+ const key = hit.domainId ?? '';
81
+ let bucket = groups.get(key);
82
+ if (!bucket) {
83
+ bucket = { key, label: hit.domainLabel ?? 'Pages', items: [] };
84
+ groups.set(key, bucket);
85
+ }
86
+ bucket.items.push(hit);
87
+ }
88
+ return [...groups.values()];
89
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ createReconnectSchedule,
4
+ RECONNECT_CAP_MS,
5
+ retriesAreJittered,
6
+ } from './reconnectSchedule';
7
+
8
+ describe('reconnect backoff with full jitter (J5-2)', () => {
9
+ it('doubles the ceiling per attempt up to the cap', () => {
10
+ const schedule = createReconnectSchedule({ random: () => 1 });
11
+ expect(schedule.currentCeilingMs()).toBe(500);
12
+ schedule.nextDelayMs();
13
+ expect(schedule.currentCeilingMs()).toBe(1_000);
14
+ schedule.nextDelayMs();
15
+ expect(schedule.currentCeilingMs()).toBe(2_000);
16
+ for (let i = 0; i < 20; i++) schedule.nextDelayMs();
17
+ expect(schedule.currentCeilingMs()).toBe(RECONNECT_CAP_MS);
18
+ });
19
+
20
+ it('draws the delay from [0, ceiling) — full jitter, not a fixed interval', () => {
21
+ const lowest = createReconnectSchedule({ random: () => 0 });
22
+ const highest = createReconnectSchedule({ random: () => 0.999 });
23
+ expect(lowest.nextDelayMs()).toBe(0);
24
+ expect(highest.nextDelayMs()).toBeLessThan(500);
25
+ expect(highest.nextDelayMs()).toBeGreaterThan(500); // second attempt, larger window
26
+ });
27
+
28
+ it('spreads a synchronized wake across three clients', () => {
29
+ const clients = [0.05, 0.4, 0.95].map((seed) => createReconnectSchedule({ random: () => seed }));
30
+ const delays = clients.map((client) => client.nextDelayMs());
31
+ expect(retriesAreJittered(delays)).toBe(true);
32
+ });
33
+
34
+ it('flags a fixed-interval retry loop as a stampede', () => {
35
+ expect(retriesAreJittered([1000, 1001, 1002])).toBe(false);
36
+ expect(retriesAreJittered([1000])).toBe(true); // a single client cannot stampede
37
+ });
38
+
39
+ it('resets only after the connection has been stable long enough', () => {
40
+ const schedule = createReconnectSchedule({ random: () => 0.5 });
41
+ schedule.nextDelayMs();
42
+ schedule.nextDelayMs();
43
+ expect(schedule.attempt()).toBe(2);
44
+ schedule.onConnected(10_000);
45
+ expect(schedule.attempt()).toBe(2); // a brief connection is not stability
46
+ schedule.onConnected(60_000);
47
+ expect(schedule.attempt()).toBe(0);
48
+ });
49
+ });
@@ -0,0 +1,78 @@
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
+
22
+ export const RECONNECT_BASE_MS = 500;
23
+ export const RECONNECT_CAP_MS = 30_000;
24
+ export const RECONNECT_STABLE_RESET_MS = 60_000;
25
+
26
+ export interface ReconnectSchedule {
27
+ /** Delay for the next attempt, and advance the attempt counter. */
28
+ nextDelayMs(): number;
29
+ /** Call when a connection has been up; resets the counter once it has been stable. */
30
+ onConnected(uptimeMs: number): void;
31
+ /**
32
+ * Drop the accumulated backoff outright — for a DELIBERATE re-open (a
33
+ * credential change), which is not a failure and must not inherit the penalty
34
+ * the last outage earned. Never call this on a retry path: that is exactly how
35
+ * a fixed interval creeps back in.
36
+ */
37
+ reset(): void;
38
+ attempt(): number;
39
+ /** The deterministic ceiling for the current attempt — what the jitter is drawn from. */
40
+ currentCeilingMs(): number;
41
+ }
42
+
43
+ export function createReconnectSchedule(options: ReconnectScheduleOptions = {}): ReconnectSchedule {
44
+ const base = options.baseMs ?? RECONNECT_BASE_MS;
45
+ const cap = options.capMs ?? RECONNECT_CAP_MS;
46
+ const stableReset = options.stableResetMs ?? RECONNECT_STABLE_RESET_MS;
47
+ const random = options.random ?? Math.random;
48
+ let attempt = 0;
49
+
50
+ const ceiling = (n: number): number => Math.min(cap, base * 2 ** n);
51
+
52
+ return {
53
+ nextDelayMs() {
54
+ const delay = random() * ceiling(attempt);
55
+ attempt += 1;
56
+ return delay;
57
+ },
58
+ onConnected(uptimeMs) {
59
+ if (uptimeMs >= stableReset) attempt = 0;
60
+ },
61
+ reset() {
62
+ attempt = 0;
63
+ },
64
+ attempt: () => attempt,
65
+ currentCeilingMs: () => ceiling(attempt),
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Did a set of observed retry timestamps actually spread? Used by V7.11: clients
71
+ * clustered inside one 50ms bucket beyond chance means someone reintroduced a
72
+ * fixed interval.
73
+ */
74
+ export function retriesAreJittered(timestampsMs: readonly number[], bucketMs = 50): boolean {
75
+ if (timestampsMs.length < 2) return true;
76
+ const buckets = new Set(timestampsMs.map((t) => Math.floor(t / bucketMs)));
77
+ return buckets.size > 1;
78
+ }
@@ -0,0 +1,69 @@
1
+ // A tiny global registry of the app's keyboard shortcuts — the single source of truth
2
+ // that BOTH the keybinding layer (`useHotkeys`) and the command palette read from. An
3
+ // app registers a shortcut once; the palette can then list "Board → ⌥B" in its
4
+ // cold-state reference without the binding and its documentation drifting apart.
5
+ //
6
+ // Register in a mount effect (never at module load) so the palette's list reflects the
7
+ // shortcuts actually wired up for the current screen; the registry notifies subscribers
8
+ // on every change so a mounted palette stays live as shortcuts come and go.
9
+
10
+ /** A registered keyboard shortcut — its binding plus how it reads in the palette. */
11
+ export interface ShortcutEntry {
12
+ /** Short name of the thing it acts on (e.g. `'Board'`). */
13
+ label: string;
14
+ /** Binding in tinykeys format (`'$mod+k'`, `'alt+b'`) — also the registry key. */
15
+ keys: string;
16
+ /** What the shortcut does, shown as the palette row's description (e.g. `'Open the board'`). */
17
+ action: string;
18
+ }
19
+
20
+ type Listener = () => void;
21
+
22
+ /**
23
+ * Keyed by `keys`, so registering the same binding twice replaces it (last write wins
24
+ * — no duplicate rows) and a feature can re-register on every mount idempotently.
25
+ */
26
+ export class ShortcutRegistry {
27
+ private entries = new Map<string, ShortcutEntry>();
28
+ private listeners = new Set<Listener>();
29
+ // A frozen snapshot, rebuilt only on mutation, so `getAll()` is referentially stable
30
+ // between changes — required for `useSyncExternalStore` to not loop.
31
+ private snapshot: ShortcutEntry[] = [];
32
+
33
+ /** Add or replace the shortcut for `entry.keys`. */
34
+ register(entry: ShortcutEntry): void {
35
+ this.entries.set(entry.keys, entry);
36
+ this.commit();
37
+ }
38
+
39
+ /** Remove the shortcut bound to `keys` (no-op if absent). */
40
+ unregister(keys: string): void {
41
+ if (this.entries.delete(keys)) this.commit();
42
+ }
43
+
44
+ /** Every registered shortcut, in insertion order. Stable until the next mutation. */
45
+ getAll(): ShortcutEntry[] {
46
+ return this.snapshot;
47
+ }
48
+
49
+ /** Subscribe to registry changes (returns an unsubscribe). Powers the live palette list. */
50
+ subscribe(listener: Listener): () => void {
51
+ this.listeners.add(listener);
52
+ return () => this.listeners.delete(listener);
53
+ }
54
+
55
+ /** Drop every shortcut (mainly for tests / teardown). */
56
+ clear(): void {
57
+ if (this.entries.size === 0) return;
58
+ this.entries.clear();
59
+ this.commit();
60
+ }
61
+
62
+ private commit(): void {
63
+ this.snapshot = [...this.entries.values()];
64
+ for (const listener of this.listeners) listener();
65
+ }
66
+ }
67
+
68
+ /** The app-wide shortcut registry singleton. */
69
+ export const shortcutRegistry = new ShortcutRegistry();
@@ -0,0 +1,78 @@
1
+ // The command-palette ranking layer — uFuzzy over the nav catalogue. This is the
2
+ // *upgrade* HeaderSearch's `rankCatalogue` (a substring scorer) cannot match: uFuzzy
3
+ // scores by contiguous-match proximity, so abbreviated/typeahead input ranks the
4
+ // right page first. Pure + synchronous + React-free (uFuzzy is regex-based, no DOM),
5
+ // so it runs in the client, a server test, or the kitchen-sink demo identically.
6
+ //
7
+ // Why `intraMode: 1` (SingleError, contiguous) over `0` (MultiInsert, scattered):
8
+ // scattered mode treats every needle char as "appears somewhere later", which is far
9
+ // too permissive for short nav labels (every query matches half the menu). Contiguous
10
+ // mode keeps results tight — the documented tradeoff is lower typo tolerance, which is
11
+ // the right call for a fixed, known set of navigation labels.
12
+ //
13
+ // The catch: contiguous mode means a bare initialism ("qb") would NOT reach "Query
14
+ // Builder" (the chars aren't adjacent). So each haystack string is augmented with the
15
+ // label's **initials** as an explicit token ("Query Builder" → "query builder qb") —
16
+ // now "qb" matches that token contiguously, giving free initialism support without
17
+ // loosening the matcher. Keywords (synonyms / old names / abbreviations) are appended
18
+ // too, so "mandelbrot" still finds "Fractal".
19
+
20
+ import uFuzzy from '@leeoniya/ufuzzy';
21
+ import type { NavItem } from '../navigation';
22
+
23
+ // One singleton instance — uFuzzy compiles regexes from its options on construction,
24
+ // so re-creating it per query would be wasteful. `intraMode: 1` = contiguous match.
25
+ const uf = new uFuzzy({ intraMode: 1 });
26
+
27
+ /** Split a label into words on whitespace and the separators nav labels actually use. */
28
+ const WORD_SPLIT = /[\s/&·,_-]+/;
29
+
30
+ /**
31
+ * The initials of a multi-word label (`'Query Builder'` → `'qb'`). Single-word labels
32
+ * return `''` (their prefix already matches via the label itself). Powers initialism
33
+ * matching ("sh" → "System Health") under the strict contiguous matcher.
34
+ */
35
+ export function searchInitials(label: string): string {
36
+ const words = label.split(WORD_SPLIT).filter(Boolean);
37
+ if (words.length < 2) return '';
38
+ // Lower-cased for a predictable token; uFuzzy matches case-insensitively anyway.
39
+ return words.map((w) => (w[0] ?? '').toLowerCase()).join('');
40
+ }
41
+
42
+ /** Build the per-item haystack string: label + its initials + any keywords, space-joined. */
43
+ function haystackFor(item: NavItem): string {
44
+ return [item.label, searchInitials(item.label), ...(item.keywords ?? [])]
45
+ .filter(Boolean)
46
+ .join(' ');
47
+ }
48
+
49
+ /**
50
+ * Rank `items` against `query`, best match first; non-matches dropped. Generic over the
51
+ * item type (so callers can pass plain {@link NavItem}s *or* richer catalogue entries
52
+ * that carry domain/group tags for grouping — the extra fields ride straight through).
53
+ * An empty/blank query returns `[]` (the palette shows its cold state instead).
54
+ *
55
+ * Returns *all* matches in rank order; callers cap the count for display (the palette
56
+ * shows 30). uFuzzy's contiguous scorer means "frac" ranks "Fractal" above "Format
57
+ * Lab", and the initials augmentation means "qb" surfaces "Query Builder".
58
+ */
59
+ export function fuzzyRankItems<T extends NavItem>(query: string, items: T[]): T[] {
60
+ const q = query.trim();
61
+ if (!q || items.length === 0) return [];
62
+
63
+ const haystack = items.map(haystackFor);
64
+ const [idxs, , order] = uf.search(haystack, q);
65
+ // `idxs` is null when nothing matched and an empty array when filtered to zero; both
66
+ // mean "no results". `order` (the ranked iteration order into `idxs`) is present
67
+ // whenever there are matches under uFuzzy's info threshold (1e3 — far above any nav
68
+ // set); fall back to raw `idxs` order if it's ever absent.
69
+ if (!idxs || idxs.length === 0) return [];
70
+ const ranked = order ? order.map((o) => idxs[o]) : idxs;
71
+
72
+ const out: T[] = [];
73
+ for (const haystackIdx of ranked) {
74
+ const item = items[haystackIdx];
75
+ if (item) out.push(item);
76
+ }
77
+ return out;
78
+ }