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,388 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ CURRENT_NAV_PREFS_VERSION,
4
+ createCoverageTest,
5
+ deriveAllPaths,
6
+ deriveBreadcrumbTrail,
7
+ deriveDomainGroups,
8
+ deriveDomainItems,
9
+ deriveSearchCatalogue,
10
+ deriveSideNavDomains,
11
+ findCoverageGaps,
12
+ type GateFn,
13
+ groupCatalogueHits,
14
+ type LegacyNavPrefs,
15
+ migrateNavPreferences,
16
+ type NavGate,
17
+ type NavigationModel,
18
+ normalizeNavPath,
19
+ rankCatalogue,
20
+ } from './index';
21
+
22
+ // A representative slice of the ca IA: 6 domains + an Admin domain + ungrouped, with
23
+ // deepLinkOnly + gated + tab-target items — enough to exercise every selector.
24
+ const model: NavigationModel = {
25
+ domains: [
26
+ {
27
+ id: 'today',
28
+ label: 'Today',
29
+ icon: 'home',
30
+ path: '/',
31
+ groups: [
32
+ {
33
+ id: 'today-main',
34
+ label: 'At a glance',
35
+ items: [{ id: 'daily', path: '/daily', label: 'Daily' }],
36
+ },
37
+ ],
38
+ },
39
+ {
40
+ id: 'tools',
41
+ label: 'Tools',
42
+ icon: 'wrench',
43
+ path: '/tools',
44
+ groups: [
45
+ {
46
+ id: 'connectors',
47
+ label: 'Connectors',
48
+ items: [
49
+ { id: 'curl', path: '/tools#tab=curl', label: 'Curl', keywords: ['http', 'request'] },
50
+ { id: 'query', path: '/tools#tab=query', label: 'Query Builder' },
51
+ ],
52
+ },
53
+ {
54
+ id: 'utilities',
55
+ label: 'Utilities',
56
+ items: [
57
+ {
58
+ id: 'env-sets',
59
+ path: '/env-sets',
60
+ label: 'Env Sets',
61
+ keywords: ['environment', 'variables'],
62
+ },
63
+ { id: 'aliases', path: '/aliases', label: 'Shell Aliases' },
64
+ ],
65
+ },
66
+ ],
67
+ },
68
+ {
69
+ id: 'play',
70
+ label: 'Play',
71
+ icon: 'gamepad',
72
+ path: '/games',
73
+ groups: [
74
+ {
75
+ id: 'generative',
76
+ label: 'Generative',
77
+ items: [
78
+ { id: 'fractal', path: '/games/fractal', label: 'Fractal', keywords: ['mandelbrot'] },
79
+ { id: 'comet', path: '/games/comet', label: 'Comet' },
80
+ ],
81
+ },
82
+ {
83
+ id: 'media',
84
+ label: 'Media',
85
+ items: [{ id: 'youtube', path: '/youtube', label: 'YouTube' }],
86
+ },
87
+ ],
88
+ },
89
+ {
90
+ id: 'admin',
91
+ label: 'Admin',
92
+ icon: 'shield',
93
+ path: '/admin',
94
+ gate: { requiresAuth: true, requiredRoles: ['admin', 'owner'] },
95
+ groups: [
96
+ {
97
+ id: 'admin-access',
98
+ label: 'Users & Access',
99
+ items: [
100
+ { id: 'admin-users', path: '/admin/users', label: 'Users' },
101
+ {
102
+ id: 'admin-vault',
103
+ path: '/admin/vault',
104
+ label: 'Vault',
105
+ gate: { hiddenInCtgr: true },
106
+ },
107
+ ],
108
+ },
109
+ ],
110
+ },
111
+ ],
112
+ ungrouped: [
113
+ { id: 'settings', path: '/settings', label: 'Settings', keywords: ['preferences'] },
114
+ { id: 'shared-space', path: '/shared/:id', label: 'Shared Space', deepLinkOnly: true },
115
+ { id: 'set-pw', path: '/set-initial-password', label: 'Set Password', deepLinkOnly: true },
116
+ ],
117
+ };
118
+
119
+ const allowAll: GateFn = () => true;
120
+ // A signed-in, non-admin, ctgr-off gate.
121
+ const userGate: GateFn = (g?: NavGate) => {
122
+ if (g?.requiresNoAuth) return false;
123
+ if (g?.requiredRoles && !g.requiredRoles.includes('user')) return false;
124
+ return true;
125
+ };
126
+
127
+ describe('normalizeNavPath', () => {
128
+ test('strips hash, query, trailing slash; preserves root', () => {
129
+ expect(normalizeNavPath('/tools#tab=curl')).toBe('/tools');
130
+ expect(normalizeNavPath('/lists/?q=1')).toBe('/lists');
131
+ expect(normalizeNavPath('/')).toBe('/');
132
+ expect(normalizeNavPath('/admin/users')).toBe('/admin/users');
133
+ });
134
+ });
135
+
136
+ describe('deriveSideNavDomains', () => {
137
+ test('returns all domains under an allow-all gate', () => {
138
+ expect(deriveSideNavDomains(model, allowAll).map((d) => d.id)).toEqual([
139
+ 'today',
140
+ 'tools',
141
+ 'play',
142
+ 'admin',
143
+ ]);
144
+ });
145
+ test('drops role-gated domains the user cannot see', () => {
146
+ expect(deriveSideNavDomains(model, userGate).map((d) => d.id)).toEqual([
147
+ 'today',
148
+ 'tools',
149
+ 'play',
150
+ ]);
151
+ });
152
+ });
153
+
154
+ describe('deriveDomainItems / deriveDomainGroups', () => {
155
+ const play = model.domains.find((d) => d.id === 'play')!;
156
+ test('flattens visible items across groups', () => {
157
+ expect(deriveDomainItems(play, allowAll).map((i) => i.id)).toEqual([
158
+ 'fractal',
159
+ 'comet',
160
+ 'youtube',
161
+ ]);
162
+ });
163
+ test('groups keep their labels and only non-empty groups survive', () => {
164
+ const groups = deriveDomainGroups(play, allowAll);
165
+ expect(groups.map((g) => g.label)).toEqual(['Generative', 'Media']);
166
+ expect(groups[0]!.items.map((i) => i.id)).toEqual(['fractal', 'comet']);
167
+ });
168
+ test('a ctgr gate hides the flagged item', () => {
169
+ const admin = model.domains.find((d) => d.id === 'admin')!;
170
+ const ctgrGate: GateFn = (g?: NavGate) => !g?.hiddenInCtgr;
171
+ expect(deriveDomainItems(admin, ctgrGate).map((i) => i.id)).toEqual(['admin-users']);
172
+ });
173
+ });
174
+
175
+ describe('deriveBreadcrumbTrail', () => {
176
+ test('domain landing → [domain]', () => {
177
+ expect(deriveBreadcrumbTrail(model, '/tools').map((c) => c.label)).toEqual(['Tools']);
178
+ });
179
+ test('item → [domain, item] for ≥5 deep paths', () => {
180
+ expect(deriveBreadcrumbTrail(model, '/admin/users').map((c) => c.label)).toEqual([
181
+ 'Admin',
182
+ 'Users',
183
+ ]);
184
+ expect(deriveBreadcrumbTrail(model, '/games/fractal').map((c) => c.label)).toEqual([
185
+ 'Play',
186
+ 'Fractal',
187
+ ]);
188
+ expect(deriveBreadcrumbTrail(model, '/env-sets').map((c) => c.label)).toEqual([
189
+ 'Tools',
190
+ 'Env Sets',
191
+ ]);
192
+ expect(deriveBreadcrumbTrail(model, '/youtube').map((c) => c.label)).toEqual([
193
+ 'Play',
194
+ 'YouTube',
195
+ ]);
196
+ expect(deriveBreadcrumbTrail(model, '/daily').map((c) => c.label)).toEqual(['Today', 'Daily']);
197
+ });
198
+ test('a tab-target path resolves to its own leaf', () => {
199
+ expect(deriveBreadcrumbTrail(model, '/tools#tab=curl').map((c) => c.label)).toEqual([
200
+ 'Tools',
201
+ 'Curl',
202
+ ]);
203
+ });
204
+ test('ungrouped item → [item]; unknown → []', () => {
205
+ expect(deriveBreadcrumbTrail(model, '/settings').map((c) => c.label)).toEqual(['Settings']);
206
+ expect(deriveBreadcrumbTrail(model, '/nope')).toEqual([]);
207
+ });
208
+ });
209
+
210
+ describe('deriveSearchCatalogue + rankCatalogue', () => {
211
+ const catalogue = deriveSearchCatalogue(model, allowAll);
212
+
213
+ test('every visible domain + item is catalogued; deepLinkOnly is excluded', () => {
214
+ const ids = catalogue.map((e) => e.id);
215
+ expect(ids).toContain('fractal');
216
+ expect(ids).toContain('env-sets');
217
+ expect(ids).toContain('youtube');
218
+ expect(ids).toContain('settings');
219
+ // deepLinkOnly never surfaces in search
220
+ expect(ids).not.toContain('shared-space');
221
+ expect(ids).not.toContain('set-pw');
222
+ });
223
+
224
+ test('previously-buried features are now findable (parity check #3)', () => {
225
+ expect(rankCatalogue(catalogue, 'fractal')[0]?.path).toBe('/games/fractal');
226
+ expect(rankCatalogue(catalogue, 'env')[0]?.path).toBe('/env-sets');
227
+ expect(rankCatalogue(catalogue, 'youtube')[0]?.path).toBe('/youtube');
228
+ // keyword match
229
+ expect(rankCatalogue(catalogue, 'mandelbrot')[0]?.id).toBe('fractal');
230
+ });
231
+
232
+ test('exact label outranks substring; blank query → no hits', () => {
233
+ expect(rankCatalogue(catalogue, '')).toEqual([]);
234
+ const hits = rankCatalogue(catalogue, 'tools');
235
+ expect(hits[0]?.id).toBe('tools'); // the domain landing, exact label
236
+ });
237
+
238
+ test('groupCatalogueHits buckets by domain and caps to limit', () => {
239
+ const groups = groupCatalogueHits(catalogue, 'e', 3);
240
+ expect(groups.flatMap((g) => g.items).length).toBeLessThanOrEqual(3);
241
+ });
242
+
243
+ test('role-gated entries are absent under a non-admin gate', () => {
244
+ const userCat = deriveSearchCatalogue(model, userGate);
245
+ expect(userCat.map((e) => e.id)).not.toContain('admin-users');
246
+ });
247
+
248
+ test("a DOMAIN's description + keywords reach the catalogue, so a leaf-shaped rail is findable by synonym", () => {
249
+ // The asymmetry this closes: an app whose places are leaf domains (no
250
+ // sub-items) used to be searchable by exact label only, while an identical
251
+ // rail expressed as items matched synonyms — a data-model gap, not a ranking one.
252
+ const leafRail = deriveSearchCatalogue(
253
+ {
254
+ domains: [
255
+ {
256
+ id: 'changes',
257
+ label: 'Changes',
258
+ icon: 'git-compare-arrows',
259
+ path: '/changes',
260
+ description: 'Notes that changed at their source',
261
+ keywords: ['review', 'diff', 'merge'],
262
+ groups: [],
263
+ },
264
+ ],
265
+ },
266
+ allowAll,
267
+ );
268
+ expect(leafRail[0]?.description).toBe('Notes that changed at their source');
269
+ expect(rankCatalogue(leafRail, 'diff')[0]?.id).toBe('changes');
270
+ expect(rankCatalogue(leafRail, 'review')[0]?.id).toBe('changes');
271
+ });
272
+
273
+ test('a domain without them still catalogues cleanly (both stay optional)', () => {
274
+ const bare = deriveSearchCatalogue(
275
+ { domains: [{ id: 'x', label: 'X', icon: 'a', path: '/x', groups: [] }] },
276
+ allowAll,
277
+ );
278
+ expect(bare).toHaveLength(1);
279
+ expect('description' in (bare[0] ?? {})).toBe(false);
280
+ expect('keywords' in (bare[0] ?? {})).toBe(false);
281
+ });
282
+ });
283
+
284
+ describe('deriveAllPaths + createCoverageTest', () => {
285
+ test('all paths include deepLinkOnly entries', () => {
286
+ const paths = deriveAllPaths(model);
287
+ expect(paths).toContain('/shared/:id');
288
+ expect(paths).toContain('/set-initial-password');
289
+ expect(paths).toContain('/admin/users');
290
+ });
291
+
292
+ test('coverage passes when every route is registered', () => {
293
+ const routes = [
294
+ '/',
295
+ '/daily',
296
+ '/tools',
297
+ '/env-sets',
298
+ '/aliases',
299
+ '/games',
300
+ '/games/fractal',
301
+ '/games/comet',
302
+ '/youtube',
303
+ '/admin',
304
+ '/admin/users',
305
+ '/admin/vault',
306
+ '/settings',
307
+ '/shared/:id',
308
+ '/set-initial-password',
309
+ ];
310
+ expect(createCoverageTest(model, routes)).not.toThrow();
311
+ });
312
+
313
+ test('coverage FAILS when a route is missing from the model (negative — parity check #6)', () => {
314
+ const routes = ['/tools', '/brand-new-feature'];
315
+ expect(createCoverageTest(model, routes)).toThrow(/coverage gap/i);
316
+ expect(findCoverageGaps(model, routes)).toEqual(['/brand-new-feature']);
317
+ });
318
+
319
+ test('a tab-target model entry covers its base route', () => {
320
+ // The model only has /tools#tab=curl, but routes.tsx lists the base /tools route.
321
+ expect(findCoverageGaps(model, ['/tools'])).toEqual([]);
322
+ });
323
+ });
324
+
325
+ describe('migrateNavPreferences', () => {
326
+ // Old 8-hub world: planner + finance both consolidate into the new "plan" domain.
327
+ const legacy: LegacyNavPrefs = {
328
+ nav: {
329
+ order: ['planner', 'finance', 'games', 'youtube'],
330
+ colors: { games: '#f43f5e', finance: '#10b981' },
331
+ },
332
+ hiddenNavRoutes: ['youtube'],
333
+ hubs: {
334
+ planner: {
335
+ order: ['/calendar', '/alarms'],
336
+ hidden: ['/timeoff'],
337
+ colors: { '/calendar': '#3b82f6' },
338
+ },
339
+ finance: { order: ['/budgets'], colors: { '/budgets': '#f59e0b' } },
340
+ games: { order: ['/games/fractal'] },
341
+ },
342
+ };
343
+ const keyMap = {
344
+ planner: 'plan',
345
+ finance: 'plan',
346
+ games: 'play',
347
+ youtube: 'play',
348
+ };
349
+ // The migration target model (ids the legacy keys remap onto).
350
+ const target: NavigationModel = {
351
+ domains: [
352
+ { id: 'plan', label: 'Plan', icon: 'calendar', path: '/plan', groups: [] },
353
+ { id: 'play', label: 'Play', icon: 'gamepad', path: '/games', groups: [] },
354
+ ],
355
+ };
356
+
357
+ test('remaps 8 hubs → fewer domains, merging consolidated tile prefs (no data loss)', () => {
358
+ const next = migrateNavPreferences(legacy, target, { keyMap });
359
+ expect(next.version).toBe(CURRENT_NAV_PREFS_VERSION);
360
+ // order: planner+finance→plan (deduped), games+youtube→play
361
+ expect(next.order).toEqual(['plan', 'play']);
362
+ // hidden: youtube → play
363
+ expect(next.hidden).toEqual(['play']);
364
+ // colors: games→play, finance→plan
365
+ expect(next.colors).toEqual({ play: '#f43f5e', plan: '#10b981' });
366
+ // hubs: planner + finance MERGED into plan; games → play
367
+ expect(next.hubs?.plan?.order).toEqual(['/calendar', '/alarms', '/budgets']);
368
+ expect(next.hubs?.plan?.hidden).toEqual(['/timeoff']);
369
+ expect(next.hubs?.plan?.colors).toEqual({ '/calendar': '#3b82f6', '/budgets': '#f59e0b' });
370
+ expect(next.hubs?.play?.order).toEqual(['/games/fractal']);
371
+ });
372
+
373
+ test('drops keys whose target domain no longer exists', () => {
374
+ const next = migrateNavPreferences(
375
+ { nav: { order: ['gone'] }, hubs: { gone: { order: ['/x'] } } },
376
+ target,
377
+ { keyMap: { gone: 'nonexistent' } },
378
+ );
379
+ expect(next.order).toBeUndefined();
380
+ expect(next.hubs).toBeUndefined();
381
+ });
382
+
383
+ test('is idempotent — re-running on already-migrated prefs is a no-op', () => {
384
+ const once = migrateNavPreferences(legacy, target, { keyMap });
385
+ const twice = migrateNavPreferences(once, target, { keyMap });
386
+ expect(twice).toBe(once); // same reference, no re-mapping / no reset
387
+ });
388
+ });
@@ -0,0 +1,185 @@
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
+
14
+ /**
15
+ * Visibility / eligibility gate carried by a domain or item. The model only stores
16
+ * the *flags*; the consuming app injects a {@link GateFn} that decides what they
17
+ * mean (is the user authed, do they hold the role, is the GET-only "ctgr" mode on).
18
+ * Gates are inherited: an item inside a gated-out domain never surfaces.
19
+ */
20
+ export interface NavGate {
21
+ /** Only visible when signed in. */
22
+ requiresAuth?: boolean;
23
+ /** Only visible when signed out (public/marketing routes). */
24
+ requiresNoAuth?: boolean;
25
+ /** Visible only to users holding ONE of these roles (e.g. `['admin','owner']`). */
26
+ requiredRoles?: string[];
27
+ /** Hidden while the GET-only ("ctgr") session mode is active (write-heavy tools). */
28
+ hiddenInCtgr?: boolean;
29
+ }
30
+
31
+ /**
32
+ * A navigable leaf. `path` references a route (a `routeMeta` path in ca); use a
33
+ * `#tab=…` suffix for an in-page tab target (`/tools#tab=curl`) — the selectors
34
+ * strip the hash for route matching but keep it for navigation.
35
+ */
36
+ export interface NavItem {
37
+ /** Stable unique key — the React key AND the key prefs (order/hide/color) store by. */
38
+ id: string;
39
+ /** Route destination. `#tab=…` addresses an in-page tab. */
40
+ path: string;
41
+ /** Display label (side nav row, hub tile title, breadcrumb leaf, search hit). */
42
+ label: string;
43
+ /** Icon *name* from the app's icon set (resolved to a node by the React layer). */
44
+ icon?: string;
45
+ /** Short description — hub-tile subtitle + search snippet. */
46
+ description?: string;
47
+ /** Auth / role / ctgr gate (see {@link NavGate}). */
48
+ gate?: NavGate;
49
+ /**
50
+ * Registered for coverage + deep-link reachability, but **never surfaced** in the
51
+ * side nav, hub tiles, search, or palette. For routes reachable only by a shared
52
+ * link, a notification, or a WS invite (Shared Space, `/watch/:type/:id`, auth
53
+ * routes, `/set-initial-password`, admin deep-links). Use deliberately — it is not
54
+ * a way to dodge the coverage test for a real, discoverable feature.
55
+ */
56
+ deepLinkOnly?: true;
57
+ /** Extra match terms for search / palette (synonyms, old names, abbreviations). */
58
+ keywords?: string[];
59
+ }
60
+
61
+ /** A labeled cluster of items inside a domain (a sub-section of a hub landing). */
62
+ export interface NavGroup {
63
+ /** Stable unique key (within the model). */
64
+ id: string;
65
+ /** Section heading shown on the hub landing; omit-able visually when there's one. */
66
+ label: string;
67
+ items: NavItem[];
68
+ }
69
+
70
+ /**
71
+ * A top-level **place** — the browsable backbone (the UX north-star's small fixed
72
+ * place-set: Today / Library / People / Calendar / Play + a role-gated Operate; see
73
+ * `react/components/SHELL.md`). It is itself navigable (its `path` is the place
74
+ * landing) and owns the groups of items shown there. The shell renders these as the
75
+ * slim desktop place rail (`SideNav`) and the mobile capture-forward `PlaceBar`.
76
+ */
77
+ /**
78
+ * A place that exists in the IA but CANNOT do its job on the host currently
79
+ * serving the page.
80
+ *
81
+ * The worked example is the station's "Vault" row: the prod mirror offers it
82
+ * identically to the local one, and clicking it there reaches a machine that does
83
+ * not hold the data. The owner's ruling (`owner-uis-are-one-ui`, 2026-08-13) is
84
+ * that the answer is never a second UI and never a hidden item:
85
+ *
86
+ * *"Keeping two versions of UIs … is not a good long-term approach. I'd rather
87
+ * a button that literally can't work in prod just be disabled but in the same
88
+ * UI and with a message about why when I hover."*
89
+ *
90
+ * A hidden row teaches the reader the feature does not exist; a disabled row with
91
+ * a reason teaches them where to go. `UX_STANDARDS.md` §8.6 states it as a rule
92
+ * and no component carried it until unit 11.
93
+ *
94
+ * 🔴 "Too dangerous to expose" is NOT a host limit — Cloudflare Access already
95
+ * enforces owner-only at the edge. Only three things qualify: the destination
96
+ * points at the Mac, the bytes are not on this host, or it drives local
97
+ * hardware/the desktop.
98
+ */
99
+ export interface NavHostLimit {
100
+ /** Why it cannot work here, in a sentence the owner would write. Shown on hover. */
101
+ reason: string;
102
+ /** Where it DOES work, if anywhere — "Open it on the Mac at localhost:4747". */
103
+ worksAt?: string;
104
+ /** Still let it be clicked (it degrades rather than failing). Default `false`. */
105
+ navigable?: boolean;
106
+ }
107
+
108
+ export interface NavDomain {
109
+ /** Stable unique key — prefs (order/hide/color) for the side-nav rail store by it. */
110
+ id: string;
111
+ /** Display label (side-nav row + hub landing title + breadcrumb root). */
112
+ label: string;
113
+ /** Icon *name* (required — domains always show an icon in the rail). */
114
+ icon: string;
115
+ /** The hub landing page path. */
116
+ path: string;
117
+ /** Domain-wide gate — inherited by every group/item under it. */
118
+ gate?: NavGate;
119
+ /**
120
+ * Short description — the hub-tile subtitle and the search/palette snippet, the
121
+ * same role it plays on a {@link NavItem}.
122
+ */
123
+ description?: string;
124
+ /**
125
+ * Extra match terms for search / palette (synonyms, old names, abbreviations) —
126
+ * again the same as on a {@link NavItem}.
127
+ *
128
+ * Both of these exist because a domain IS a destination: `deriveSearchCatalogue`
129
+ * emits one catalogue entry per domain, so a rail of leaf-shaped domains (an app
130
+ * whose places have no sub-items) had no way to be found by synonym while an
131
+ * otherwise identical rail of items did. That asymmetry was in the data model,
132
+ * not in the ranker.
133
+ */
134
+ keywords?: string[];
135
+ /**
136
+ * Optional rail section label. When any domain carries one, `SideNav` renders the
137
+ * rail as labeled, collapsible groups instead of one flat list — the fix for the
138
+ * "wall of 20+ flat items" rail (owner UX teardown 2026-07-15 §2). Domains without
139
+ * a section render first, ungrouped. Section order follows first appearance.
140
+ */
141
+ section?: string;
142
+ /**
143
+ * Mark this place as belonging in the mobile `PlaceBar`'s **thumb zone** — the
144
+ * flat row of tabs a phone shows, as opposed to the "More" bottom-sheet behind it.
145
+ * When ANY domain in the model is pinned, the `PlaceBar` fills the thumb zone from
146
+ * the pinned set (in declaration order, up to its `max` budget) and overflows the
147
+ * *unpinned* places to More — so the important destinations survive onto a phone
148
+ * regardless of where they sit in the rail order.
149
+ *
150
+ * The default (no domain pinned) is the historical behavior: the first `max - 1`
151
+ * places by declaration order ride the bar and the rest go to More. Reach for a pin
152
+ * when an app has more places than fit AND the ones that matter on a phone are not
153
+ * the first few — e.g. an Inbox declared after Media/Prompts (owner UX report
154
+ * 2026-08-04: the Inbox fell into More, and it is a place the owner opens).
155
+ *
156
+ * `SideNav` (the desktop rail) ignores this — the rail shows every place.
157
+ */
158
+ mobilePinned?: boolean;
159
+ /** Declare that this place cannot do its job on THIS host. It stays visible and
160
+ * says why on hover, rather than vanishing. @see NavHostLimit */
161
+ hostLimit?: NavHostLimit;
162
+ groups: NavGroup[];
163
+ }
164
+
165
+ /**
166
+ * The complete IA tree. `domains` are the browsable backbone; `ungrouped` items are
167
+ * registered + searchable (and reachable by palette) but live under no domain hub —
168
+ * use it for cross-cutting destinations (Settings, Profile) or for `deepLinkOnly`
169
+ * routes that need coverage without a hub home.
170
+ */
171
+ export interface NavigationModel {
172
+ domains: NavDomain[];
173
+ ungrouped?: NavItem[];
174
+ }
175
+
176
+ /**
177
+ * Injected by the app: decides whether a gated node is visible *right now* (auth
178
+ * state, the user's roles, the ctgr GET-only mode). Pure — the selectors call it for
179
+ * every domain/item, so it must be cheap and side-effect-free. An undefined gate
180
+ * (no restrictions) is always visible.
181
+ */
182
+ export type GateFn = (gate?: NavGate) => boolean;
183
+
184
+ /** The trivial gate — everything is visible. Default for selectors + unguarded apps. */
185
+ export const allowAllGate: GateFn = () => true;