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,144 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ buildCategoryForest,
4
+ categoryPath,
5
+ createMemoryCategoryStore,
6
+ descendantCategoryIds,
7
+ flattenCategoryForest,
8
+ wouldCreateCycle,
9
+ type Category,
10
+ } from './index';
11
+
12
+ const cat = (id: string, parentId: string | null, label: string, order = 0): Category => ({
13
+ id,
14
+ parentId,
15
+ label,
16
+ order,
17
+ tenant: 't',
18
+ });
19
+
20
+ describe('buildCategoryForest', () => {
21
+ it('nests children under parents and sorts by order then label', () => {
22
+ const forest = buildCategoryForest([
23
+ cat('b', null, 'B', 1),
24
+ cat('a', null, 'A', 0),
25
+ cat('a2', 'a', 'Second', 1),
26
+ cat('a1', 'a', 'First', 0),
27
+ ]);
28
+ expect(forest.map((n) => n.id)).toEqual(['a', 'b']);
29
+ expect(forest[0]!.children.map((n) => n.id)).toEqual(['a1', 'a2']);
30
+ expect(forest[0]!.depth).toBe(0);
31
+ expect(forest[0]!.children[0]!.depth).toBe(1);
32
+ });
33
+
34
+ it('breaks ties on label when order is equal', () => {
35
+ const forest = buildCategoryForest([cat('z', null, 'Zebra', 0), cat('a', null, 'Apple', 0)]);
36
+ expect(forest.map((n) => n.label)).toEqual(['Apple', 'Zebra']);
37
+ });
38
+
39
+ it('treats orphans (missing parent) as roots without losing them', () => {
40
+ const forest = buildCategoryForest([cat('x', 'ghost', 'Orphan')]);
41
+ expect(forest.map((n) => n.id)).toEqual(['x']);
42
+ });
43
+
44
+ it('does not lose nodes or hang on a cyclic parent chain', () => {
45
+ // a -> b -> a is a pure cycle; both must still surface exactly once.
46
+ const forest = buildCategoryForest([cat('a', 'b', 'A'), cat('b', 'a', 'B')]);
47
+ const ids = flattenCategoryForest(forest).map((n) => n.id).sort();
48
+ expect(ids).toEqual(['a', 'b']);
49
+ });
50
+
51
+ it('emits every input category exactly once', () => {
52
+ const input = [cat('a', null, 'A'), cat('b', 'a', 'B'), cat('c', 'b', 'C'), cat('d', 'ghost', 'D')];
53
+ expect(flattenCategoryForest(buildCategoryForest(input)).length).toBe(input.length);
54
+ });
55
+ });
56
+
57
+ describe('categoryPath', () => {
58
+ const cats = [cat('a', null, 'A'), cat('b', 'a', 'B'), cat('c', 'b', 'C')];
59
+ it('returns the root→node breadcrumb', () => {
60
+ expect(categoryPath(cats, 'c').map((c) => c.id)).toEqual(['a', 'b', 'c']);
61
+ });
62
+ it('returns [] for an unknown id', () => {
63
+ expect(categoryPath(cats, 'nope')).toEqual([]);
64
+ });
65
+ it('is cycle-safe', () => {
66
+ const cyclic = [cat('a', 'b', 'A'), cat('b', 'a', 'B')];
67
+ expect(categoryPath(cyclic, 'a').length).toBeLessThanOrEqual(2);
68
+ });
69
+ });
70
+
71
+ describe('descendantCategoryIds', () => {
72
+ it('collects all descendants depth-first, excluding the root', () => {
73
+ const cats = [cat('a', null, 'A'), cat('b', 'a', 'B'), cat('c', 'b', 'C'), cat('d', 'a', 'D')];
74
+ expect(descendantCategoryIds(cats, 'a').sort()).toEqual(['b', 'c', 'd']);
75
+ expect(descendantCategoryIds(cats, 'b')).toEqual(['c']);
76
+ expect(descendantCategoryIds(cats, 'c')).toEqual([]);
77
+ });
78
+ });
79
+
80
+ describe('wouldCreateCycle', () => {
81
+ const cats = [cat('a', null, 'A'), cat('b', 'a', 'B'), cat('c', 'b', 'C')];
82
+ it('rejects self-parenting', () => {
83
+ expect(wouldCreateCycle(cats, 'a', 'a')).toBe(true);
84
+ });
85
+ it('rejects parenting under a descendant', () => {
86
+ expect(wouldCreateCycle(cats, 'a', 'c')).toBe(true);
87
+ });
88
+ it('allows a legal reparent and root moves', () => {
89
+ expect(wouldCreateCycle(cats, 'c', 'a')).toBe(false);
90
+ expect(wouldCreateCycle(cats, 'b', null)).toBe(false);
91
+ });
92
+ });
93
+
94
+ describe('createMemoryCategoryStore', () => {
95
+ it('creates with auto-incrementing sibling order and lists per tenant', () => {
96
+ let n = 0;
97
+ const store = createMemoryCategoryStore(() => `id${++n}`);
98
+ const a = store.create('t1', { label: 'A' });
99
+ const b = store.create('t1', { label: 'B' });
100
+ expect(a.order).toBe(0);
101
+ expect(b.order).toBe(1);
102
+ expect(store.list('t1').map((c) => c.id)).toEqual([a.id, b.id]);
103
+ expect(store.list('t2')).toEqual([]);
104
+ });
105
+
106
+ it('rejects creating under a missing parent', () => {
107
+ const store = createMemoryCategoryStore();
108
+ expect(() => store.create('t', { label: 'X', parentId: 'ghost' })).toThrow();
109
+ });
110
+
111
+ it('renames in place', () => {
112
+ const store = createMemoryCategoryStore();
113
+ const a = store.create('t', { label: 'A' });
114
+ const renamed = store.rename('t', a.id, 'Renamed');
115
+ expect(renamed.label).toBe('Renamed');
116
+ expect(store.list('t')[0]!.label).toBe('Renamed');
117
+ });
118
+
119
+ it('moves under a new parent but rejects cycles', () => {
120
+ const store = createMemoryCategoryStore();
121
+ const a = store.create('t', { label: 'A' });
122
+ const b = store.create('t', { label: 'B', parentId: a.id });
123
+ const moved = store.move('t', b.id, null);
124
+ expect(moved.parentId).toBeNull();
125
+ store.move('t', a.id, b.id); // legal now that b is a root
126
+ expect(() => store.move('t', b.id, a.id)).toThrow(); // b under a (its child) → cycle
127
+ });
128
+
129
+ it('promotes children to the removed category’s parent', () => {
130
+ const store = createMemoryCategoryStore();
131
+ const a = store.create('t', { label: 'A' });
132
+ const b = store.create('t', { label: 'B', parentId: a.id });
133
+ const c = store.create('t', { label: 'C', parentId: b.id });
134
+ store.remove('t', b.id);
135
+ const cats = store.list('t');
136
+ expect(cats.find((x) => x.id === c.id)!.parentId).toBe(a.id);
137
+ expect(cats.find((x) => x.id === b.id)).toBeUndefined();
138
+ });
139
+
140
+ it('remove is a no-op for an unknown id', () => {
141
+ const store = createMemoryCategoryStore();
142
+ expect(() => store.remove('t', 'ghost')).not.toThrow();
143
+ });
144
+ });
@@ -0,0 +1,19 @@
1
+ // src/core/category — reusable hierarchical category/folder model, reaching consumers
2
+ // through `cursedbelt/react`'s categoryTree. Pure and
3
+ // React-free (the `TreeView` adapter lives in `cursedbelt/react`), so servers
4
+ // can import the contract + store without pulling any UI runtime.
5
+
6
+ export type {
7
+ Category,
8
+ CategoryCreateInput,
9
+ CategoryNode,
10
+ CategoryStore,
11
+ } from './types';
12
+ export {
13
+ buildCategoryForest,
14
+ categoryPath,
15
+ descendantCategoryIds,
16
+ flattenCategoryForest,
17
+ wouldCreateCycle,
18
+ } from './tree';
19
+ export { createMemoryCategoryStore } from './memoryStore';
@@ -0,0 +1,104 @@
1
+ import type { Category, CategoryCreateInput, CategoryStore } from './types';
2
+ import { wouldCreateCycle } from './tree';
3
+
4
+ /**
5
+ * Zero-config in-memory {@link CategoryStore}: a per-boot `Map` per tenant, so a
6
+ * host that wires nothing up still gets a working (non-persistent) category
7
+ * tree — the same role `createMemoryNotesStore`/`createMemoryAuditSink` play.
8
+ * Swap in a persistent store (f's `createSqliteCategoryStore`) for real data.
9
+ *
10
+ * Deliberately not annotated `: CategoryStore` — the interface allows async
11
+ * returns, which would force callers (and these tests) to `await` values that
12
+ * are always synchronous here. Structural typing keeps it assignable wherever a
13
+ * `CategoryStore` is expected.
14
+ */
15
+ export function createMemoryCategoryStore(idFactory: () => string = () => crypto.randomUUID()) {
16
+ const byTenant = new Map<string, Map<string, Category>>();
17
+
18
+ const tenantMap = (tenant: string): Map<string, Category> => {
19
+ let map = byTenant.get(tenant);
20
+ if (!map) {
21
+ map = new Map();
22
+ byTenant.set(tenant, map);
23
+ }
24
+ return map;
25
+ };
26
+
27
+ const requireCategory = (map: Map<string, Category>, id: string): Category => {
28
+ const cat = map.get(id);
29
+ if (!cat) throw new Error(`no category with id ${id}`);
30
+ return cat;
31
+ };
32
+
33
+ /** Next order value at a level = max sibling order + 1 (append to the end). */
34
+ const nextOrder = (map: Map<string, Category>, parentId: string | null): number => {
35
+ let max = -1;
36
+ for (const c of map.values()) {
37
+ if (c.parentId === parentId && c.order > max) max = c.order;
38
+ }
39
+ return max + 1;
40
+ };
41
+
42
+ return {
43
+ list(tenant: string): Category[] {
44
+ return [...tenantMap(tenant).values()];
45
+ },
46
+
47
+ create(tenant: string, input: CategoryCreateInput): Category {
48
+ const map = tenantMap(tenant);
49
+ const parentId = input.parentId ?? null;
50
+ if (parentId !== null && !map.has(parentId)) {
51
+ throw new Error(`no parent category with id ${parentId}`);
52
+ }
53
+ const category: Category = {
54
+ id: idFactory(),
55
+ parentId,
56
+ label: input.label,
57
+ order: input.order ?? nextOrder(map, parentId),
58
+ tenant,
59
+ };
60
+ map.set(category.id, category);
61
+ return category;
62
+ },
63
+
64
+ rename(tenant: string, id: string, label: string): Category {
65
+ const map = tenantMap(tenant);
66
+ const cat = requireCategory(map, id);
67
+ const updated = { ...cat, label };
68
+ map.set(id, updated);
69
+ return updated;
70
+ },
71
+
72
+ move(tenant: string, id: string, parentId: string | null, order?: number): Category {
73
+ const map = tenantMap(tenant);
74
+ const cat = requireCategory(map, id);
75
+ if (parentId !== null && !map.has(parentId)) {
76
+ throw new Error(`no parent category with id ${parentId}`);
77
+ }
78
+ if (wouldCreateCycle([...map.values()], id, parentId)) {
79
+ throw new Error(`moving ${id} under ${parentId} would create a cycle`);
80
+ }
81
+ const updated: Category = {
82
+ ...cat,
83
+ parentId,
84
+ order: order ?? nextOrder(map, parentId),
85
+ };
86
+ map.set(id, updated);
87
+ return updated;
88
+ },
89
+
90
+ /** Remove a category, promoting its direct children to the removed node's
91
+ * own parent so no subtree is orphaned. */
92
+ remove(tenant: string, id: string): void {
93
+ const map = tenantMap(tenant);
94
+ const cat = map.get(id);
95
+ if (!cat) return;
96
+ for (const child of map.values()) {
97
+ if (child.parentId === id) {
98
+ map.set(child.id, { ...child, parentId: cat.parentId });
99
+ }
100
+ }
101
+ map.delete(id);
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,118 @@
1
+ import type { Category, CategoryNode } from './types';
2
+
3
+ /** Sort siblings by explicit `order`, then label (locale-aware, stable). */
4
+ function compareSiblings(a: Category, b: Category): number {
5
+ return a.order - b.order || a.label.localeCompare(b.label);
6
+ }
7
+
8
+ /**
9
+ * Would setting `childId`'s parent to `candidateParentId` create a cycle?
10
+ * True when the candidate parent is the child itself or one of its
11
+ * descendants — i.e. walking UP from the candidate reaches the child.
12
+ */
13
+ export function wouldCreateCycle(
14
+ cats: Category[],
15
+ childId: string,
16
+ candidateParentId: string | null,
17
+ ): boolean {
18
+ if (candidateParentId === null) return false;
19
+ if (candidateParentId === childId) return true;
20
+ const byId = new Map(cats.map((c) => [c.id, c]));
21
+ let cursor: string | null = candidateParentId;
22
+ const seen = new Set<string>();
23
+ while (cursor !== null) {
24
+ if (cursor === childId) return true;
25
+ if (seen.has(cursor)) break; // pre-existing cycle in the data — stop, don't hang
26
+ seen.add(cursor);
27
+ cursor = byId.get(cursor)?.parentId ?? null;
28
+ }
29
+ return false;
30
+ }
31
+
32
+ /**
33
+ * Assemble a flat category list into a sorted forest of {@link CategoryNode}s.
34
+ * Defensive by construction — a category is treated as a root when its
35
+ * `parentId` is `null`, points at a missing category (orphan), or would form a
36
+ * cycle. So malformed data never loses a node or infinite-loops; every input
37
+ * category appears exactly once in the output.
38
+ */
39
+ export function buildCategoryForest(cats: Category[]): CategoryNode[] {
40
+ const byId = new Map<string, CategoryNode>(
41
+ cats.map((c) => [c.id, { ...c, children: [], depth: 0 }]),
42
+ );
43
+ const roots: CategoryNode[] = [];
44
+ for (const node of byId.values()) {
45
+ const parent = node.parentId !== null ? byId.get(node.parentId) : undefined;
46
+ if (parent && !wouldCreateCycle(cats, node.id, node.parentId)) {
47
+ parent.children.push(node);
48
+ } else {
49
+ roots.push(node);
50
+ }
51
+ }
52
+ const sortLevel = (level: CategoryNode[], depth: number): void => {
53
+ level.sort(compareSiblings);
54
+ for (const node of level) {
55
+ node.depth = depth;
56
+ sortLevel(node.children, depth + 1);
57
+ }
58
+ };
59
+ sortLevel(roots, 0);
60
+ return roots;
61
+ }
62
+
63
+ /** Flatten a forest into pre-order (parent before its children), preserving the
64
+ * sibling ordering {@link buildCategoryForest} established. */
65
+ export function flattenCategoryForest(forest: CategoryNode[]): CategoryNode[] {
66
+ const out: CategoryNode[] = [];
67
+ const visit = (nodes: CategoryNode[]): void => {
68
+ for (const node of nodes) {
69
+ out.push(node);
70
+ visit(node.children);
71
+ }
72
+ };
73
+ visit(forest);
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * The root→node breadcrumb path for `id` (inclusive), or `[]` when the id is
79
+ * unknown. Cycle-safe — stops if the parent chain ever revisits a node.
80
+ */
81
+ export function categoryPath(cats: Category[], id: string): Category[] {
82
+ const byId = new Map(cats.map((c) => [c.id, c]));
83
+ const trail: Category[] = [];
84
+ const seen = new Set<string>();
85
+ let cursor: string | null = id;
86
+ while (cursor !== null) {
87
+ const cat = byId.get(cursor);
88
+ if (!cat || seen.has(cursor)) break;
89
+ seen.add(cursor);
90
+ trail.push(cat);
91
+ cursor = cat.parentId;
92
+ }
93
+ return trail.reverse();
94
+ }
95
+
96
+ /** Every descendant id of `id` (excluding `id` itself), depth-first. Used by a
97
+ * store's `remove`/`move` to reason about a subtree. */
98
+ export function descendantCategoryIds(cats: Category[], id: string): string[] {
99
+ const childrenByParent = new Map<string, Category[]>();
100
+ for (const c of cats) {
101
+ if (c.parentId === null) continue;
102
+ const list = childrenByParent.get(c.parentId) ?? [];
103
+ list.push(c);
104
+ childrenByParent.set(c.parentId, list);
105
+ }
106
+ const out: string[] = [];
107
+ const seen = new Set<string>([id]);
108
+ const visit = (parentId: string): void => {
109
+ for (const child of childrenByParent.get(parentId) ?? []) {
110
+ if (seen.has(child.id)) continue; // cycle guard
111
+ seen.add(child.id);
112
+ out.push(child.id);
113
+ visit(child.id);
114
+ }
115
+ };
116
+ visit(id);
117
+ return out;
118
+ }
@@ -0,0 +1,59 @@
1
+ // src/core/category — a reusable, hierarchical category/folder model shared
2
+ // by content plugins (notes today; snippets/aliases/… later). React-free and
3
+ // storage-agnostic: the pure `Category` data + a host-injected `CategoryStore`
4
+ // contract, mirroring how `cwip`'s `AuditSink` and cursedshell's `NotesStore`
5
+ // ship a pure contract that a host backs with its own (SQLite) implementation.
6
+ // The cursedbelt `TreeView` adapter lives in the React layer, so importing this
7
+ // module on a server (e.g. f's covert-GET backend) never pulls React.
8
+
9
+ /** One node in a category tree. Roots have `parentId === null`. `order` sorts
10
+ * siblings ascending; `tenant` scopes every category to its owner so two
11
+ * tenants' trees never collide. */
12
+ export interface Category {
13
+ id: string;
14
+ parentId: string | null;
15
+ label: string;
16
+ order: number;
17
+ tenant: string;
18
+ }
19
+
20
+ /** Fields a caller supplies when creating a category — the store assigns `id`,
21
+ * `tenant` (from the call), and a default `order` when omitted. */
22
+ export interface CategoryCreateInput {
23
+ label: string;
24
+ parentId?: string | null;
25
+ order?: number;
26
+ }
27
+
28
+ /**
29
+ * A `Category` assembled into a tree by {@link buildCategoryForest} — its
30
+ * resolved `children` plus a `depth` (0 at the root) for indentation without a
31
+ * second traversal.
32
+ */
33
+ export interface CategoryNode extends Category {
34
+ children: CategoryNode[];
35
+ depth: number;
36
+ }
37
+
38
+ /**
39
+ * Storage-agnostic, tenant-scoped category store. The host injects a real
40
+ * implementation (f backs it with SQLite); {@link createMemoryCategoryStore}
41
+ * is the zero-config in-memory default. Every method is tenant-first, matching
42
+ * the `NotesStore`/`AliasStore` contracts so a host can resolve the tenant to a
43
+ * per-role DB handle identically.
44
+ *
45
+ * `remove` promotes a removed category's direct children to its own parent
46
+ * (they are never silently dropped); a host that also stores per-item
47
+ * `categoryId`s is responsible for re-pointing those items (to the parent or
48
+ * `null`) in the same transaction.
49
+ */
50
+ export interface CategoryStore {
51
+ list(tenant: string): Category[] | Promise<Category[]>;
52
+ create(tenant: string, input: CategoryCreateInput): Category | Promise<Category>;
53
+ rename(tenant: string, id: string, label: string): Category | Promise<Category>;
54
+ /** Reparent (and optionally reorder) a category. Passing `parentId` equal to
55
+ * `id` or to one of the category's own descendants is rejected — it would
56
+ * orphan a subtree by creating a cycle. */
57
+ move(tenant: string, id: string, parentId: string | null, order?: number): Category | Promise<Category>;
58
+ remove(tenant: string, id: string): void | Promise<void>;
59
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { contrastText } from './color';
3
+
4
+ describe('contrastText', () => {
5
+ it('returns white on dark backgrounds', () => {
6
+ expect(contrastText('#000000')).toBe('white');
7
+ expect(contrastText('#111827')).toBe('white'); // gray-900
8
+ expect(contrastText('#1f2937')).toBe('white'); // slate-800
9
+ expect(contrastText('#7c3aed')).toBe('white'); // violet-600
10
+ expect(contrastText('#dc2626')).toBe('white'); // red-600
11
+ });
12
+
13
+ it('returns black on light backgrounds', () => {
14
+ expect(contrastText('#ffffff')).toBe('black');
15
+ expect(contrastText('#f59e0b')).toBe('black'); // amber-500
16
+ expect(contrastText('#facc15')).toBe('black'); // yellow-400
17
+ expect(contrastText('#22c55e')).toBe('black'); // green-500
18
+ // Mid-tones where WCAG luminance (unlike the old YIQ@0.6 formula) correctly
19
+ // prefers black for a higher contrast ratio.
20
+ expect(contrastText('#3b82f6')).toBe('black'); // blue-500
21
+ expect(contrastText('#a855f7')).toBe('black'); // purple-500
22
+ });
23
+
24
+ it('accepts hex without a leading # and 3-digit shorthand', () => {
25
+ expect(contrastText('000')).toBe('white');
26
+ expect(contrastText('fff')).toBe('black');
27
+ expect(contrastText('1f2937')).toBe('white');
28
+ });
29
+
30
+ it('falls back to white for unparseable input', () => {
31
+ expect(contrastText('')).toBe('white');
32
+ expect(contrastText('not-a-color')).toBe('white');
33
+ expect(contrastText('#12')).toBe('white');
34
+ });
35
+ });
@@ -0,0 +1,35 @@
1
+ // Framework-agnostic color helpers. Pure functions over hex strings — no React,
2
+ // no DOM — so they're usable in the apps, on the server, and in tests.
3
+
4
+ /**
5
+ * Pick the readable foreground keyword (`'black'` or `'white'`) for text on a solid
6
+ * hex background, using the WCAG relative-luminance formula (sRGB gamma-expanded).
7
+ *
8
+ * Returns CSS color keywords (not hex) so a call site can drop the result straight
9
+ * into a `color:` style. Accepts 3- or 6-digit hex, with or without a leading `#`;
10
+ * an unparseable input falls back to `'white'` (legible on most accent colors).
11
+ *
12
+ * This is the canonical contrast primitive. The nav toolkit's `readableTextOn`
13
+ * (`react/nav/colors.ts`) and the spreadsheet theme's `contrastOn` deliberately
14
+ * return tuned near-black/near-white *hex* with their own thresholds for those
15
+ * surfaces; this returns pure keywords on the standard WCAG threshold.
16
+ */
17
+ export function contrastText(hex: string): 'black' | 'white' {
18
+ const c = hex.replace('#', '').trim();
19
+ const full =
20
+ c.length === 3
21
+ ? c
22
+ .split('')
23
+ .map((ch) => ch + ch)
24
+ .join('')
25
+ : c;
26
+ if (!/^[0-9a-f]{6}$/i.test(full)) return 'white';
27
+ const r = Number.parseInt(full.slice(0, 2), 16) / 255;
28
+ const g = Number.parseInt(full.slice(2, 4), 16) / 255;
29
+ const b = Number.parseInt(full.slice(4, 6), 16) / 255;
30
+ const [rs, gs, bs] = [r, g, b].map((v) =>
31
+ v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4,
32
+ );
33
+ const luminance = 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
34
+ return luminance > 0.179 ? 'black' : 'white';
35
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ COLOR_SCHEME_STORAGE_KEY,
4
+ type ColorSchemeStorage,
5
+ createColorSchemeStore,
6
+ type ResolvedColorScheme,
7
+ } from './index';
8
+
9
+ const makeStorage = (): ColorSchemeStorage & { map: Map<string, string> } => {
10
+ const map = new Map<string, string>();
11
+ return {
12
+ map,
13
+ getItem: (k) => map.get(k) ?? null,
14
+ setItem: (k, v) => void map.set(k, v),
15
+ removeItem: (k) => void map.delete(k),
16
+ };
17
+ };
18
+
19
+ describe('color-scheme store (injected side-effects)', () => {
20
+ test('defaults to system mode resolving through the OS preference', () => {
21
+ const store = createColorSchemeStore({ getSystemScheme: () => 'dark' });
22
+ expect(store.getMode()).toBe('system');
23
+ expect(store.getResolved()).toBe('dark');
24
+ });
25
+
26
+ test('hydrates an explicit stored choice; garbage in storage → system', () => {
27
+ const storage = makeStorage();
28
+ storage.map.set(COLOR_SCHEME_STORAGE_KEY, 'dark');
29
+ expect(createColorSchemeStore({ storage }).getResolved()).toBe('dark');
30
+
31
+ const garbage = makeStorage();
32
+ garbage.map.set(COLOR_SCHEME_STORAGE_KEY, 'blurple');
33
+ expect(createColorSchemeStore({ storage: garbage }).getMode()).toBe('system');
34
+ });
35
+
36
+ test('setMode persists + applies + notifies; system clears the override', () => {
37
+ const storage = makeStorage();
38
+ const applied: ResolvedColorScheme[] = [];
39
+ const store = createColorSchemeStore({ storage, apply: (s) => applied.push(s) });
40
+ let notifications = 0;
41
+ const unsub = store.subscribe(() => notifications++);
42
+
43
+ store.setMode('dark');
44
+ expect(storage.getItem(COLOR_SCHEME_STORAGE_KEY)).toBe('dark');
45
+ expect(applied).toEqual(['dark']);
46
+ expect(notifications).toBe(1);
47
+
48
+ store.setMode('system');
49
+ expect(storage.getItem(COLOR_SCHEME_STORAGE_KEY)).toBeNull();
50
+ expect(applied).toEqual(['dark', 'light']);
51
+ unsub();
52
+ });
53
+
54
+ test('toggle flips the RESOLVED scheme and stores it explicitly', () => {
55
+ const storage = makeStorage();
56
+ const store = createColorSchemeStore({ storage, getSystemScheme: () => 'dark' });
57
+ expect(store.getResolved()).toBe('dark'); // system → OS dark
58
+ store.toggle();
59
+ expect(store.getMode()).toBe('light'); // explicit now
60
+ expect(storage.getItem(COLOR_SCHEME_STORAGE_KEY)).toBe('light');
61
+ store.toggle();
62
+ expect(store.getMode()).toBe('dark');
63
+ });
64
+
65
+ test('init applies the resolved scheme and follows OS changes only in system mode', () => {
66
+ let system: ResolvedColorScheme = 'light';
67
+ let systemListener: (() => void) | undefined;
68
+ const applied: ResolvedColorScheme[] = [];
69
+ const store = createColorSchemeStore({
70
+ storage: makeStorage(),
71
+ apply: (s) => applied.push(s),
72
+ getSystemScheme: () => system,
73
+ onSystemChange: (l) => {
74
+ systemListener = l;
75
+ return () => {
76
+ systemListener = undefined;
77
+ };
78
+ },
79
+ });
80
+
81
+ store.init();
82
+ expect(applied).toEqual(['light']);
83
+
84
+ // OS flips while in system mode → re-applied.
85
+ system = 'dark';
86
+ systemListener?.();
87
+ expect(applied).toEqual(['light', 'dark']);
88
+
89
+ // Explicit choice pins the scheme; OS flips are ignored.
90
+ store.setMode('light');
91
+ system = 'light';
92
+ systemListener?.();
93
+ expect(applied).toEqual(['light', 'dark', 'light']);
94
+ });
95
+
96
+ test('an out-of-band class change (devtools, a screenshot harness) drops the cached mode and re-notifies', () => {
97
+ // Simulates cursedshell's screenshot-engine: it sets localStorage AND toggles
98
+ // the `.dark` class directly via CDP, bypassing setMode()/commit() entirely —
99
+ // so the store must re-hydrate from storage rather than trust its stale cache.
100
+ const storage = makeStorage();
101
+ let externalListener: (() => void) | undefined;
102
+ const store = createColorSchemeStore({
103
+ storage,
104
+ getSystemScheme: () => 'light',
105
+ onExternalChange: (l) => {
106
+ externalListener = l;
107
+ return () => {
108
+ externalListener = undefined;
109
+ };
110
+ },
111
+ });
112
+
113
+ expect(store.getResolved()).toBe('light'); // hydrates + caches 'system' → light
114
+ store.init();
115
+
116
+ // Something outside the store flips storage + the DOM class without calling setMode.
117
+ storage.map.set(COLOR_SCHEME_STORAGE_KEY, 'dark');
118
+ let notifications = 0;
119
+ const unsub = store.subscribe(() => notifications++);
120
+ externalListener?.();
121
+
122
+ expect(notifications).toBe(1);
123
+ expect(store.getMode()).toBe('dark'); // re-hydrated from storage, not the stale cache
124
+ expect(store.getResolved()).toBe('dark');
125
+ unsub();
126
+ });
127
+ });