brane 0.3.2 → 0.3.4

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.
@@ -0,0 +1,62 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { QuipLoop, shuffled } from "../shared/membrane-quips.js";
4
+ // The caption's order: one shuffle per page load, then a fixed loop through
5
+ // it — every line once before any line twice, the same order each time round.
6
+ const list = ["a", "b", "c", "d", "e"];
7
+ /** A seeded generator, so a "random" shuffle is the same on every run. */
8
+ function seeded(seed) {
9
+ return () => {
10
+ seed = (seed * 1103515245 + 12345) % 2 ** 31;
11
+ return seed / 2 ** 31;
12
+ };
13
+ }
14
+ describe("shuffled", () => {
15
+ it("is a permutation, and leaves its input alone", () => {
16
+ const before = [...list];
17
+ const out = shuffled(list, seeded(7));
18
+ assert.deepEqual([...out].sort(), before);
19
+ assert.deepEqual(list, before);
20
+ });
21
+ it("can produce every order", () => {
22
+ const seen = new Set();
23
+ for (let s = 1; s < 2000; s++)
24
+ seen.add(shuffled(list.slice(0, 3), seeded(s)).join(""));
25
+ assert.equal(seen.size, 6);
26
+ });
27
+ it("clamps a rand that returns 1", () => {
28
+ assert.equal(shuffled(list, () => 1).length, list.length);
29
+ });
30
+ });
31
+ describe("QuipLoop", () => {
32
+ it("shows every line once per cycle, and the same cycle each time round", () => {
33
+ const loop = new QuipLoop(list, seeded(3));
34
+ const first = [loop.current()];
35
+ for (let i = 1; i < list.length; i++)
36
+ first.push(loop.next());
37
+ assert.equal(new Set(first).size, list.length);
38
+ const second = [loop.next()];
39
+ for (let i = 1; i < list.length; i++)
40
+ second.push(loop.next());
41
+ assert.deepEqual(second, first);
42
+ });
43
+ it("never shows the same line twice in a row", () => {
44
+ const loop = new QuipLoop(list, seeded(11));
45
+ let prev = loop.current();
46
+ for (let i = 0; i < list.length * 3; i++) {
47
+ const q = loop.next();
48
+ assert.notEqual(q, prev);
49
+ prev = q;
50
+ }
51
+ });
52
+ it("does not move until told to", () => {
53
+ const loop = new QuipLoop(list, seeded(5));
54
+ assert.equal(loop.current(), loop.current());
55
+ });
56
+ it("holds a single line", () => {
57
+ const one = ["solo"];
58
+ const loop = new QuipLoop(one);
59
+ assert.equal(loop.next(), one[0]);
60
+ });
61
+ });
62
+ //# sourceMappingURL=membrane-quips.test.js.map
@@ -1,6 +1,6 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { MO_GLYPH_BOX, MO_GLYPH_PATH } from "../shared/mo-glyph.js";
3
+ import { MO_GLYPH_BOX, MO_GLYPH_MARGIN, MO_GLYPH_PATH } from "../shared/mo-glyph.js";
4
4
  test("mo-glyph: closed subpaths, absolute commands only, every point inside the box", () => {
5
5
  assert.match(MO_GLYPH_PATH, /^M/);
6
6
  assert.match(MO_GLYPH_PATH, /Z$/);
@@ -18,5 +18,7 @@ test("mo-glyph: closed subpaths, absolute commands only, every point inside the
18
18
  const mid = MO_GLYPH_BOX / 2;
19
19
  assert.ok(Math.abs(Math.min(...xs) + Math.max(...xs) - 2 * mid) < 2);
20
20
  assert.ok(Math.abs(Math.min(...ys) + Math.max(...ys) - 2 * mid) < 2);
21
+ // The caption under the glyph is spaced by this (MembraneEmpty.web.tsx).
22
+ assert.ok(Math.abs(Math.min(...ys) - MO_GLYPH_MARGIN) < 0.01, `ink top is ${Math.min(...ys)}`);
21
23
  });
22
24
  //# sourceMappingURL=mo-glyph.test.js.map
@@ -1,6 +1,6 @@
1
1
  import { describe, it } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { CANONICAL_TABS, isIsolated, parseActiveTab, parsePinnedTabs, serializePinned, viewParams, parseLineSpec, buildIsolateUrl, buildMeldUrl, terminalOnlyHref, withoutConnectionHash, withoutConnectionParams, connectionFragment, } from "../shared/view-url.js";
3
+ import { CANONICAL_TABS, isIsolated, parseActiveTab, parsePinnedTabs, serializePinned, viewParams, parseLineSpec, buildIsolateUrl, buildMeldUrl, terminalOnlyHref, withoutConnectionHash, withoutConnectionParams, connectionFragment, withDaemonFragment, daemonFromHash, activeDaemonBraneId, daemonToAdopt, } from "../shared/view-url.js";
4
4
  // These pin the URL contract that the Isolate menu item depends on. It opens
5
5
  // the same narrative on the *other* origin — a shared app, or a daemon's own
6
6
  // bundle — and the tab layout and open file live in sessionStorage, which is
@@ -219,6 +219,9 @@ describe("withoutConnectionHash", () => {
219
219
  ["#other=1&token=t", "#other=1"],
220
220
  ["host=h:1&token=t", ""],
221
221
  ["#token=a&token=b&x=1", "#x=1"],
222
+ // `daemon` names which daemon too, so it is consumed like the token.
223
+ ["#daemon=brane-f66d2b", ""],
224
+ ["#host=h:1&daemon=brane-f66d2b&token=t&x=1", "#x=1"],
222
225
  ];
223
226
  for (const [input, expected] of cases) {
224
227
  assert.equal(withoutConnectionHash(input), expected, JSON.stringify(input));
@@ -624,4 +627,106 @@ describe("terminalOnlyHref", () => {
624
627
  assert.equal(parsePinnedTabs(url.searchParams.get("pinned") ?? undefined).size, 0);
625
628
  });
626
629
  });
630
+ describe("withDaemonFragment", () => {
631
+ // A link opened in a new tab arrives with no active daemon in that tab's
632
+ // sessionStorage; the fragment is how it finds the one the link was written
633
+ // from. So the question each case asks: is the daemon named, in the
634
+ // fragment, and is the view the href's query already described left exactly
635
+ // as it was?
636
+ const BRANE = "brane-f66d2b";
637
+ it("names the daemon in the fragment of a bare path", () => {
638
+ assert.equal(withDaemonFragment("/", BRANE), `/#daemon=${BRANE}`);
639
+ assert.equal(withDaemonFragment(`/${NID}`, BRANE), `/${NID}#daemon=${BRANE}`);
640
+ });
641
+ it("leaves the query, the view, exactly as it was", () => {
642
+ const href = `/${NID}?tab=files&path=src%2Fa.ts&line=10-20`;
643
+ const url = new URL(withDaemonFragment(href, BRANE), "https://example.test");
644
+ assert.equal(url.pathname + url.search, href);
645
+ assert.equal(url.searchParams.get("daemon"), null);
646
+ assert.equal(daemonFromHash(url.hash), BRANE);
647
+ });
648
+ it("replaces a daemon already there, and keeps any other fragment param", () => {
649
+ const url = new URL(withDaemonFragment(`/${NID}?scrollToSeq=4#x=1&daemon=old`, BRANE), "https://example.test");
650
+ const frag = new URLSearchParams(url.hash.slice(1));
651
+ assert.deepEqual(frag.getAll("daemon"), [BRANE]);
652
+ assert.equal(frag.get("x"), "1");
653
+ assert.equal(url.searchParams.get("scrollToSeq"), "4");
654
+ });
655
+ it("returns the href unchanged when the daemon is not known", () => {
656
+ for (const none of [null, undefined, ""]) {
657
+ assert.equal(withDaemonFragment(`/${NID}?scrollToSeq=4`, none), `/${NID}?scrollToSeq=4`);
658
+ }
659
+ });
660
+ it("reads back through daemonFromHash, and is what the fragment cleaner removes", () => {
661
+ const url = new URL(withDaemonFragment(`/${NID}?scrollToSeq=4`, BRANE), "https://example.test");
662
+ assert.equal(daemonFromHash(url.hash), BRANE);
663
+ assert.equal(daemonFromHash(url.hash.slice(1)), BRANE);
664
+ assert.equal(withoutConnectionHash(url.hash), "");
665
+ assert.equal(withoutConnectionParams(url.search), "?scrollToSeq=4");
666
+ });
667
+ it("is absent from daemonFromHash when missing or empty", () => {
668
+ assert.equal(daemonFromHash(""), null);
669
+ assert.equal(daemonFromHash("#"), null);
670
+ assert.equal(daemonFromHash("#daemon="), null);
671
+ assert.equal(daemonFromHash("#token=t"), null);
672
+ });
673
+ it("is not carried by Isolate or Meld, whose token already names the daemon", () => {
674
+ // Isolate and Meld seed from the page's search string; a stray `daemon`
675
+ // there (an old link) must not ride along in either half of the URL.
676
+ const opts = {
677
+ host: SHARED_SPA_HOST,
678
+ protocol: "https:",
679
+ search: `?scrollToSeq=4&daemon=${BRANE}`,
680
+ path: `/${NID}`,
681
+ conn: CONN,
682
+ view: viewParams("chat", new Set()),
683
+ };
684
+ for (const built of [buildIsolateUrl(opts), buildMeldUrl(opts)]) {
685
+ const { params, conn } = open(built);
686
+ assert.equal(params.get("daemon"), null, built);
687
+ assert.equal(conn.get("daemon"), null, built);
688
+ assert.equal(params.get("scrollToSeq"), "4", built);
689
+ assert.equal(conn.get("token"), CONN.token, built);
690
+ }
691
+ });
692
+ });
693
+ describe("the daemon a tab names, and the one it adopts", () => {
694
+ // The registry entries these read, as `expo/lib/registry.ts` stores them:
695
+ // `id` is the display key the tab's slot holds, `brane_id` what a link names.
696
+ const CHALK = { id: "ben@chalk", brane_id: "brane-aaaaaa" };
697
+ const SLATE = { id: "ben@slate", brane_id: "brane-bbbbbb" };
698
+ const BOTH = [CHALK, SLATE];
699
+ it("a link names the tab's active daemon", () => {
700
+ assert.equal(activeDaemonBraneId(BOTH, SLATE.id), SLATE.brane_id);
701
+ assert.equal(activeDaemonBraneId(BOTH, CHALK.id), CHALK.brane_id);
702
+ });
703
+ it("falls back to the first entry when the active id is missing or stale, as the layout connects", () => {
704
+ assert.equal(activeDaemonBraneId(BOTH, null), CHALK.brane_id);
705
+ assert.equal(activeDaemonBraneId(BOTH, "ben@gone"), CHALK.brane_id);
706
+ });
707
+ it("names nothing with nothing melded, so the link stays bare", () => {
708
+ assert.equal(activeDaemonBraneId([], null), null);
709
+ assert.equal(withDaemonFragment("/", activeDaemonBraneId([], null)), "/");
710
+ });
711
+ it("a new tab, whose slot is empty, adopts the daemon its link named", () => {
712
+ assert.equal(daemonToAdopt(BOTH, null, SLATE.brane_id), SLATE.id);
713
+ });
714
+ it("a tab that already has a daemon keeps it, so a reload never undoes a switch", () => {
715
+ // The router restores the initial fragment on its first URL write, so a
716
+ // tab opened on SLATE and later switched to CHALK can still say SLATE.
717
+ assert.equal(daemonToAdopt(BOTH, CHALK.id, SLATE.brane_id), null);
718
+ assert.equal(daemonToAdopt(BOTH, SLATE.id, SLATE.brane_id), null);
719
+ });
720
+ it("a daemon this browser has not melded is no instruction at all", () => {
721
+ assert.equal(daemonToAdopt(BOTH, null, "brane-cccccc"), null);
722
+ assert.equal(daemonToAdopt([], null, SLATE.brane_id), null);
723
+ });
724
+ it("round-trips: what one tab names, a fresh tab adopts", () => {
725
+ for (const from of BOTH) {
726
+ const href = withDaemonFragment(`/${NID}`, activeDaemonBraneId(BOTH, from.id));
727
+ const named = daemonFromHash(new URL(href, "https://example.test").hash);
728
+ assert.equal(daemonToAdopt(BOTH, null, named), from.id);
729
+ }
730
+ });
731
+ });
627
732
  //# sourceMappingURL=view-url.test.js.map
package/docs/math.md CHANGED
@@ -7,6 +7,7 @@ LaTeX in narrative markdown renders through **KaTeX**, in the app and in the HTM
7
7
  | `renderMathHtml()` | `src/shared/markdown-math.ts` | The one KaTeX call. `htmlAndMathml`; unparseable LaTeX falls back to the literal source, not KaTeX's red error markup |
8
8
  | `prepareMathMarkdown()` | `src/shared/markdown-math.ts` | Pre-parse normalization, run by both sides before micromark/mdast |
9
9
  | `mathHtmlExtension()`, `katexStyleFor()` | `src/math.ts` | Export only: micromark hookup, and the inlined stylesheet |
10
+ | `math()` | `src/math-syntax.ts` | Server only: the `$` syntax extension, vendored from `micromark-extension-math` (see below) |
10
11
  | `public/katex/` | `expo/` | The app's stylesheet + woff2, vendored by `npm run sync:katex` |
11
12
  | `diagnoseMarkdown()` | `src/markdown-diagnostics.ts` | The same pipeline run to *report* rather than render — see [Rendering Warnings](rendering-warnings.md) |
12
13
 
@@ -29,4 +30,6 @@ Two more things are less obvious than they look:
29
30
 
30
31
  **Fonts don't come along by themselves.** KaTeX lays its span tree out entirely in CSS and needs its woff2 faces; without them math is a pile of overlapping glyphs. The export inlines only the families the document reaches for (~166 KB of base64 for an equation-dense narrative, nothing at all for one with no math), derived from the stylesheet so a KaTeX upgrade can't silently drop a face. The app can't inline per document, so it serves `public/katex/` — vendored the way `pdf.min.mjs` already is, because metro bundles a CSS *import* but never copies the `url(fonts/…)` it points at. The `<link>` lives in **`public/index.html`**, which is the real HTML template; `app/+html.tsx` is kept in step but is not what ships.
31
32
 
33
+ **The server vendors the syntax half of `micromark-extension-math`.** That package's index loads its own `mathHtml()` too, and with it `katex` at the range it pins (`^0.16.0`). Root `overrides` dedupe that onto our katex in a checkout, but npm ignores a dependency's `overrides`, so every `npm install brane` got a second, older katex — never called, since `src/math.ts` is our `mathHtml`, but loaded, and flagged by `npm audit`. `src/math-syntax.ts` is upstream 3.1.0's tokenizers with TypeScript annotations and nothing else changed (80,000 fuzzed parses matched upstream event for event); the package is no longer a root dependency. The app still imports the package: it ships prebuilt, built under `expo/`'s own overrides. If upstream ever changes how it tokenizes, the two parsers can drift, so carry the change into `src/math-syntax.ts` by hand.
34
+
32
35
  `src/shared/markdown-math.ts` is also the first shared module to import an npm package. Metro resolves a module's dependencies from its own directory and `src/shared` sits outside the expo project root, so `metro.config.js` maps `katex` explicitly — the next shared module with a bare import will need the same.
package/docs/view-urls.md CHANGED
@@ -57,8 +57,11 @@ since the stack did not grow. So the URL keeps that state and Back returns to
57
57
  it, but it never costs an extra Back: browsing ten files is one entry, not ten.
58
58
  A bare link to the narrative you are in, with no seq, goes nowhere. The list
59
59
  is a jump too: **All narratives** in the switcher pushes it, so Back returns
60
- to the narrative, whether or not a list was already beneath. So does a
61
- swipe down on the narrative's header card, which dismisses it. So does
60
+ to the narrative, whether or not a list was already beneath. It is an anchor
61
+ like the prose links, so a ⌘-click opens the list in a new tab instead, on
62
+ the same daemon (see "A new tab names its daemon" below). (In 0.3.3 every
63
+ click opened a new tab; that was undone.) So does a swipe down on the
64
+ narrative's header card, which dismisses it. So does
62
65
  **Archive** in the narrative's 🦠 menu, once the daemon has confirmed it (the
63
66
  pushed list fetches on mount, and nothing is broadcast to correct a fetch that
64
67
  raced the archive); Back returns to the archived narrative. Archiving from the
@@ -137,3 +140,30 @@ come back across an in-app card tap and two tab changes. So the module-level
137
140
  consume is what gets the credentials into storage early, `clearMeldUrl` is
138
141
  what actually clears the address bar, and `clearStoredConnection` still ends
139
142
  the flow.
143
+
144
+ **A new tab names its daemon.** Which melded daemon a tab is on lives in that
145
+ tab's `sessionStorage` (`expo/lib/registry.ts`), and a tab opened from a link
146
+ starts with none: ⌘-click, middle-click, long-press "Open in New Tab" and
147
+ `target="_blank"` are all `noopener`, which gets no copy. It falls back to
148
+ this browser's *default* daemon, which on a shared app with several melded
149
+ need not be the one the link came from. So every in-app anchor names it:
150
+ `inAppLinkProps` (the `@nid`, `nid:seq` and file links in prose, the narrate
151
+ rows in tool groups, **All narratives**) renders its href with
152
+ `#daemon=<home brane_id>` (`DAEMON_PARAM`, `withDaemonFragment`),
153
+ while a plain click still navigates in place by the clean path. It goes in the
154
+ fragment because it says which daemon, not what you are looking at: it is a
155
+ connection param (`CONNECTION_PARAMS`), and like the token it never reaches the
156
+ shared host's edge, a daemon's request log or a link unfurler. On arrival,
157
+ `consumeConnectionFromLocation` in `expo/lib/api.ts` hands it to
158
+ `adoptDaemonByBraneId` at module evaluation, before the root layout reads the
159
+ registry, and cleans it off with the token. The rules are pure functions
160
+ beside the param (`activeDaemonBraneId`, `daemonToAdopt`), tested in
161
+ `src/test/view-url.test.ts`; `registry.ts` only reads and writes storage for
162
+ them. Adoption fills only an **empty** slot. The router restores the initial fragment on its first URL write (see
163
+ above), so the param can sit in the address bar after a daemon
164
+ switched later in that tab, and a reload must not switch back; a tab that has
165
+ a daemon, reloaded, restored or duplicated, keeps it. A brane_id this browser
166
+ has not melded is ignored, and the default applies. It is `daemon`, not
167
+ `brane`, because one daemon hosts several branes and the list already filters
168
+ by one. It names a daemon and carries no credential, so it is safe to copy.
169
+ Isolate and Meld never carry it: their token already picks the daemon.