brane 0.0.2 → 0.3.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 (338) hide show
  1. package/AGENTS.md +362 -0
  2. package/CHANGELOG.md +33 -0
  3. package/CONTRIBUTING.md +91 -0
  4. package/DESIGN.md +402 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +205 -0
  9. package/dist/absorb/brane-id.js +11 -0
  10. package/dist/absorb/chatgpt.js +161 -0
  11. package/dist/absorb/claude-code.js +504 -0
  12. package/dist/absorb/index.js +543 -0
  13. package/dist/absorb/organelle.js +345 -0
  14. package/dist/absorb/run.js +520 -0
  15. package/dist/absorb/sources.js +168 -0
  16. package/dist/absorb/types.js +3 -0
  17. package/dist/agent-env.js +45 -0
  18. package/dist/agents.js +1168 -0
  19. package/dist/claude-live-session.js +124 -0
  20. package/dist/cli.js +50 -0
  21. package/dist/codex-tool-log.js +144 -0
  22. package/dist/commands/absorb.js +291 -0
  23. package/dist/commands/attach.js +31 -0
  24. package/dist/commands/certify.js +62 -0
  25. package/dist/commands/config.js +116 -0
  26. package/dist/commands/cost.js +126 -0
  27. package/dist/commands/daemon.js +350 -0
  28. package/dist/commands/doctor.js +262 -0
  29. package/dist/commands/fold.js +95 -0
  30. package/dist/commands/list.js +89 -0
  31. package/dist/commands/mcp.js +84 -0
  32. package/dist/commands/meld.js +332 -0
  33. package/dist/commands/memory.js +215 -0
  34. package/dist/commands/revive.js +74 -0
  35. package/dist/commands/run.js +85 -0
  36. package/dist/commands/spawn.js +1094 -0
  37. package/dist/commands/token.js +153 -0
  38. package/dist/copilot-tool-log.js +145 -0
  39. package/dist/cost/pricing.js +116 -0
  40. package/dist/cost/reprice.js +136 -0
  41. package/dist/crush-tool-log.js +293 -0
  42. package/dist/daemon/agent-scope.js +116 -0
  43. package/dist/daemon/awaiting.js +54 -0
  44. package/dist/daemon/cert-manager.js +349 -0
  45. package/dist/daemon/client.js +262 -0
  46. package/dist/daemon/gated-routes.js +112 -0
  47. package/dist/daemon/keep-awake.js +103 -0
  48. package/dist/daemon/local-socket.js +145 -0
  49. package/dist/daemon/presence-gate.js +206 -0
  50. package/dist/daemon/server.js +6349 -0
  51. package/dist/daemon/signal-source.js +35 -0
  52. package/dist/daemon/tailscale-rebind.js +58 -0
  53. package/dist/daemon/token-ring.js +263 -0
  54. package/dist/daemon/token-routes.js +190 -0
  55. package/dist/db/events.js +152 -0
  56. package/dist/db/mentions.js +179 -0
  57. package/dist/db/schema.js +633 -0
  58. package/dist/db/tokens.js +583 -0
  59. package/dist/db/tool-uses.js +647 -0
  60. package/dist/diagram/index.js +85 -0
  61. package/dist/diagram/label.js +455 -0
  62. package/dist/diagram/layout.js +476 -0
  63. package/dist/diagram/links.js +50 -0
  64. package/dist/diagram/metrics.js +152 -0
  65. package/dist/diagram/parse.js +487 -0
  66. package/dist/diagram/svg.js +389 -0
  67. package/dist/diagram/types.js +9 -0
  68. package/dist/export.js +629 -0
  69. package/dist/files/archive-types.js +2 -0
  70. package/dist/files/archive.js +182 -0
  71. package/dist/files/browse.js +327 -0
  72. package/dist/files/bytes.js +162 -0
  73. package/dist/files/codecs.js +33 -0
  74. package/dist/files/find.js +212 -0
  75. package/dist/files/tar.js +223 -0
  76. package/dist/files/zip.js +209 -0
  77. package/dist/folds.js +72 -0
  78. package/dist/grammars/ebnf.js +92 -0
  79. package/dist/highlight.js +250 -0
  80. package/dist/ids.js +24 -0
  81. package/dist/markdown-diagnostics.js +233 -0
  82. package/dist/math.js +168 -0
  83. package/dist/mcp/server.js +886 -0
  84. package/dist/memory/embedder.js +145 -0
  85. package/dist/memory/embedding-worker.js +71 -0
  86. package/dist/memory/embeddings.js +91 -0
  87. package/dist/memory/index.js +232 -0
  88. package/dist/memory/storage.js +587 -0
  89. package/dist/memory/wiki.js +688 -0
  90. package/dist/memory/wordpiece.js +247 -0
  91. package/dist/mentions-scan.js +40 -0
  92. package/dist/presence.js +259 -0
  93. package/dist/session-affinity.js +185 -0
  94. package/dist/shared/api-request.js +124 -0
  95. package/dist/shared/block-glyphs.js +114 -0
  96. package/dist/shared/diagram-css.js +17 -0
  97. package/dist/shared/export-selection.js +101 -0
  98. package/dist/shared/find-in-text.js +110 -0
  99. package/dist/shared/glow-hues.js +225 -0
  100. package/dist/shared/hex-dump.js +62 -0
  101. package/dist/shared/hex-pages.js +52 -0
  102. package/dist/shared/hit-drum.js +79 -0
  103. package/dist/shared/known-nids.js +58 -0
  104. package/dist/shared/list-scroll-keys.js +56 -0
  105. package/dist/shared/markdown-lists.js +72 -0
  106. package/dist/shared/markdown-math.js +485 -0
  107. package/dist/shared/markdown-mentions.js +79 -0
  108. package/dist/shared/markdown-nids.js +41 -0
  109. package/dist/shared/markdown-protocols.js +53 -0
  110. package/dist/shared/markdown-refs.js +123 -0
  111. package/dist/shared/membrane-invasion.js +342 -0
  112. package/dist/shared/membrane-motion.js +83 -0
  113. package/dist/shared/membrane-palette.js +253 -0
  114. package/dist/shared/membrane-terrain.js +212 -0
  115. package/dist/shared/message-selection.js +123 -0
  116. package/dist/shared/mo-glyph.js +57 -0
  117. package/dist/shared/narrative-format.js +237 -0
  118. package/dist/shared/narrative-menu.js +82 -0
  119. package/dist/shared/narrative-sort.js +47 -0
  120. package/dist/shared/narrative-status.js +52 -0
  121. package/dist/shared/no-pan.js +85 -0
  122. package/dist/shared/pane-layout.js +50 -0
  123. package/dist/shared/payload-inline.js +83 -0
  124. package/dist/shared/speaker-color.js +61 -0
  125. package/dist/shared/token-rotation.js +14 -0
  126. package/dist/shared/view-url.js +197 -0
  127. package/dist/shared/wiki-links.js +70 -0
  128. package/dist/shared/wiki-window.js +4 -0
  129. package/dist/tailscale.js +55 -0
  130. package/dist/test/absorb-claude.test.js +892 -0
  131. package/dist/test/agent-scope.test.js +193 -0
  132. package/dist/test/agents.test.js +55 -0
  133. package/dist/test/api-request.test.js +122 -0
  134. package/dist/test/awaiting-glow.test.js +54 -0
  135. package/dist/test/awaiting.test.js +122 -0
  136. package/dist/test/bind-token.test.js +88 -0
  137. package/dist/test/block-glyphs.test.js +68 -0
  138. package/dist/test/cert-manager.test.js +262 -0
  139. package/dist/test/claude-live-session.test.js +110 -0
  140. package/dist/test/codex-tool-log.test.js +568 -0
  141. package/dist/test/config.test.js +71 -0
  142. package/dist/test/copilot-tool-log.test.js +93 -0
  143. package/dist/test/cors-preflight.test.js +49 -0
  144. package/dist/test/cost-linger.test.js +31 -0
  145. package/dist/test/cost-pricing.test.js +72 -0
  146. package/dist/test/cost-reprice.test.js +139 -0
  147. package/dist/test/cost-sessions.test.js +301 -0
  148. package/dist/test/crush-tool-log.test.js +289 -0
  149. package/dist/test/daemon-client.test.js +109 -0
  150. package/dist/test/daemon-socket.test.js +293 -0
  151. package/dist/test/diagram-arrows.test.js +205 -0
  152. package/dist/test/diagram-export.test.js +210 -0
  153. package/dist/test/diagram-labels.test.js +313 -0
  154. package/dist/test/diagram-parse.test.js +191 -0
  155. package/dist/test/diagram-render.test.js +175 -0
  156. package/dist/test/diagram-review.test.js +288 -0
  157. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  158. package/dist/test/embedder.test.js +84 -0
  159. package/dist/test/events.test.js +123 -0
  160. package/dist/test/export-selection.test.js +102 -0
  161. package/dist/test/export.test.js +505 -0
  162. package/dist/test/files-browse.test.js +393 -0
  163. package/dist/test/files-find.test.js +272 -0
  164. package/dist/test/find-in-text.test.js +86 -0
  165. package/dist/test/first-paint-viewport.test.js +59 -0
  166. package/dist/test/fixtures/embedding-golden.js +23 -0
  167. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  168. package/dist/test/glow-hues.test.js +202 -0
  169. package/dist/test/harness-cost.test.js +423 -0
  170. package/dist/test/helpers.js +21 -0
  171. package/dist/test/hex-pages.test.js +68 -0
  172. package/dist/test/highlight.test.js +69 -0
  173. package/dist/test/hit-drum.test.js +71 -0
  174. package/dist/test/hook-config.test.js +262 -0
  175. package/dist/test/json-gzip.test.js +70 -0
  176. package/dist/test/key-routing.test.js +58 -0
  177. package/dist/test/known-nids.test.js +106 -0
  178. package/dist/test/link-preview.test.js +67 -0
  179. package/dist/test/list-scroll-keys.test.js +66 -0
  180. package/dist/test/listen-own-words.test.js +147 -0
  181. package/dist/test/listen-timeout.test.js +33 -0
  182. package/dist/test/markdown-diagnostics.test.js +121 -0
  183. package/dist/test/markdown-lists.test.js +182 -0
  184. package/dist/test/markdown-math.test.js +256 -0
  185. package/dist/test/markdown-nids.test.js +146 -0
  186. package/dist/test/markdown-protocols.test.js +139 -0
  187. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  188. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  189. package/dist/test/mcp-narrative.test.js +193 -0
  190. package/dist/test/mcp.test.js +548 -0
  191. package/dist/test/meld.test.js +85 -0
  192. package/dist/test/membrane-invasion.test.js +482 -0
  193. package/dist/test/membrane-motion.test.js +101 -0
  194. package/dist/test/membrane-palette.test.js +166 -0
  195. package/dist/test/membrane-terrain.test.js +170 -0
  196. package/dist/test/memory-log.test.js +202 -0
  197. package/dist/test/mentions-e2e.test.js +261 -0
  198. package/dist/test/mentions-ledger.test.js +265 -0
  199. package/dist/test/mentions-listen.test.js +265 -0
  200. package/dist/test/mentions.test.js +238 -0
  201. package/dist/test/message-selection.test.js +114 -0
  202. package/dist/test/mo-glyph.test.js +22 -0
  203. package/dist/test/model-resolution.test.js +166 -0
  204. package/dist/test/narrate-validate.test.js +77 -0
  205. package/dist/test/narrate-wait.test.js +160 -0
  206. package/dist/test/narrative-format.test.js +219 -0
  207. package/dist/test/narrative-menu.test.js +137 -0
  208. package/dist/test/narrative-sort.test.js +75 -0
  209. package/dist/test/no-pan.test.js +60 -0
  210. package/dist/test/pane-layout.test.js +153 -0
  211. package/dist/test/paths.test.js +37 -0
  212. package/dist/test/payload-inline.test.js +91 -0
  213. package/dist/test/precache-fonts.test.js +72 -0
  214. package/dist/test/presence-gate.test.js +440 -0
  215. package/dist/test/presence.test.js +160 -0
  216. package/dist/test/read-events.test.js +220 -0
  217. package/dist/test/remote-meld.test.js +115 -0
  218. package/dist/test/revive-cli.test.js +93 -0
  219. package/dist/test/search-floor.test.js +206 -0
  220. package/dist/test/search-fusion.test.js +168 -0
  221. package/dist/test/search.test.js +205 -0
  222. package/dist/test/security.test.js +48 -0
  223. package/dist/test/selection-wiring.test.js +61 -0
  224. package/dist/test/session-affinity.test.js +295 -0
  225. package/dist/test/signal-source.test.js +32 -0
  226. package/dist/test/socket-account.test.js +268 -0
  227. package/dist/test/spawn-toml.test.js +72 -0
  228. package/dist/test/speaker-color.test.js +90 -0
  229. package/dist/test/tailscale-rebind.test.js +79 -0
  230. package/dist/test/terminal-cursor.test.js +61 -0
  231. package/dist/test/terminal-resync.test.js +23 -0
  232. package/dist/test/thin-cli.test.js +382 -0
  233. package/dist/test/token-bootstrap.test.js +272 -0
  234. package/dist/test/token-devices.test.js +587 -0
  235. package/dist/test/token-lapse.test.js +94 -0
  236. package/dist/test/token-ring.test.js +188 -0
  237. package/dist/test/token-rotate-cli.test.js +58 -0
  238. package/dist/test/token-routes.test.js +95 -0
  239. package/dist/test/tool-uses.test.js +1006 -0
  240. package/dist/test/usage-ledger.test.js +126 -0
  241. package/dist/test/view-url.test.js +581 -0
  242. package/dist/test/wiki-links.test.js +57 -0
  243. package/dist/test/wiki-sources.test.js +85 -0
  244. package/dist/test/wiki-window.test.js +31 -0
  245. package/dist/test/wiki.test.js +399 -0
  246. package/dist/test/wordpiece.test.js +68 -0
  247. package/dist/tls.js +148 -0
  248. package/dist/tmux.js +202 -0
  249. package/dist/user-agent.js +46 -0
  250. package/docker-entrypoint.sh +141 -0
  251. package/docs/README.md +3 -0
  252. package/docs/absorb.md +231 -0
  253. package/docs/cost-ledger.md +125 -0
  254. package/docs/daemon-api.md +189 -0
  255. package/docs/diagrams.md +163 -0
  256. package/docs/doctor.md +36 -0
  257. package/docs/export-scope.md +69 -0
  258. package/docs/files-view.md +133 -0
  259. package/docs/link-previews.md +38 -0
  260. package/docs/math.md +32 -0
  261. package/docs/mcp.md +69 -0
  262. package/docs/memory-search.md +75 -0
  263. package/docs/mentions.md +101 -0
  264. package/docs/multi-brane.md +14 -0
  265. package/docs/passkeys.md +165 -0
  266. package/docs/presence.md +202 -0
  267. package/docs/releasing.md +53 -0
  268. package/docs/rendering-warnings.md +22 -0
  269. package/docs/request-layer.md +72 -0
  270. package/docs/selection-store.md +50 -0
  271. package/docs/session-affinity.md +61 -0
  272. package/docs/terminal-mirroring.md +211 -0
  273. package/docs/token-rotation.md +413 -0
  274. package/docs/view-urls.md +114 -0
  275. package/docs/web-app.md +23 -0
  276. package/docs/wiki.md +43 -0
  277. package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -0
  278. package/expo/dist/apple-touch-icon.png +0 -0
  279. package/expo/dist/favicon.svg +3 -0
  280. package/expo/dist/index.html +177 -0
  281. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  282. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  283. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  284. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  285. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  301. package/expo/dist/katex/katex.min.css +1 -0
  302. package/expo/dist/manifest.json +16 -0
  303. package/expo/dist/metadata.json +1 -0
  304. package/expo/dist/og/membrane.v1.png +0 -0
  305. package/expo/dist/pdf.min.mjs +28 -0
  306. package/expo/dist/pdf.worker.min.mjs +28 -0
  307. package/expo/dist/sw.js +154 -0
  308. package/package.json +81 -23
  309. package/protocols/LIVE_loop.md +407 -0
  310. package/protocols/bebrief.md +41 -0
  311. package/protocols/digest.md +231 -0
  312. package/protocols/fly.md +266 -0
  313. package/protocols/idle.md +81 -0
  314. package/protocols/keeper.md +829 -0
  315. package/protocols/markdown.md +80 -0
  316. package/protocols/memory.md +419 -0
  317. package/protocols/mentions.md +93 -0
  318. package/protocols/payloads.md +53 -0
  319. package/protocols/prelude.md +91 -0
  320. package/protocols/ramify.md +105 -0
  321. package/protocols/review.md +356 -0
  322. package/protocols/rrr.md +232 -0
  323. package/protocols/scribe.md +429 -0
  324. package/protocols/summarize.md +203 -0
  325. package/protocols/twin.md +174 -0
  326. package/protocols/wiki.md +438 -0
  327. package/protocols/worker.md +38 -0
  328. package/wiki/shared/append-only-event-log.md +148 -0
  329. package/wiki/shared/index.md +48 -0
  330. package/wiki/shared/owning-your-narratives.md +161 -0
  331. package/wiki/shared/premature-ontologies.md +435 -0
  332. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  333. package/wiki/shared/sequestering-the-narrative.md +143 -0
  334. package/wiki/shared/why-wikis-work.md +216 -0
  335. package/.npmignore +0 -2
  336. package/.travis.yml +0 -5
  337. package/index.js +0 -8
  338. package/index.js~ +0 -12
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Which hue a narrative's awaiting glow wears on this device.
3
+ *
4
+ * The glow used to be the narrative's speaker hash (`speakerHue`), which knows
5
+ * nothing about what else is on screen: two agents parked side by side could
6
+ * glow the same purple, and 🎨 Recolor's `salt + 1` was a fresh random draw
7
+ * that could land on another. The glow's job is to let the operator tell
8
+ * sessions apart at a glance, so the hue is *allocated* instead:
9
+ *
10
+ * - **Chosen once, then sticky.** The first time a narrative glows here it
11
+ * takes the middle of the widest empty arc among the hues of the other
12
+ * narratives that glowed recently, and keeps that hue on every later listen.
13
+ * Nothing moves it automatically — an operator learns sessions by color, and
14
+ * a hue that shifted whenever a neighbour lit up would be unlearnable.
15
+ * - **Recolor rolls within the clear arcs.** A random hue at least half as far
16
+ * from the others as the best spot on the wheel, a visible step from the
17
+ * current one, and clear of the hues this narrative was just recolored away
18
+ * from. Always taking the widest gap's middle made it a toggle: with one
19
+ * other session glowing there is one such middle, and alone on the wheel
20
+ * the fallback flipped to the opposite hue and back again.
21
+ *
22
+ * "Recently" rather than "glowing right now": a session between replies will
23
+ * glow again in a minute, so its hue is still spoken for. Rather than ask
24
+ * every screen for a list of live narratives (the header has none), the store
25
+ * keeps when each hue last glowed and treats the {@link RECENT_LIMIT} most
26
+ * recent within {@link RECENT_MS} as taken. A dead session drops out on its
27
+ * own by never glowing again.
28
+ *
29
+ * Client-side only, like the Recolor salt: a preference of this device's
30
+ * eyes, never sent to the daemon. Window-free so `src/test/glow-hues.test.ts`
31
+ * can pin it; `expo/lib/glow-hues.ts` binds it to `localStorage`.
32
+ */
33
+ import { speakerHue } from "./speaker-color.js";
34
+ export const GLOW_HUES_STORAGE_KEY = "brane:glow-hues";
35
+ /** How long a hue stays spoken for after it last glowed. */
36
+ export const RECENT_MS = 7 * 24 * 60 * 60 * 1000;
37
+ /** At most this many hues count as taken — the most recently glowing. Past a
38
+ * dozen, every gap is under 30° anyway, and the sessions that matter are the
39
+ * ones the operator saw last. */
40
+ export const RECENT_LIMIT = 12;
41
+ /** `seen` is refreshed on render, so writes are coalesced to one per minute. */
42
+ const SEEN_RESOLUTION_MS = 60 * 1000;
43
+ /** A Recolor must change the color, not shade it: nothing nearer the current
44
+ * hue than this. At 45° a fifth of presses moved under 70°, pink to rose or
45
+ * sky to blue, and read as no change at all; 100° crosses a color family. */
46
+ export const MIN_RECOLOR_STEP = 100;
47
+ /** A Recolor keeps at least this fraction of the best clearance from the
48
+ * others that any hue on the wheel could have… */
49
+ export const RECOLOR_CLEARANCE = 0.5;
50
+ /** …or, when that leaves nowhere a full step away, this fraction. The step
51
+ * outranks the clearance: with one other session opposite, the arc at half
52
+ * the best clearance is 180° wide and a 100° step can't be taken inside it. */
53
+ export const RECOLOR_CLEARANCE_MIN = 0.25;
54
+ /** Hues this narrative was recolored away from are rejected for this long… */
55
+ export const REJECTED_MS = 60 * 60 * 1000;
56
+ /** …at most this many of them… */
57
+ export const REJECTED_LIMIT = 4;
58
+ /** …and a Recolor keeps this far from each, so a second press can't undo the
59
+ * first by stepping back to where it started. */
60
+ export const REJECTED_CLEARANCE = 60;
61
+ /** Shortest distance between two hues around the wheel, in degrees. */
62
+ export function hueDistance(a, b) {
63
+ const d = Math.abs(a - b) % 360;
64
+ return Math.min(d, 360 - d);
65
+ }
66
+ const norm = (h) => ((Math.round(h) % 360) + 360) % 360;
67
+ /**
68
+ * Midpoints of the empty arcs between `taken` hues, widest first. Ties keep
69
+ * wheel order, so the answer is deterministic. Empty when nothing is taken —
70
+ * the caller decides what a free wheel means.
71
+ */
72
+ export function gapMidpoints(taken) {
73
+ const hues = [...new Set(taken.map(norm))].sort((a, b) => a - b);
74
+ if (hues.length === 0)
75
+ return [];
76
+ const gaps = hues.map((h, i) => {
77
+ const next = i + 1 < hues.length ? hues[i + 1] : hues[0] + 360;
78
+ const width = next - h;
79
+ return { width, mid: norm(h + width / 2) };
80
+ });
81
+ return gaps
82
+ .map((g, i) => ({ ...g, i }))
83
+ .sort((a, b) => b.width - a.width || a.i - b.i)
84
+ .map((g) => g.mid);
85
+ }
86
+ export function parseGlowAssignments(raw) {
87
+ if (!raw)
88
+ return {};
89
+ try {
90
+ const parsed = JSON.parse(raw);
91
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
92
+ return {};
93
+ const out = {};
94
+ for (const [nid, v] of Object.entries(parsed)) {
95
+ const a = v;
96
+ if (a && Number.isFinite(a.hue) && Number.isFinite(a.seen)) {
97
+ out[nid] = { hue: norm(a.hue), seen: a.seen };
98
+ const rejected = Array.isArray(a.rejected)
99
+ ? a.rejected.filter((r) => r && Number.isFinite(r.hue) && Number.isFinite(r.at))
100
+ : [];
101
+ if (rejected.length)
102
+ out[nid].rejected = rejected.map((r) => ({ hue: norm(r.hue), at: r.at }));
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+ catch {
108
+ return {};
109
+ }
110
+ }
111
+ /** Hues spoken for by narratives other than `self`: the most recently glowing
112
+ * {@link RECENT_LIMIT}, within {@link RECENT_MS}. */
113
+ export function takenHues(assignments, self, now) {
114
+ return Object.entries(assignments)
115
+ .filter(([nid, a]) => nid !== self && now - a.seen <= RECENT_MS)
116
+ .sort((a, b) => b[1].seen - a[1].seen)
117
+ .slice(0, RECENT_LIMIT)
118
+ .map(([, a]) => a.hue);
119
+ }
120
+ /** The hue a narrative gets the first time it glows. On a free wheel that is
121
+ * its speaker hash — the color it wore before this allocator existed. */
122
+ export function firstHue(nid, taken) {
123
+ return gapMidpoints(taken)[0] ?? speakerHue(nid);
124
+ }
125
+ /** Nearest distance from `hue` to any of `hues`; a half turn when there are none. */
126
+ function clearance(hue, hues) {
127
+ return hues.reduce((m, h) => Math.min(m, hueDistance(hue, h)), 180);
128
+ }
129
+ /** Where 🎨 Recolor moves a hue: a random whole degree at least
130
+ * {@link MIN_RECOLOR_STEP} from the current hue, clear of the `rejected` ones,
131
+ * and at least {@link RECOLOR_CLEARANCE} of the best available clearance from
132
+ * the others. On a wheel too crowded for all three, the clearance drops to
133
+ * {@link RECOLOR_CLEARANCE_MIN} first, then the rejected hues give way oldest
134
+ * first, and only then the step; with nothing left, the widest gap's middle, with the current
135
+ * hue counted as taken so it still moves. */
136
+ export function recolorHue(current, taken, opts = {}) {
137
+ const { rejected = [], random = Math.random } = opts;
138
+ const others = [...new Set(taken.map(norm))];
139
+ const left = rejected.map(norm);
140
+ const best = others.length ? gapMidpoints(others).reduce((m, mid) => Math.max(m, clearance(mid, others)), 0) : 180;
141
+ const clearAt = (fraction) => {
142
+ const out = [];
143
+ for (let h = 0; h < 360; h++)
144
+ if (clearance(h, others) >= best * fraction)
145
+ out.push(h);
146
+ return out;
147
+ };
148
+ const clear = clearAt(RECOLOR_CLEARANCE);
149
+ const looser = clearAt(RECOLOR_CLEARANCE_MIN);
150
+ const stepped = (hues) => hues.filter((h) => hueDistance(h, current) >= MIN_RECOLOR_STEP);
151
+ // `rejected` is newest first, so the hues it lets go of are the oldest: the
152
+ // one this press is about to leave, and the one before, are kept longest.
153
+ const fresh = (hues, n) => hues.filter((h) => clearance(h, left.slice(0, n)) >= REJECTED_CLEARANCE);
154
+ const tiers = [];
155
+ for (let n = left.length; n > 0; n--)
156
+ tiers.push(fresh(stepped(clear), n), fresh(stepped(looser), n));
157
+ tiers.push(stepped(clear), stepped(looser), clear.filter((h) => h !== norm(current)));
158
+ const pool = tiers.find((t) => t.length > 0);
159
+ if (!pool)
160
+ return gapMidpoints([...others, current])[0];
161
+ return pool[Math.min(pool.length - 1, Math.floor(random() * pool.length))];
162
+ }
163
+ /** The allocator over a key-value store — `localStorage` in the app. Every
164
+ * store access is guarded: an unreadable store is an empty one, and a failed
165
+ * write still returns the hue it computed. */
166
+ export class GlowHues {
167
+ store;
168
+ now;
169
+ random;
170
+ constructor(store, now = Date.now, random = Math.random) {
171
+ this.store = store;
172
+ this.now = now;
173
+ this.random = random;
174
+ }
175
+ read() {
176
+ try {
177
+ return parseGlowAssignments(this.store.getItem(GLOW_HUES_STORAGE_KEY));
178
+ }
179
+ catch {
180
+ return {};
181
+ }
182
+ }
183
+ write(assignments) {
184
+ try {
185
+ this.store.setItem(GLOW_HUES_STORAGE_KEY, JSON.stringify(assignments));
186
+ }
187
+ catch { }
188
+ }
189
+ /** The hue `nid` glows in, assigning one the first time. Call it only for a
190
+ * narrative that is glowing: that call is what marks the hue as in use. */
191
+ hueFor(nid) {
192
+ const assignments = this.read();
193
+ const now = this.now();
194
+ const existing = assignments[nid];
195
+ if (existing) {
196
+ if (now - existing.seen >= SEEN_RESOLUTION_MS) {
197
+ assignments[nid] = { ...existing, seen: now };
198
+ this.write(assignments);
199
+ }
200
+ return existing.hue;
201
+ }
202
+ const hue = firstHue(nid, takenHues(assignments, nid, now));
203
+ assignments[nid] = { hue, seen: now };
204
+ this.write(assignments);
205
+ return hue;
206
+ }
207
+ /** 🎨 Recolor: move `nid` somewhere new and clear of the others, and
208
+ * remember the hue it left so the next few presses don't go back to it. */
209
+ recolor(nid) {
210
+ const assignments = this.read();
211
+ const now = this.now();
212
+ const current = assignments[nid]?.hue ?? speakerHue(nid);
213
+ const rejected = [{ hue: current, at: now }, ...(assignments[nid]?.rejected ?? [])]
214
+ .filter((r) => now - r.at <= REJECTED_MS)
215
+ .slice(0, REJECTED_LIMIT);
216
+ const hue = recolorHue(current, takenHues(assignments, nid, now), {
217
+ rejected: rejected.map((r) => r.hue),
218
+ random: this.random,
219
+ });
220
+ assignments[nid] = { hue, seen: now, rejected };
221
+ this.write(assignments);
222
+ return hue;
223
+ }
224
+ }
225
+ //# sourceMappingURL=glow-hues.js.map
@@ -0,0 +1,62 @@
1
+ // The hex view's two rules, window-free so the daemon (which decides a file
2
+ // is binary) and the app (which lays the bytes out) agree without a browser.
3
+ /** Bytes a binary file is served in, per request. 16 KB is 1,024 rows: enough
4
+ * to read a header by, and few enough DOM lines for a phone. */
5
+ export const HEX_PAGE_BYTES = 16 * 1024;
6
+ /** How far into a file the binary check looks — git's figure. */
7
+ export const BINARY_SNIFF_BYTES = 8000;
8
+ export const HEX_ROW_BYTES = 16;
9
+ /** Columns a row takes at `rowBytes` bytes: offset, gap, bytes, group gap,
10
+ * space, gutter. What the view measures against its width. */
11
+ export function hexRowColumns(rowBytes) {
12
+ return 8 + 2 + rowBytes * 3 + (rowBytes > 8 ? 1 : 0) + 1 + rowBytes;
13
+ }
14
+ /** A file is binary if a NUL appears in its first 8000 bytes: git's rule, and
15
+ * the one every diff tool and pager the operator uses already agrees with.
16
+ * UTF-16 text reads as binary under it, which is what git shows too. */
17
+ export function looksBinary(head) {
18
+ const n = Math.min(head.length, BINARY_SNIFF_BYTES);
19
+ for (let i = 0; i < n; i++)
20
+ if (head[i] === 0)
21
+ return true;
22
+ return false;
23
+ }
24
+ export function hexRows(bytes, offset = 0, rowBytes = HEX_ROW_BYTES) {
25
+ const rows = [];
26
+ for (let at = 0; at < bytes.length; at += rowBytes) {
27
+ let hex = "";
28
+ let ascii = "";
29
+ for (let j = 0; j < rowBytes; j++) {
30
+ if (j === 8)
31
+ hex += " ";
32
+ const i = at + j;
33
+ if (i < bytes.length) {
34
+ const b = bytes[i];
35
+ hex += (b < 16 ? "0" : "") + b.toString(16) + " ";
36
+ ascii += b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : ".";
37
+ }
38
+ else {
39
+ hex += " ";
40
+ }
41
+ }
42
+ rows.push({ offset: hexOffset(offset + at), hex, ascii });
43
+ }
44
+ return rows;
45
+ }
46
+ /** Where byte `j` of a row starts in its `hex` column. */
47
+ export function hexColumnOf(j, rowBytes = HEX_ROW_BYTES) {
48
+ return j * 3 + (rowBytes > 8 && j >= 8 ? 1 : 0);
49
+ }
50
+ /** `xxd`'s layout: an 8-digit offset, the bytes (sixteen to a row in two
51
+ * groups of eight, or eight to a row on a phone), and the printable-ASCII
52
+ * gutter. `offset` is where `bytes` starts in the file, so a later page
53
+ * numbers its rows where they are. */
54
+ export function hexDumpRows(bytes, offset = 0, rowBytes = HEX_ROW_BYTES) {
55
+ return hexRows(bytes, offset, rowBytes).map((r) => `${r.offset} ${r.hex} ${r.ascii}`);
56
+ }
57
+ /** Eight hex digits, more past 4 GiB — the column widens rather than wraps. */
58
+ export function hexOffset(n) {
59
+ const s = n.toString(16);
60
+ return s.length >= 8 ? s : "0".repeat(8 - s.length) + s;
61
+ }
62
+ //# sourceMappingURL=hex-dump.js.map
@@ -0,0 +1,52 @@
1
+ // The hex view's page arithmetic, window-free so it can be tested without a
2
+ // browser. A file is shown as whichever of its 16 KB pages have been loaded,
3
+ // in place, with a gap row standing for each unloaded run between them; a
4
+ // search hit names bytes, and these say which pages and rows those bytes are
5
+ // on.
6
+ import { HEX_PAGE_BYTES } from "./hex-dump.js";
7
+ /** The page that byte `offset` is on. Pages sit on multiples of the page
8
+ * size, so two loaded neighbours always join without a seam. */
9
+ export function pageOf(offset, pageBytes = HEX_PAGE_BYTES) {
10
+ return offset - (offset % pageBytes);
11
+ }
12
+ /** Every page a hit of `length` bytes at `at` touches. */
13
+ export function pagesOfHit(at, length, pageBytes = HEX_PAGE_BYTES) {
14
+ const out = [];
15
+ for (let p = pageOf(at, pageBytes); p < at + Math.max(1, length); p += pageBytes)
16
+ out.push(p);
17
+ return out;
18
+ }
19
+ /** The file as it will be drawn: loaded pages in order, a gap wherever the
20
+ * next page doesn't start where the last one ended, and a trailing gap
21
+ * until the page holding the end has been read. `size` is the file's size
22
+ * when known. */
23
+ export function hexSegments(pages, size) {
24
+ const sorted = [...pages].sort((a, b) => a.offset - b.offset);
25
+ const out = [];
26
+ let at = 0;
27
+ let end = size;
28
+ for (const p of sorted) {
29
+ if (p.offset > at)
30
+ out.push({ kind: "gap", from: at, to: p.offset });
31
+ out.push({ kind: "page", offset: p.offset });
32
+ at = p.offset + p.length;
33
+ if (p.eof)
34
+ end = at;
35
+ }
36
+ if (end === null || at < end)
37
+ out.push({ kind: "gap", from: at, to: end });
38
+ return out;
39
+ }
40
+ /** A hit's bytes, row by row: for each row it touches, the row's start and
41
+ * the first and last byte of the hit within it (inclusive, 0-based in the
42
+ * row). A hit that wraps a row is two spans, which is how it is painted in
43
+ * both columns. */
44
+ export function rowSpans(at, length, rowBytes) {
45
+ const out = [];
46
+ const end = at + Math.max(1, length);
47
+ for (let row = at - (at % rowBytes); row < end; row += rowBytes) {
48
+ out.push({ row, first: Math.max(at, row) - row, last: Math.min(end, row + rowBytes) - 1 - row });
49
+ }
50
+ return out;
51
+ }
52
+ //# sourceMappingURL=hex-pages.js.map
@@ -0,0 +1,79 @@
1
+ /** The physics of the find-in-file hit drum, window-free.
2
+ *
3
+ * The drum's position is a real number in *hit units*: 2.0 means hit #3 is
4
+ * centered, 2.5 is halfway to #4. It does not wrap — reaching the first or
5
+ * last hit is information the reader needs — so a position past either end
6
+ * only ever exists as rubber-band overscroll on its way back. The page
7
+ * follows it in whole hits (`clampIndex`), snapping each onto the middle
8
+ * line as the drum passes it. */
9
+ /** The hit a position rests on: nearest, and never past either end. */
10
+ export function clampIndex(pos, count) {
11
+ if (count <= 0)
12
+ return -1;
13
+ return Math.max(0, Math.min(count - 1, Math.round(pos)));
14
+ }
15
+ /** How far past an end the drum can be pulled, in hits. */
16
+ export const RUBBER_BAND_LIMIT = 0.5;
17
+ /** Map a raw position (finger-tracked, unbounded) onto the drum: exact
18
+ * inside the range, and past either end an overscroll that grows ever more
19
+ * slowly toward `RUBBER_BAND_LIMIT` — the resistance that says "end". */
20
+ export function rubberBand(raw, count) {
21
+ const hi = Math.max(0, count - 1);
22
+ const band = (over) => (RUBBER_BAND_LIMIT * over) / (RUBBER_BAND_LIMIT + over);
23
+ if (raw < 0)
24
+ return -band(-raw);
25
+ if (raw > hi)
26
+ return hi + band(raw - hi);
27
+ return raw;
28
+ }
29
+ /** The nudge a step into an end gives the drum (hits/s): under the spring
30
+ * it travels about a quarter of a detent toward the wall and settles back,
31
+ * so Enter on the last hit visibly lands rather than doing nothing. */
32
+ export const BUMP_VELOCITY = 22;
33
+ /** Critically damped spring: fastest approach with no overshoot, so the page
34
+ * it drives never scrolls past a hit and comes back. */
35
+ export const SPRING_OMEGA = 16;
36
+ /** One integration step (semi-implicit Euler) toward `target`; `dt` in
37
+ * seconds, velocity in hits per second. */
38
+ export function springStep(x, v, target, dt, omega = SPRING_OMEGA) {
39
+ const a = -omega * omega * (x - target) - 2 * omega * v;
40
+ const nv = v + a * dt;
41
+ return { x: x + nv * dt, v: nv };
42
+ }
43
+ /** Close enough to call it landed. */
44
+ export function springAtRest(x, v, target) {
45
+ return Math.abs(x - target) < 0.002 && Math.abs(v) < 0.05;
46
+ }
47
+ /** How far a flick carries, in seconds of release velocity — the same
48
+ * projection iOS scroll views use (deceleration 0.998/ms ≈ 0.5 s), trimmed
49
+ * so a casual flick skims a handful of hits rather than the whole file. */
50
+ export const THROW_SECONDS = 0.35;
51
+ /** Where a release at `pos` moving `v` hits/s comes to rest: a whole hit,
52
+ * and never past either end — a flick into the last hit stops there, with
53
+ * its leftover velocity spent as a bounce against the wall. */
54
+ export function throwTarget(pos, v, count) {
55
+ return clampIndex(pos + v * THROW_SECONDS, count);
56
+ }
57
+ /** Release velocity from recent pointer samples, in px/ms: the slope over the
58
+ * last `windowMs`, so a finger that stopped before lifting throws nothing. */
59
+ export function releaseVelocity(samples, now, windowMs = 80) {
60
+ const recent = samples.filter((s) => now - s.t <= windowMs);
61
+ if (recent.length < 2)
62
+ return 0;
63
+ const first = recent[0];
64
+ const last = recent[recent.length - 1];
65
+ const dt = last.t - first.t;
66
+ return dt > 0 ? (last.y - first.y) / dt : 0;
67
+ }
68
+ /** A hit total short enough for the drum's face: exact below 1,000, then
69
+ * `1.2k`. Rounded down, so the total never claims a hit that isn't there;
70
+ * the ordinal beside it stays exact, which is what tells hit 1,234 from
71
+ * 1,235. */
72
+ export function compactCount(n) {
73
+ if (n < 1000)
74
+ return String(n);
75
+ if (n < 10000)
76
+ return `${Math.floor(n / 100) / 10}k`;
77
+ return `${Math.floor(n / 1000)}k`;
78
+ }
79
+ //# sourceMappingURL=hit-drum.js.map
@@ -0,0 +1,58 @@
1
+ /** Which narrative ids a screen is allowed to linkify.
2
+ *
3
+ * `narrativeIdLinks` links only ids that actually exist, so that arbitrary
4
+ * strings of the same shape don't become dead links. That refusal is right,
5
+ * but it makes the *set* load-bearing in a way that is easy to miss: an
6
+ * incomplete set is indistinguishable, on screen, from a narrative that isn't
7
+ * there. Nothing renders differently. Nothing is logged. A reference just
8
+ * quietly stops being a link.
9
+ *
10
+ * So the rule this module exists to enforce is: **linkification degrades, it
11
+ * does not switch off.** Ids come from every source the screen already has,
12
+ * ordered by how little can go wrong with them:
13
+ *
14
+ * viewing — the route param. No network, always available.
15
+ * pushed — the control channel's `state` frame, sent on connect over the
16
+ * socket the screen needs anyway, and covering every narrative
17
+ * including archived ones.
18
+ * listed — the `GET /api/narratives` sweep. The heaviest request the
19
+ * screen makes, and the one that fails first on a slow link.
20
+ *
21
+ * Before this, `listed` was the only source. On a slow or flaky connection it
22
+ * would fail, its rejection was swallowed, the effect could not re-fire, and
23
+ * every narrative reference on the screen stayed dead until a reload — while
24
+ * the conversation itself, fetched separately, rendered perfectly. That is
25
+ * what makes it read as "the linkifier is broken" rather than "a request
26
+ * didn't land".
27
+ *
28
+ * Window-free on purpose, like `view-url.ts`, so the property can be tested
29
+ * without a browser.
30
+ */
31
+ function addAll(into, from) {
32
+ if (!from)
33
+ return;
34
+ for (const n of from) {
35
+ if (n && typeof n.id === "string" && n.id)
36
+ into.add(n.id);
37
+ }
38
+ }
39
+ /** Every narrative id this screen can vouch for.
40
+ *
41
+ * Deliberately a union rather than a fallback chain: a source being present
42
+ * says nothing about it being complete. The `state` frame is a snapshot from
43
+ * connect time and misses anything spawned since; the list sweep is a moment
44
+ * later. Taking both costs nothing and each covers the other's gap.
45
+ *
46
+ * Note what is *not* a source: ids parsed out of event `re` arrays. Those are
47
+ * never validated on write, so trusting them would hand the dead-link problem
48
+ * back to whoever wrote the reference — which is exactly what the existence
49
+ * check is there to prevent. */
50
+ export function collectKnownNids(sources) {
51
+ const known = new Set();
52
+ if (sources.viewing)
53
+ known.add(sources.viewing);
54
+ addAll(known, sources.pushed);
55
+ addAll(known, sources.listed);
56
+ return known;
57
+ }
58
+ //# sourceMappingURL=known-nids.js.map
@@ -0,0 +1,56 @@
1
+ /** The browser's scroll keys, for a list that doesn't hold focus.
2
+ *
3
+ * A browser gives PageUp/PageDown, Home/End and the vertical arrows to the
4
+ * scroller that holds focus, or to the document when nothing does. The
5
+ * narrative list is an inner ScrollView, and the document has nothing to
6
+ * scroll on purpose (see public/index.html). So on a freshly loaded page, or
7
+ * after a click in the header, those keys reach no scroller and do nothing.
8
+ * The list screen hears them on `document` and moves its own scroller. This
9
+ * file works out where to scroll to. Deciding whether the screen should handle
10
+ * the key at all needs the DOM, so that part stays in the screen.
11
+ *
12
+ * The steps match Chromium's: 40 px per arrow press, and 87.5% of the visible
13
+ * height per page, so a line from the old page stays in view.
14
+ */
15
+ export const ARROW_STEP_PX = 40;
16
+ export const PAGE_FRACTION = 0.875;
17
+ const SCROLL_KEYS = new Set(["PageUp", "PageDown", "Home", "End", "ArrowUp", "ArrowDown"]);
18
+ /** Whether this keydown is one the list should scroll for. Modified keys are
19
+ * left alone: ⌘↑/⌘↓ are the browser's own top/bottom keys, ⇧↑/⇧↓ extend a
20
+ * selection, and ⌥ and ⌃ belong to the OS. */
21
+ export function isListScrollKey(e) {
22
+ if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey)
23
+ return false;
24
+ return SCROLL_KEYS.has(e.key);
25
+ }
26
+ /** The scrollTop that `key` should move a scroller to, or `null` if the key
27
+ * doesn't scroll.
28
+ *
29
+ * `from` is usually the scroller's current `scrollTop`. While a smooth scroll
30
+ * is still animating, pass the target it is heading for instead. Otherwise a
31
+ * second PageDown pressed mid-animation starts from wherever the animation
32
+ * had got to, and falls short of two pages. */
33
+ export function listScrollTarget(key, from, m) {
34
+ const max = Math.max(0, m.scrollHeight - m.clientHeight);
35
+ const page = Math.max(1, m.clientHeight * PAGE_FRACTION);
36
+ let to;
37
+ switch (key) {
38
+ case "ArrowUp":
39
+ to = from - ARROW_STEP_PX;
40
+ break;
41
+ case "ArrowDown":
42
+ to = from + ARROW_STEP_PX;
43
+ break;
44
+ case "PageUp":
45
+ to = from - page;
46
+ break;
47
+ case "PageDown":
48
+ to = from + page;
49
+ break;
50
+ case "Home": return 0;
51
+ case "End": return max;
52
+ default: return null;
53
+ }
54
+ return Math.min(max, Math.max(0, to));
55
+ }
56
+ //# sourceMappingURL=list-scroll-keys.js.map
@@ -0,0 +1,72 @@
1
+ /** Where the app and the HTML export have to agree about lists.
2
+ *
3
+ * The export never calls into here — micromark decides looseness itself, on
4
+ * the way to HTML, and emits `<li><p>…</p></li>` for a loose item against a
5
+ * bare `<li>…</li>` for a tight one. The app has no such luxury: it renders
6
+ * from mdast, where the flags survive but the decision does not, so it has to
7
+ * make the same call the HTML side already made. This is that call, written
8
+ * once and pinned by `src/test/markdown-lists.test.ts` *against micromark's
9
+ * own output* — the only way to know the two still agree.
10
+ */
11
+ /** CommonMark: an item is loose when it holds more than one block, which is
12
+ * what `spread` records. The flag is authoritative when present; the child
13
+ * count is the fallback for a tree built by hand rather than by a parser. */
14
+ function itemIsLoose(item) {
15
+ if (!item)
16
+ return false;
17
+ if (typeof item.spread === "boolean")
18
+ return item.spread;
19
+ return (item.children?.length ?? 0) > 1;
20
+ }
21
+ /** Whether a list renders loose — paragraph margins between items — or tight.
22
+ *
23
+ * Looseness is a property of the **list**, never of the item: CommonMark says
24
+ * a list is loose if *any* of its items are separated by a blank line, and
25
+ * every item then renders loose. So a two-item list whose first item is spread
26
+ * gives its second item paragraph margins too, and reading `item.spread`
27
+ * alone — the obvious shortcut — would space the two differently. */
28
+ export function listIsLoose(list) {
29
+ if (!list)
30
+ return false;
31
+ if (list.spread)
32
+ return true;
33
+ return (list.children ?? []).some(itemIsLoose);
34
+ }
35
+ /** Where a loose list item's top margin has to live.
36
+ *
37
+ * A list item row is two flex siblings — the marker `<Text>` and a `<View>`
38
+ * holding the content — top-aligned. So a margin on the *first paragraph*
39
+ * indents only the content column, and the marker stays parked at the top of
40
+ * the row: a `1.` floating ~8px above the line it numbers, reading as a
41
+ * superscript. Tight lists never showed it because their paragraph margins are
42
+ * zeroed anyway; loose lists show it on every item, because there the spacing
43
+ * is deliberate.
44
+ *
45
+ * CSS has no such failure mode — the `::marker` is aligned to the first line
46
+ * box of the item's principal block, so a `<p>` margin moves the marker with
47
+ * the text — which is why the export renders these correctly and the app does
48
+ * not. The fix is to put the margin somewhere it moves *both* halves of the
49
+ * row: on the row itself.
50
+ *
51
+ * The pair below has to be read together, since the whole point is that they
52
+ * add up to what the paragraph margins used to contribute on their own. */
53
+ /** The margin override for a paragraph, by where it sits in a list. `null`
54
+ * leaves the stylesheet's own `paragraph` margins alone. */
55
+ export function listParagraphOverride(where) {
56
+ if (!where.inListItem)
57
+ return null;
58
+ // A tight list runs its items together: no paragraph margins at all.
59
+ if (!where.loose)
60
+ return { marginVertical: 0 };
61
+ // Loose: the first block's top margin has moved to the row. Later blocks in
62
+ // the same item keep theirs — they sit below the marker, not beside it.
63
+ return where.firstBlock ? { marginTop: 0 } : null;
64
+ }
65
+ /** The row's own top margin — exactly the margin `listParagraphOverride` just
66
+ * took off the first paragraph, so the text lands where it always did and only
67
+ * the marker moves. `paragraphMargin` is the stylesheet's `paragraph`
68
+ * `marginVertical`, passed in rather than duplicated here. */
69
+ export function listItemRowMarginTop(loose, paragraphMargin) {
70
+ return loose ? paragraphMargin : 0;
71
+ }
72
+ //# sourceMappingURL=markdown-lists.js.map