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,244 @@
1
+ /**
2
+ * The folder TREE model — pure, React-free, and the single source of truth for
3
+ * "given the folder paths the items carry, what is the tree, and what does a
4
+ * rename / move / delete DO to those paths".
5
+ *
6
+ * The consumer owns the items and their storage; this module never touches an item.
7
+ * A structural edit is expressed as a set of {@link FolderRemap}s — `from`→`to`
8
+ * serialized-path rewrites the consumer applies to every item whose folder matches
9
+ * `from`. That keeps the tree generic (it works the same for the vault's encrypted
10
+ * entries and for notes' groups) and keeps the dangerous part — rewriting stored
11
+ * items — a small, testable list of string swaps.
12
+ */
13
+
14
+ import {
15
+ folderPathKey,
16
+ isFolderPathUnder,
17
+ normalizeFolderName,
18
+ parseFolderPath,
19
+ serializeFolderPath,
20
+ } from "./path";
21
+
22
+ /** A node in the derived tree. `path` is the full segment array from the root; a
23
+ * `child` node's path is its parent's plus one segment. */
24
+ export interface FolderNode {
25
+ /** The leaf name of this node (its last path segment). */
26
+ name: string;
27
+ /** Full path from the root, e.g. `["Home","Phones"]`. */
28
+ path: string[];
29
+ /** Serialized full path — the value an item at THIS folder stores. */
30
+ serialized: string;
31
+ /** Case-insensitive identity key for the full path. */
32
+ key: string;
33
+ /** Depth from the root (a root folder is 0). */
34
+ depth: number;
35
+ /** Child folders, sorted case-insensitively by name. */
36
+ children: FolderNode[];
37
+ }
38
+
39
+ /**
40
+ * Build the nested tree from a flat collection of serialized folder-path strings
41
+ * (one per item, duplicates fine). EVERY prefix becomes a node, so a tree with only
42
+ * `Home/Phones/Noah` still surfaces `Home` and `Home/Phones` as ancestors. Names
43
+ * dedupe case-insensitively, first spelling wins; siblings sort by name.
44
+ */
45
+ export function buildFolderTree(serializedPaths: Iterable<string>): FolderNode[] {
46
+ interface Building {
47
+ name: string;
48
+ path: string[];
49
+ children: Map<string, Building>;
50
+ }
51
+ const roots = new Map<string, Building>();
52
+
53
+ for (const serialized of serializedPaths) {
54
+ const segments = parseFolderPath(serialized);
55
+ if (segments.length === 0) continue;
56
+ let level = roots;
57
+ const acc: string[] = [];
58
+ for (const seg of segments) {
59
+ acc.push(seg);
60
+ const k = normalizeFolderName(seg).toLowerCase();
61
+ let node = level.get(k);
62
+ if (!node) {
63
+ node = { name: seg, path: [...acc], children: new Map() };
64
+ level.set(k, node);
65
+ }
66
+ level = node.children;
67
+ }
68
+ }
69
+
70
+ const toNode = (b: Building, depth: number): FolderNode => ({
71
+ name: b.name,
72
+ path: b.path,
73
+ serialized: serializeFolderPath(b.path),
74
+ key: folderPathKey(b.path),
75
+ depth,
76
+ children: sortNodes([...b.children.values()].map((c) => toNode(c, depth + 1))),
77
+ });
78
+
79
+ return sortNodes([...roots.values()].map((b) => toNode(b, 0)));
80
+ }
81
+
82
+ /** The derived tree plus a CUMULATIVE per-folder count — how many of the supplied
83
+ * paths sit at-or-under each folder. */
84
+ export interface FolderIndex {
85
+ tree: FolderNode[];
86
+ /** Items at-or-under the folder with this serialized path. Unknown folder → 0. */
87
+ count: (serialized: string) => number;
88
+ }
89
+
90
+ /**
91
+ * {@link buildFolderTree} plus counts, in ONE pass over the paths.
92
+ *
93
+ * Counts are cumulative: an item in `Home/Phones/Noah` counts for `Home`,
94
+ * `Home/Phones` AND `Home/Phones/Noah`, so a collapsed parent still shows a
95
+ * meaningful total rather than the zero its own direct membership would report.
96
+ * That is the number a rail wants next to a folder, and every consumer wants the
97
+ * same one — it was written once in the vault and lifted here when notes needed
98
+ * it, rather than counted twice with two chances to disagree about whether a
99
+ * parent includes its children.
100
+ */
101
+ export function buildFolderIndex(serializedPaths: Iterable<string>): FolderIndex {
102
+ const paths: string[] = [];
103
+ const counts = new Map<string, number>();
104
+ for (const serialized of serializedPaths) {
105
+ const segments = parseFolderPath(serialized);
106
+ if (segments.length === 0) continue;
107
+ paths.push(serializeFolderPath(segments));
108
+ for (let i = 1; i <= segments.length; i++) {
109
+ const key = folderPathKey(segments.slice(0, i));
110
+ counts.set(key, (counts.get(key) ?? 0) + 1);
111
+ }
112
+ }
113
+ return {
114
+ tree: buildFolderTree(paths),
115
+ count: (serialized) => counts.get(folderPathKey(parseFolderPath(serialized))) ?? 0,
116
+ };
117
+ }
118
+
119
+ function sortNodes(nodes: FolderNode[]): FolderNode[] {
120
+ return nodes.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
121
+ }
122
+
123
+ /** Depth-first flatten (parent before children) — the order a picker lists options
124
+ * and a rail renders rows. */
125
+ export function flattenFolderTree(nodes: readonly FolderNode[]): FolderNode[] {
126
+ const out: FolderNode[] = [];
127
+ const walk = (list: readonly FolderNode[]) => {
128
+ for (const n of list) {
129
+ out.push(n);
130
+ walk(n.children);
131
+ }
132
+ };
133
+ walk(nodes);
134
+ return out;
135
+ }
136
+
137
+ /** A single stored-path rewrite. `to === ""` means the item drops to the root
138
+ * (uncategorized). Consumers apply one to every item whose serialized folder path
139
+ * equals `from`. */
140
+ export interface FolderRemap {
141
+ from: string;
142
+ to: string;
143
+ }
144
+
145
+ /** The distinct set of serialized paths that actually appear on items (parents that
146
+ * exist only as ancestors are not stored on any item, so they never need a remap).
147
+ * Deduped by identity key. */
148
+ function distinctStoredPaths(serializedPaths: Iterable<string>): string[][] {
149
+ const seen = new Map<string, string[]>();
150
+ for (const s of serializedPaths) {
151
+ const segs = parseFolderPath(s);
152
+ if (segs.length === 0) continue;
153
+ const k = folderPathKey(segs);
154
+ if (!seen.has(k)) seen.set(k, segs);
155
+ }
156
+ return [...seen.values()];
157
+ }
158
+
159
+ /** Rebuild a stored path by swapping its first `prefixLen` segments for `replacement`
160
+ * (which may be empty, re-homing to the root). */
161
+ function replacePrefix(path: string[], prefixLen: number, replacement: string[]): string[] {
162
+ return [...replacement, ...path.slice(prefixLen)];
163
+ }
164
+
165
+ /**
166
+ * Rename the folder at `target` to `newName`, keeping it under the same parent. Every
167
+ * item at `target` OR beneath it is remapped so descendants move with it. Returns the
168
+ * `from`→`to` rewrites the consumer applies; an empty list means nothing to change.
169
+ * Throws on an invalid/blank `newName`.
170
+ */
171
+ export function planFolderRename(
172
+ serializedPaths: Iterable<string>,
173
+ target: readonly string[],
174
+ newName: string,
175
+ ): FolderRemap[] {
176
+ const name = normalizeFolderName(newName);
177
+ if (name.length === 0) throw new Error("a folder name cannot be blank");
178
+ if (target.length === 0) throw new Error("cannot rename the root");
179
+ const renamedPrefix = [...target.slice(0, -1), name];
180
+ return remapUnder(serializedPaths, target, renamedPrefix);
181
+ }
182
+
183
+ /**
184
+ * Move the folder at `source` under `newParent` (an empty array = the root),
185
+ * carrying its descendants. Refuses to move a folder into itself or a descendant
186
+ * (which would orphan the subtree), and to move a folder to where it already is.
187
+ * Returns the rewrites to apply.
188
+ */
189
+ export function planFolderMove(
190
+ serializedPaths: Iterable<string>,
191
+ source: readonly string[],
192
+ newParent: readonly string[],
193
+ ): FolderRemap[] {
194
+ if (source.length === 0) throw new Error("cannot move the root");
195
+ if (isFolderPathUnder(newParent, source)) {
196
+ throw new Error("cannot move a folder into itself or one of its descendants");
197
+ }
198
+ const movedPrefix = [...newParent, source[source.length - 1] as string];
199
+ if (folderPathKey(movedPrefix) === folderPathKey(source)) return [];
200
+ return remapUnder(serializedPaths, source, movedPrefix);
201
+ }
202
+
203
+ /**
204
+ * Delete the folder at `target` by SPLICING it out: every item at or beneath it is
205
+ * re-homed to `target`'s parent, preserving the relative sub-structure. An item
206
+ * directly in `target` moves to the parent (or the root, if `target` was a root);
207
+ * an item in `target/child` moves to `parent/child`. Nothing is deleted from the
208
+ * consumer's store — items keep their secrets, they just lose one path segment.
209
+ */
210
+ export function planFolderDelete(
211
+ serializedPaths: Iterable<string>,
212
+ target: readonly string[],
213
+ ): FolderRemap[] {
214
+ if (target.length === 0) throw new Error("cannot delete the root");
215
+ const parent = target.slice(0, -1);
216
+ const out: FolderRemap[] = [];
217
+ for (const path of distinctStoredPaths(serializedPaths)) {
218
+ if (!isFolderPathUnder(path, target)) continue;
219
+ // Drop the target segment: keep the parent, then everything below target.
220
+ const spliced = [...parent, ...path.slice(target.length)];
221
+ const from = serializeFolderPath(path);
222
+ const to = serializeFolderPath(spliced);
223
+ if (from !== to) out.push({ from, to });
224
+ }
225
+ return out;
226
+ }
227
+
228
+ /** Shared core for rename/move: replace the `target`-length prefix of every matching
229
+ * stored path with `replacement`. */
230
+ function remapUnder(
231
+ serializedPaths: Iterable<string>,
232
+ target: readonly string[],
233
+ replacement: string[],
234
+ ): FolderRemap[] {
235
+ const out: FolderRemap[] = [];
236
+ for (const path of distinctStoredPaths(serializedPaths)) {
237
+ if (!isFolderPathUnder(path, target)) continue;
238
+ const rebuilt = replacePrefix(path, target.length, replacement);
239
+ const from = serializeFolderPath(path);
240
+ const to = serializeFolderPath(rebuilt);
241
+ if (from !== to) out.push({ from, to });
242
+ }
243
+ return out;
244
+ }
@@ -0,0 +1,7 @@
1
+ // src/core/layout — re-export of `cwip/layout`, the single source of truth for the
2
+ // framework-agnostic layout/widget engine core (v2 node model + idempotent migration,
3
+ // pure treeOps, aggregates, container-query grid/style class maps, binding resolution,
4
+ // undo/redo history, wire version). cursedbelt's copy was absorbed into cwip
5
+ // (feat/layout-absorb) — add layout-engine core logic THERE, and the React
6
+ // renderer/editor on top of it here (src/react/layoutEngine).
7
+ export * from 'cwip/layout';
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `cursedbelt-core/master-lock` — the isomorphic half of the sitewide master-password lock.
3
+ *
4
+ * Derivation, policy numbers and the wire contract: everything both the browser and the
5
+ * server need to agree on, and nothing that needs either. The server engine is
6
+ * `cursedbelt-server/master-lock`; the in-app controls are `cursedbelt/react/master-lock`.
7
+ *
8
+ * What the whole feature is, and what it is NOT, is written once in the engine's module
9
+ * note — read that before changing anything here.
10
+ */
11
+ export {
12
+ MASTER_LOCK_KDF,
13
+ type MasterLockKdfParams,
14
+ b64u,
15
+ deriveMasterLockVerifier,
16
+ isMasterLockKdfParams,
17
+ newMasterLockKdfParams,
18
+ unb64u,
19
+ } from "./kdf";
20
+ export {
21
+ BASE_DELAY_MS,
22
+ DEFAULT_IDLE_MS,
23
+ FREE_ATTEMPTS,
24
+ MAX_DELAY_MS,
25
+ MAX_IDLE_MS,
26
+ MIN_IDLE_MS,
27
+ clampIdleMs,
28
+ delayAfter,
29
+ windowRemaining,
30
+ } from "./policy";
31
+ export {
32
+ MASTER_LOCK_COOKIE,
33
+ masterLockCookieName,
34
+ MASTER_LOCK_HEADER,
35
+ MASTER_LOCK_PATHS,
36
+ MASTER_LOCK_PREFIX,
37
+ MASTER_LOCK_STATE_HEADER,
38
+ type MasterLockChangeRequest,
39
+ type MasterLockEnrollRequest,
40
+ type MasterLockRefusal,
41
+ type MasterLockSettingsRequest,
42
+ type MasterLockStatus,
43
+ type MasterLockUnlockRequest,
44
+ } from "./wire";
45
+ export {
46
+ PRESENCE_ATTRIBUTE,
47
+ type PresenceMedia,
48
+ isPresenceMedia,
49
+ isSomebodyWatching,
50
+ } from "./presence";
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The derivation, pinned.
3
+ *
4
+ * 🔴 The first test is the load-bearing one, and it is not a tautology. `apps/vault`'s own
5
+ * `kdf.test.ts` asserts a FIXED (salt, password) → verifier vector; this asserts the SAME
6
+ * vector. That is what makes "seed an app from the vault's stored row and the owner's
7
+ * existing vault master password opens it" true — and it is the one property no integration
8
+ * test can check, because nobody involved knows the password.
9
+ *
10
+ * If this vector ever has to change, every app seeded from a vault record has to be
11
+ * re-seeded on the same day, and the owner has to be told before it lands.
12
+ */
13
+ import { describe, expect, test } from "bun:test";
14
+ import {
15
+ MASTER_LOCK_KDF,
16
+ type MasterLockKdfParams,
17
+ deriveMasterLockVerifier,
18
+ isMasterLockKdfParams,
19
+ newMasterLockKdfParams,
20
+ } from ".";
21
+ import { b64u, unb64u } from "./kdf";
22
+
23
+ // The exact fixture `apps/vault/src/crypto/kdf.test.ts` derives against.
24
+ const FIXED: MasterLockKdfParams = {
25
+ v: 1,
26
+ alg: "PBKDF2-SHA256",
27
+ iter: 600_000,
28
+ salt: "AAECAwQFBgcICQoLDA0ODw",
29
+ };
30
+
31
+ describe("deriveMasterLockVerifier", () => {
32
+ test("agrees with the vault on a fixed vector — this is what makes the seed work", async () => {
33
+ const verifier = await deriveMasterLockVerifier("correct horse battery staple", FIXED);
34
+ // The literal IS the pin — recomputing it here would only prove this file agrees
35
+ // with itself. It is `EXPECTED_VERIFIER` copied from
36
+ // `apps/vault/src/crypto/kdf.test.ts`, and both were re-derived side by side on
37
+ // 2026-08-15 to confirm the two implementations produce it.
38
+ expect(verifier).toBe("xnUVRdpRFD1tjzd7Il-Jd-YpvfVTGOs-6gAdsf2VfXc");
39
+ });
40
+
41
+ test("a different password under the same salt derives differently", async () => {
42
+ const a = await deriveMasterLockVerifier("one", FIXED);
43
+ const b = await deriveMasterLockVerifier("two", FIXED);
44
+ expect(a).not.toBe(b);
45
+ });
46
+
47
+ test("the same password under a different salt derives differently", async () => {
48
+ const a = await deriveMasterLockVerifier("same", FIXED);
49
+ const b = await deriveMasterLockVerifier("same", { ...FIXED, salt: "DwoNDAsKCQgHBgUEAwIB" });
50
+ expect(a).not.toBe(b);
51
+ });
52
+
53
+ test("refuses params it cannot derive under, rather than deriving something wrong", async () => {
54
+ await expect(
55
+ deriveMasterLockVerifier("x", { ...FIXED, alg: "scrypt" } as unknown as MasterLockKdfParams),
56
+ ).rejects.toThrow(/unsupported/);
57
+ });
58
+ });
59
+
60
+ describe("newMasterLockKdfParams", () => {
61
+ test("carries today's pinned numbers and a fresh salt each time", () => {
62
+ const a = newMasterLockKdfParams();
63
+ const b = newMasterLockKdfParams();
64
+ expect(a.iter).toBe(MASTER_LOCK_KDF.iter);
65
+ expect(a.alg).toBe("PBKDF2-SHA256");
66
+ expect(unb64u(a.salt).length).toBe(MASTER_LOCK_KDF.saltBytes);
67
+ expect(a.salt).not.toBe(b.salt);
68
+ });
69
+ });
70
+
71
+ describe("isMasterLockKdfParams", () => {
72
+ test("accepts a real descriptor and rejects every near miss", () => {
73
+ expect(isMasterLockKdfParams(FIXED)).toBe(true);
74
+ expect(isMasterLockKdfParams(null)).toBe(false);
75
+ expect(isMasterLockKdfParams({ ...FIXED, v: 2 })).toBe(false);
76
+ expect(isMasterLockKdfParams({ ...FIXED, alg: "PBKDF2-SHA512" })).toBe(false);
77
+ expect(isMasterLockKdfParams({ ...FIXED, iter: 0 })).toBe(false);
78
+ expect(isMasterLockKdfParams({ ...FIXED, iter: 1.5 })).toBe(false);
79
+ expect(isMasterLockKdfParams({ ...FIXED, salt: "" })).toBe(false);
80
+ // A salt with base64 padding or a `+`/`/` is not base64URL — it would decode to
81
+ // different bytes on one side than the other, which is the silent version of this bug.
82
+ expect(isMasterLockKdfParams({ ...FIXED, salt: "AA+/" })).toBe(false);
83
+ });
84
+ });
85
+
86
+ describe("b64u", () => {
87
+ test("round-trips arbitrary bytes without padding", () => {
88
+ for (const length of [0, 1, 2, 3, 16, 31, 32]) {
89
+ const bytes = new Uint8Array(length).map((_, i) => (i * 37 + 11) % 256);
90
+ const text = b64u(bytes);
91
+ expect(text).not.toContain("=");
92
+ expect([...unb64u(text)]).toEqual([...bytes]);
93
+ }
94
+ });
95
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The master-password derivation — deliberately BYTE-IDENTICAL to `apps/vault`'s.
3
+ *
4
+ * ── Why identical, and not merely similar ───────────────────────────────────
5
+ * The owner's instruction for this feature was *"set the master password to be the same
6
+ * as the vault master password for now … apply the verification like vault uses so I can
7
+ * type in the same master password I have on the vault and it works"*. Nobody — not the
8
+ * vault, not an agent, not this code — knows that password. The only way an app can accept
9
+ * it on day one is to be seeded with the vault's own KDF params and the vault's own stored
10
+ * `argon2id(verifier)`, and for that seed to mean anything the derivation has to agree to
11
+ * the byte. So this is a deliberate DUPLICATE of `apps/vault/src/crypto/kdf.ts`, pinned by
12
+ * `kdf.spec.ts` against the same fixed (salt, password) → verifier vector that file pins.
13
+ *
14
+ * It is not imported from the vault because the dependency would point the wrong way: a
15
+ * satellite app is a consumer of this package, and `cursedbelt` may never import one.
16
+ *
17
+ * ── The two outputs ────────────────────────────────────────────────────────
18
+ * KEK = PBKDF2-SHA256(password, salt, 600_000) → 256 bits
19
+ * verifier = PBKDF2-SHA256(KEK ‖ password, salt, 1), base64url
20
+ *
21
+ * The extra round exists so the value the server sees is one step past the key. Here there
22
+ * IS no encryption key — this lock guards a UI, and the bytes behind it are plaintext at
23
+ * rest by nature (see the module note in `../../server/master-lock/masterLock.ts`). The KEK
24
+ * is computed only because dropping it would change the verifier and break the whole point
25
+ * of the paragraph above.
26
+ *
27
+ * The server stores `argon2id(verifier)`, never the verifier, and the password never leaves
28
+ * the browser.
29
+ */
30
+
31
+ /** The pinned v1 parameters. Iterations may only ever be RAISED, and only by a change. */
32
+ export const MASTER_LOCK_KDF = {
33
+ v: 1,
34
+ alg: "PBKDF2-SHA256",
35
+ iter: 600_000,
36
+ saltBytes: 16,
37
+ } as const;
38
+
39
+ /** The versioned KDF descriptor. Every field is PUBLIC — it is served to the lock page
40
+ * before anyone has proved anything, exactly as the vault serves it. */
41
+ export interface MasterLockKdfParams {
42
+ readonly v: 1;
43
+ readonly alg: "PBKDF2-SHA256";
44
+ readonly iter: number;
45
+ /** base64url of the random salt. */
46
+ readonly salt: string;
47
+ }
48
+
49
+ const B64_ALPHABET = /^[0-9A-Za-z_-]+$/;
50
+
51
+ /** Is this a KDF descriptor this build knows how to derive under? Used at the seam where
52
+ * params arrive from a database, an env seed or the network — all three are outside this
53
+ * module's control, and a silently-wrong `iter` derives a verifier that can never match. */
54
+ export function isMasterLockKdfParams(value: unknown): value is MasterLockKdfParams {
55
+ if (value === null || typeof value !== "object") return false;
56
+ const p = value as Record<string, unknown>;
57
+ return (
58
+ p.v === 1 &&
59
+ p.alg === "PBKDF2-SHA256" &&
60
+ typeof p.iter === "number" &&
61
+ Number.isInteger(p.iter) &&
62
+ p.iter >= 1 &&
63
+ typeof p.salt === "string" &&
64
+ p.salt.length > 0 &&
65
+ B64_ALPHABET.test(p.salt)
66
+ );
67
+ }
68
+
69
+ /** Fresh params for a NEW master password: today's pinned numbers, a random salt. */
70
+ export function newMasterLockKdfParams(): MasterLockKdfParams {
71
+ const salt = new Uint8Array(MASTER_LOCK_KDF.saltBytes);
72
+ crypto.getRandomValues(salt);
73
+ return { v: MASTER_LOCK_KDF.v, alg: MASTER_LOCK_KDF.alg, iter: MASTER_LOCK_KDF.iter, salt: b64u(salt) };
74
+ }
75
+
76
+ /**
77
+ * Derive the unlock verifier from a typed master password.
78
+ *
79
+ * Runs in the BROWSER (the lock page and the change-password dialog) and in `bun test`.
80
+ * `crypto.subtle` ships PBKDF2 natively in both, which is why it is PBKDF2 and not argon2 —
81
+ * the same reasoning the vault records at length.
82
+ */
83
+ export async function deriveMasterLockVerifier(
84
+ masterPassword: string,
85
+ params: MasterLockKdfParams,
86
+ ): Promise<string> {
87
+ if (!isMasterLockKdfParams(params)) {
88
+ throw new Error(`unsupported master-lock KDF params: ${JSON.stringify(params)}`);
89
+ }
90
+ const salt = unb64u(params.salt);
91
+ const kek = await pbkdf2(utf8(masterPassword), salt, params.iter);
92
+ // Same salt as the KEK, by design: it individualises the verifier without a second
93
+ // stored value. One round, over KEK ‖ password.
94
+ const verifier = await pbkdf2(concat(kek, utf8(masterPassword)), salt, 1);
95
+ return b64u(verifier);
96
+ }
97
+
98
+ async function pbkdf2(material: Uint8Array, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
99
+ const key = await crypto.subtle.importKey("raw", buf(material), "PBKDF2", false, ["deriveBits"]);
100
+ const bits = await crypto.subtle.deriveBits(
101
+ { name: "PBKDF2", salt: buf(salt), iterations, hash: "SHA-256" },
102
+ key,
103
+ 256,
104
+ );
105
+ return new Uint8Array(bits);
106
+ }
107
+
108
+ // ── bytes ───────────────────────────────────────────────────────────────────
109
+ // Local rather than imported: this module is the one piece that must stay derivable with
110
+ // nothing behind it, and `atob`/`btoa` exist in every runtime it runs in.
111
+
112
+ /** A copy whose backing store is a plain `ArrayBuffer` — what `BufferSource` wants, and
113
+ * what a `Uint8Array` over a `SharedArrayBuffer` is not. */
114
+ function buf(bytes: Uint8Array): ArrayBuffer {
115
+ const out = new ArrayBuffer(bytes.byteLength);
116
+ new Uint8Array(out).set(bytes);
117
+ return out;
118
+ }
119
+
120
+ function utf8(text: string): Uint8Array {
121
+ return new TextEncoder().encode(text);
122
+ }
123
+
124
+ function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
125
+ const out = new Uint8Array(a.length + b.length);
126
+ out.set(a, 0);
127
+ out.set(b, a.length);
128
+ return out;
129
+ }
130
+
131
+ /** base64url, unpadded — the vault's `b64u`. */
132
+ export function b64u(bytes: Uint8Array): string {
133
+ let binary = "";
134
+ for (const byte of bytes) binary += String.fromCharCode(byte);
135
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
136
+ }
137
+
138
+ /** The inverse of {@link b64u}. */
139
+ export function unb64u(text: string): Uint8Array {
140
+ const padded = text.replace(/-/g, "+").replace(/_/g, "/");
141
+ const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
142
+ const out = new Uint8Array(binary.length);
143
+ for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
144
+ return out;
145
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The numbers — how long an unlock survives being ignored, and how hard it is to guess at.
3
+ *
4
+ * Pure functions with no clock and no I/O of their own, so every edge of the schedule is a
5
+ * unit test rather than a wait. The engine in `../../server/master-lock/` supplies `now`.
6
+ */
7
+
8
+ /** The owner's default: *"default to 5 minutes if I haven't configured it"*. */
9
+ export const DEFAULT_IDLE_MS = 5 * 60_000;
10
+ /** Short enough to be a real lock, long enough that typing a password is not the task. */
11
+ export const MIN_IDLE_MS = 30_000;
12
+ /** A day. Past this the timer is not what is protecting anything — the manual lock is. */
13
+ export const MAX_IDLE_MS = 24 * 60 * 60_000;
14
+
15
+ /** Clamp a submitted idle timeout into the usable band. A value that is not a finite
16
+ * number at all falls back to the default rather than to a bound: a corrupt record must
17
+ * not silently become "locks in 30 seconds" or "never locks". */
18
+ export function clampIdleMs(value: unknown): number {
19
+ if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_IDLE_MS;
20
+ return Math.min(MAX_IDLE_MS, Math.max(MIN_IDLE_MS, Math.round(value)));
21
+ }
22
+
23
+ // ── The guess throttle ──────────────────────────────────────────────────────
24
+ // The same schedule `apps/vault`'s unlock guard implements, and for the same reason: the
25
+ // owner mistypes, an attacker iterates. Never a lockout — the owner locking themselves out
26
+ // of their own laptop is a worse outcome than a slow attacker.
27
+
28
+ /** Consecutive failures that cost nothing. */
29
+ export const FREE_ATTEMPTS = 5;
30
+ /** The first enforced wait once those are spent. Doubles thereafter. */
31
+ export const BASE_DELAY_MS = 30_000;
32
+ /** The ceiling on that wait. */
33
+ export const MAX_DELAY_MS = 15 * 60_000;
34
+
35
+ /**
36
+ * How long must pass after `failures` consecutive failures before another attempt is
37
+ * evaluated. `failures` includes the one that just happened.
38
+ *
39
+ * ≤5 → 0 · 6 → 30s · 7 → 60s · 8 → 2m · … · ≥11 → 15m (capped)
40
+ */
41
+ export function delayAfter(failures: number): number {
42
+ if (failures <= FREE_ATTEMPTS) return 0;
43
+ // Clamp the EXPONENT, not the product: a client that keeps hammering must not shift
44
+ // past a finite number into Infinity.
45
+ const exponent = Math.min(failures - FREE_ATTEMPTS - 1, 32);
46
+ return Math.min(BASE_DELAY_MS * 2 ** exponent, MAX_DELAY_MS);
47
+ }
48
+
49
+ /** Milliseconds still to wait, given the failure count, when the last one happened, and
50
+ * now. `0` means "evaluate it". */
51
+ export function windowRemaining(failures: number, lastFailureAt: number, now: number): number {
52
+ const delay = delayAfter(failures);
53
+ if (delay === 0) return 0;
54
+ return Math.max(0, lastFailureAt + delay - now);
55
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Watching a film is being present — and a decorative loop is not.
3
+ *
4
+ * Owner ask, 2026-08-25: *"apps that lock like collections with a master password should not
5
+ * lock when I am watching a video even if the timer runs out."*
6
+ *
7
+ * 🔴 The decorative-loop test is the security half. Presence that a muted autoplaying
8
+ * background clip could assert would hold a shared laptop unlocked all night with nobody in
9
+ * the room, which is the exact scenario the master lock exists for — a fix that opens the
10
+ * hole it was asked to close.
11
+ */
12
+ import { describe, expect, test } from "bun:test";
13
+ import { PRESENCE_ATTRIBUTE, type PresenceMedia, isPresenceMedia, isSomebodyWatching } from "./presence";
14
+
15
+ const media = (over: Partial<PresenceMedia> & { presence?: string } = {}): PresenceMedia => ({
16
+ paused: false,
17
+ ended: false,
18
+ muted: false,
19
+ loop: false,
20
+ duration: 5_400,
21
+ getAttribute: (name: string) => (name === PRESENCE_ATTRIBUTE ? (over.presence ?? null) : null),
22
+ ...over,
23
+ });
24
+
25
+ describe("isPresenceMedia", () => {
26
+ test("a film that is playing is somebody watching", () => {
27
+ expect(isPresenceMedia(media())).toBe(true);
28
+ });
29
+
30
+ test("paused or ended is not", () => {
31
+ expect(isPresenceMedia(media({ paused: true }))).toBe(false);
32
+ expect(isPresenceMedia(media({ ended: true }))).toBe(false);
33
+ });
34
+
35
+ test("🔴 a muted looping backdrop is NOT a person", () => {
36
+ expect(isPresenceMedia(media({ muted: true, loop: true }))).toBe(false);
37
+ });
38
+
39
+ test("muted alone, or looping alone, still counts — neither is the backdrop signature", () => {
40
+ expect(isPresenceMedia(media({ muted: true }))).toBe(true);
41
+ expect(isPresenceMedia(media({ loop: true }))).toBe(true);
42
+ });
43
+
44
+ test("a live stream (Infinity) counts; one with no duration yet does not", () => {
45
+ expect(isPresenceMedia(media({ duration: Number.POSITIVE_INFINITY }))).toBe(true);
46
+ expect(isPresenceMedia(media({ duration: 0 }))).toBe(false);
47
+ expect(isPresenceMedia(media({ duration: Number.NaN }))).toBe(false);
48
+ });
49
+
50
+ test("the attribute overrules the heuristic in both directions", () => {
51
+ // A backdrop that insists it is being watched still is.
52
+ expect(isPresenceMedia(media({ muted: true, loop: true, presence: "watch" }))).toBe(true);
53
+ // …and a real film that opts out is not, even mid-play.
54
+ expect(isPresenceMedia(media({ presence: "ignore" }))).toBe(false);
55
+ });
56
+
57
+ test("🔴 `ignore` beats `paused` too — an opted-out element is never evidence", () => {
58
+ expect(isPresenceMedia(media({ paused: true, presence: "ignore" }))).toBe(false);
59
+ });
60
+ });
61
+
62
+ describe("isSomebodyWatching", () => {
63
+ test("one playing element among many is enough", () => {
64
+ expect(
65
+ isSomebodyWatching([media({ paused: true }), media({ muted: true, loop: true }), media()]),
66
+ ).toBe(true);
67
+ });
68
+
69
+ test("an empty page, or a page of stopped media, is an empty room", () => {
70
+ expect(isSomebodyWatching([])).toBe(false);
71
+ expect(isSomebodyWatching([media({ paused: true }), media({ ended: true })])).toBe(false);
72
+ });
73
+ });