From fff305334a73b19fba179f840b1edf70f85e2e25 Mon Sep 17 00:00:00 2001 From: Lachlan Kermode Date: Sat, 19 Sep 2026 20:50:26 +0200 Subject: [PATCH 01/39] Initial version of core --- .github/workflows/check.yml | 160 +- .gitignore | 10 + CLAUDE.md | 285 ++ Justfile | 187 + core/0.1.0/.gitignore | 3 + core/0.1.0/.marrow.typ | 720 ++++ core/0.1.0/Justfile | 26 + core/0.1.0/demo/pure/Justfile | 76 + core/0.1.0/demo/pure/card-gap.typ | 37 + core/0.1.0/demo/pure/display-parity.typ | 27 + core/0.1.0/demo/pure/excluded.typ | 70 + core/0.1.0/demo/pure/folded-height.typ | 35 + core/0.1.0/demo/pure/root-prefix.typ | 30 + core/0.1.0/demo/pure/root.typ | 33 + core/0.1.0/demo/pure/theme.typ | 28 + core/0.1.0/demo/rheo/Justfile | 45 + core/0.1.0/demo/rheo/check-native.sh | 68 + core/0.1.0/demo/rheo/check.sh | 781 +++++ .../demo/rheo/content/ideated-doctitle.typ | 11 + core/0.1.0/demo/rheo/content/ideated-id.typ | 27 + .../0.1.0/demo/rheo/content/ideated-named.typ | 27 + core/0.1.0/demo/rheo/content/ideated.typ | 49 + core/0.1.0/demo/rheo/content/index.typ | 113 + core/0.1.0/demo/rheo/content/lib.typ | 83 + core/0.1.0/demo/rheo/content/refs.bib | 13 + core/0.1.0/demo/rheo/content/refs.typ | 52 + core/0.1.0/demo/rheo/content/relations.typ | 54 + .../demo/rheo/content/same-title-pair.typ | 30 + .../demo/rheo/content/sub/deeper/page.typ | 141 + core/0.1.0/demo/rheo/content/sub/page.typ | 39 + core/0.1.0/demo/rheo/content/tags.typ | 143 + core/0.1.0/demo/rheo/native.typ | 19 + core/0.1.0/demo/rheo/rheo.toml | 30 + core/0.1.0/readme.md | 3041 +++++++++++++++++ core/0.1.0/src/base.typ | 236 ++ core/0.1.0/src/bib.typ | 205 ++ core/0.1.0/src/core.css | 1787 ++++++++++ core/0.1.0/src/data.typ | 423 +++ core/0.1.0/src/hyperlink.typ | 192 ++ core/0.1.0/src/idea.typ | 702 ++++ core/0.1.0/src/ideate.typ | 648 ++++ core/0.1.0/src/lib.typ | 60 + core/0.1.0/src/links.typ | 90 + core/0.1.0/src/outline.typ | 649 ++++ core/0.1.0/src/permalink.typ | 228 ++ core/0.1.0/src/pure.typ | 971 ++++++ core/0.1.0/src/row.typ | 197 ++ core/0.1.0/src/state.typ | 631 ++++ core/0.1.0/src/template.typ | 711 ++++ core/0.1.0/src/theme.typ | 172 + core/0.1.0/src/transclusion.typ | 554 +++ core/0.1.0/src/urls.typ | 153 + core/0.1.0/src/window.typ | 502 +++ core/0.1.0/test/inputs.typ | 40 + core/0.1.0/test/units.typ | 828 +++++ core/0.1.0/typst.toml | 35 + flake.lock | 61 + flake.nix | 35 + test/browser/harness.mjs | 142 + test/safari-bug.txt | 93 + 60 files changed, 16746 insertions(+), 92 deletions(-) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Justfile create mode 100644 core/0.1.0/.gitignore create mode 100644 core/0.1.0/.marrow.typ create mode 100644 core/0.1.0/Justfile create mode 100644 core/0.1.0/demo/pure/Justfile create mode 100644 core/0.1.0/demo/pure/card-gap.typ create mode 100644 core/0.1.0/demo/pure/display-parity.typ create mode 100644 core/0.1.0/demo/pure/excluded.typ create mode 100644 core/0.1.0/demo/pure/folded-height.typ create mode 100644 core/0.1.0/demo/pure/root-prefix.typ create mode 100644 core/0.1.0/demo/pure/root.typ create mode 100644 core/0.1.0/demo/pure/theme.typ create mode 100755 core/0.1.0/demo/rheo/Justfile create mode 100755 core/0.1.0/demo/rheo/check-native.sh create mode 100755 core/0.1.0/demo/rheo/check.sh create mode 100644 core/0.1.0/demo/rheo/content/ideated-doctitle.typ create mode 100644 core/0.1.0/demo/rheo/content/ideated-id.typ create mode 100644 core/0.1.0/demo/rheo/content/ideated-named.typ create mode 100644 core/0.1.0/demo/rheo/content/ideated.typ create mode 100644 core/0.1.0/demo/rheo/content/index.typ create mode 100644 core/0.1.0/demo/rheo/content/lib.typ create mode 100644 core/0.1.0/demo/rheo/content/refs.bib create mode 100644 core/0.1.0/demo/rheo/content/refs.typ create mode 100644 core/0.1.0/demo/rheo/content/relations.typ create mode 100644 core/0.1.0/demo/rheo/content/same-title-pair.typ create mode 100644 core/0.1.0/demo/rheo/content/sub/deeper/page.typ create mode 100644 core/0.1.0/demo/rheo/content/sub/page.typ create mode 100644 core/0.1.0/demo/rheo/content/tags.typ create mode 100644 core/0.1.0/demo/rheo/native.typ create mode 100644 core/0.1.0/demo/rheo/rheo.toml create mode 100644 core/0.1.0/readme.md create mode 100644 core/0.1.0/src/base.typ create mode 100644 core/0.1.0/src/bib.typ create mode 100644 core/0.1.0/src/core.css create mode 100644 core/0.1.0/src/data.typ create mode 100644 core/0.1.0/src/hyperlink.typ create mode 100644 core/0.1.0/src/idea.typ create mode 100644 core/0.1.0/src/ideate.typ create mode 100644 core/0.1.0/src/lib.typ create mode 100644 core/0.1.0/src/links.typ create mode 100644 core/0.1.0/src/outline.typ create mode 100644 core/0.1.0/src/permalink.typ create mode 100644 core/0.1.0/src/pure.typ create mode 100644 core/0.1.0/src/row.typ create mode 100644 core/0.1.0/src/state.typ create mode 100644 core/0.1.0/src/template.typ create mode 100644 core/0.1.0/src/theme.typ create mode 100644 core/0.1.0/src/transclusion.typ create mode 100644 core/0.1.0/src/urls.typ create mode 100644 core/0.1.0/src/window.typ create mode 100644 core/0.1.0/test/inputs.typ create mode 100644 core/0.1.0/test/units.typ create mode 100644 core/0.1.0/typst.toml create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 test/browser/harness.mjs create mode 100644 test/safari-bug.txt diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index a5fd271f..bc1fb74e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -14,20 +14,12 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v5 - with: - version: latest - - - uses: actions/setup-node@v5 - with: - node-version: '22' - - uses: extractions/setup-just@v3 - # Pinned to the version this repo is developed against, and the one all - # four manifests declare as their `compiler` floor (0.15.0). Installed - # from the release tarball rather than a third-party action: one pinned URL - # is easier to audit than an action's own moving parts. + # Pinned to the version this repo is developed against, and the one the + # manifest declares as its `compiler` floor (0.15.0). Installed from the + # release tarball rather than a third-party action: one pinned URL is + # easier to audit than an action's own moving parts. - name: Install Typst 0.15.1 run: | set -euo pipefail @@ -36,39 +28,64 @@ jobs: tar -xJf typst.tar.xz echo "$PWD/typst-x86_64-unknown-linux-musl" >> "$GITHUB_PATH" - # Pinned to the OLDEST rheo the four manifests declare as their floor, NOT - # to the newest rheo: the point is that the DECLARED floor is the floor - # actually tested, so a package that reaches for a newer surface fails - # here instead of on a user's machine. Nothing in rheo reads `min_version` - # yet, so this step is the only thing enforcing it. + # Pinned to the OLDEST rheo the demo projects need, NOT to the newest + # rheo: the point is that the DECLARED floor is the floor actually + # tested, so a package that reaches for a newer surface fails here + # instead of on a user's machine. rheo reads `min_version` itself and + # refuses a package above the running version, so this step and that + # check have to agree — if they drift, one of them is testing a fiction. + # + # core declares `min_version = "0.6.2"`, and NOT because the Typst + # surface moved — it has not since 0.6.0. Three requirements stack up. + # + # 0.6.0: `_resolve-dest` (`core/0.1.0/src/urls.typ`) emits a reserved + # `rheo-page:` link dest that only 0.6.0 and later rewrite into a + # depth-correct href; `core/0.1.0/demo/rheo/check.sh` catches the literal an + # older rheo ships instead. # - # `core` declares `[tool.rheo] min_version = "0.6.0"` because it REQUIRES - # a 0.6.0 surface: `_resolve-dest` (`core/0.1.0/src/urls.typ`) emits a - # reserved `rheo-page:` link dest, and only rheo 0.6.0 rewrites it - # into a depth-correct href. Under 0.5.2 the dest passes straight through - # and every page ships a literal `href="rheo-page:ideas:"`, which is - # what `core/0.1.0/demo/rheo/check.sh` catches. + # 0.6.1: where rheo learned to resolve a namespace it does not ship, via the + # `[packages.]` table a project needs to say where `@rookery` comes from + # at all — so no older rheo can consume this family under its own namespace. + # + # 0.6.2: 0.6.1 still located packages by probing Typst's directory layout in + # the two places that read a package's `.marrow.typ`, and a package fetched + # from a ref lives at a path keyed by its resolved commit, which no probe + # matches. Every page this family mints from marrow went missing, on a build + # that succeeded and warned about nothing. Since every consumer currently + # tracks a branch rather than a release, that is the ordinary case here, not + # an edge one. + # + # 0.6.3: where a namespace could first resolve from a directory on disk + # (`path`), which is what `[packages.rookery] path = "../../../.."` in + # `core/0.1.0/demo/rheo/rheo.toml` asks for. core's `min_version` stays at + # 0.6.2 because no package surface moved — the gap is entirely in how the + # DEMO PROJECT locates `@rookery`, so the version installed here is set by + # the demo's floor rather than the package's own. # # Pinned URL AND pinned digest, for the same auditability reason the # Typst step gives: a re-uploaded asset fails this step rather than # silently changing what CI tested. - - name: Install rheo 0.6.0 (the declared min_version floor) + - name: Install rheo 0.6.3 (the floor the demo project needs) run: | set -euo pipefail - # Digest of the v0.6.0 rheo-x86_64-unknown-linux-gnu.zip asset, published - # 2026-08-24. Read back with: - # gh release view v0.6.0 --repo freecomputinglab/rheo --json assets + # Digest of the v0.6.3 rheo-x86_64-unknown-linux-gnu.zip asset, published + # 2026-09-15. Read it back with (note the exact asset name: matching on + # "linux-gnu.zip" also catches the aarch64 build, and `digest` comes back + # "sha256:"-prefixed, which `sha256sum -c` will not accept): + # gh release view v0.6.3 --repo freecomputinglab/rheo --json assets \ + # --jq '.assets[] | select(.name == "rheo-x86_64-unknown-linux-gnu.zip") | .digest' \ + # | cut -d: -f2 # BUMP THIS IN THE SAME EDIT AS THE URL BELOW: the `sha256sum -c` is what # makes a re-uploaded or wrong-version asset fail here rather than silently # changing what CI tested. - sha256="05c40231acaaf58fd0e3cba0b40e1c1d1a939bfe6bea8e775868dbf6f9c79d7f" + sha256="15c6f8d9b61f37020e252fe97e7ad4fbfb437289b1241f40b56e791bde81d3e0" curl -fsSL -o rheo.zip \ - https://github.com/freecomputinglab/rheo/releases/download/v0.6.0/rheo-x86_64-unknown-linux-gnu.zip + https://github.com/freecomputinglab/rheo/releases/download/v0.6.3/rheo-x86_64-unknown-linux-gnu.zip echo "$sha256 rheo.zip" | sha256sum -c - unzip -q rheo.zip -d rheo-bin chmod +x rheo-bin/rheo echo "$PWD/rheo-bin" >> "$GITHUB_PATH" - rheo-bin/rheo --version # must print: rheo 0.6.0 + rheo-bin/rheo --version # must print: rheo 0.6.3 # First, because it is the cheapest failure in the repo and the one that # silently ships a broken release: a version directory cut by copying and @@ -81,10 +98,11 @@ jobs: # `@rookery` IS A CUSTOM NAMESPACE, NOT A TYPST-UNIVERSE ONE, so there is # no registry to download it from: it resolves only where the package - # cache already holds it. `search/0.1.0/src/lib.typ` imports - # `@rookery/core:0.1.0` for `ideas` and `note-href`, and `just parity` - # compiles that file through `typst eval` — so without this the next step - # dies with `error: package not found (searched for @rookery/core:0.1.0)`. + # cache already holds it. `core/0.1.0/demo/rheo/native.typ` imports + # `@rookery/core:0.1.0` by coordinate and is compiled by BARE `typst`, + # which knows only the cache — so without this the `check-typst` step + # below dies with + # `error: package not found (searched for @rookery/core:0.1.0)`. # # THE WHOLE NAMESPACE, not one package, and this is the same one-line # setup CLAUDE.md prescribes for a developer's machine. Linking a single @@ -93,10 +111,9 @@ jobs: # `ln -sfn TARGET DIR` then writes the link INSIDE it, leaving a # self-referential `/`. # - # Only `search` needs it by spec. `core`'s own fixture imports - # `/src/lib.typ` and `demo/pure` imports `../../src/lib.typ`, both by - # path — which is why those two steps have always passed while this one - # could not. + # The rheo build needs nothing from the cache: `demo/rheo/rheo.toml` + # declares `[packages.rookery] path = "../../../.."` and reads the + # package straight out of this checkout. - name: Resolve the @rookery namespace from this checkout run: | set -euo pipefail @@ -104,29 +121,18 @@ jobs: mkdir -p "$cache" ln -s "$PWD" "$cache/rookery" # Fail HERE with something legible rather than inside a typst error later. - test -f "$cache/rookery/search/0.1.0/typst.toml" - - # AFTER the namespace-symlink step: @rookery/timeline binds `#dated-idea` - # from `@rookery/core:0.1.0` — one line, and enough to need the cache. - # Still buildless, like core itself, so no vite step. - # - # `just test` here runs TWO fixtures: `test/units.typ` asserts values, and - # `test/view.typ` + `test/check.sh` assert the markup `#timeline-view` - # produces, which a paged compile cannot see. - - name: timeline unit and view fixtures - run: cd timeline/0.1.0 && just test - - # `just parity` is the only thing keeping the Typst and JavaScript copies - # of the ranking rule from drifting — two leaf scorers and the tiering rule - # above them. `just build` first: parity imports `src/`, but a build failure - # is worth catching here too, since publish-packages.yml only builds on main. - - name: search build and parity - run: cd search/0.1.0 && just build && just parity + test -f "$cache/rookery/core/0.1.0/typst.toml" # This repo's definition of "lint" is that a package's demo compiles # (CLAUDE.md, "Build"). `demo/pure` is plain `typst compile`, so it runs # anywhere. `just build`, not a hand-copied subset of its `typst compile` # lines, so a new root added to the Justfile is gated in CI automatically. + # + # What's left here is what the rheo demo's own native-Typst build (below) + # cannot cover: `excluded.typ`'s `--input` pair (`rheo compile` forwards no + # `--input` at all) and `root-prefix.typ`/`theme.typ`'s non-default + # `prefix:`/`theme:`, which is document-wide state and cannot share a + # document with content that hardcodes the default. - name: core pure demo compiles and checks, HTML and PDF run: cd core/0.1.0/demo/pure && just build @@ -134,42 +140,12 @@ jobs: # package-`.marrow.typ` still mints: `check.sh` asserts on the OUTPUT — # minted note pages, backlinks, depth-relative hrefs, and the generated # `@layer rookery-tags` rules — none of which `demo/pure` can reach. - # - # AFTER the namespace-symlink step above, not before: the demo's - # `.marrow.typ` needs `@rookery/core:0.1.0` to resolve from the cache. - name: core rheo demo compiles and asserts, under the declared floor run: cd core/0.1.0/demo/rheo && just check - # search's own rheo fixture. Its `check.sh` asserts what neither the - # `node --test` suite nor the parity harness can see: that rheo copied - # `dist/lib.js` and `dist/search.css` into the output, that BOTH are - # linked from every page at the right depth-relative prefix - # (`rookery/...` at the root, `../rookery/...` one level down), that the - # JSON island parses with one row per note, and that every href in it - # resolves to a file on disk. - # - # AFTER the namespace-symlink step, like core's own — and after the - # `search build and parity` step above, because `dist/` is gitignored and - # rheo cannot resolve the package until vite has written it. - - name: search rheo demo compiles and asserts - run: cd search/0.1.0/demo/rheo && just check - - # @rookery/todos needs three things in this order, which is why it sits - # here rather than beside the unit fixtures above: - # - it is a BUILT package (its release ships `dist/lib.js`, gitignored), - # so vite has to run before rheo can resolve the release path; - # - it hard-imports BOTH `@rookery/core:0.1.0` and - # `@rookery/timeline:0.1.0`, so it must follow the namespace symlink; - # - `just test-js` covers the graph layout, which is the half of this - # package the Typst fixture cannot reach. - - name: todos build, unit fixture and graph tests - run: cd todos/0.1.0 && just build && just test && just test-js - - # `just check` builds the demo and then asserts on its OUTPUT. The one - # assertion it exists for is the `.todo-search-row[hidden]` CSS rule: - # without it `#todos-search` sets `hidden` on every non-matching row and - # the stylesheet un-hides all of them, so the filter reorders the list and - # removes nothing. That compiles clean, passes the unit and JS suites, and - # looks correct in the markup — it is only wrong on screen. - - name: todos rheo demo compiles and asserts - run: cd todos/0.1.0 && just check + # The same content compiled a SECOND way, plain `typst compile`, no + # rheo — one document, one compile pass, no minted pages — and asserted + # by `check-native.sh`, not merely compiled. AFTER the namespace-symlink + # step, because the content imports `@rookery/core:0.1.0` by coordinate. + - name: core rheo demo's rookery compiles and asserts without rheo too + run: cd core/0.1.0/demo/rheo && just check-typst diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..febb950d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.direnv/** + +# Beads is machine-local in this repo, as it is everywhere else. Nothing under +# `.beads/` is tracked — not the db, not its locks, and not `issues.jsonl`. +# Tracking the export was tried here (see the CLAUDE.md note) and reverted: br +# reimports from that file, so a copy pulled from another machine can silently +# revert a close, and every mutation on two machines is a whole-file conflict in +# a file no one reads by hand. +.beads/** +.abacus/** diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..858bfdbd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,285 @@ +# CLAUDE.md — rookery + +The `@rookery` family of [Rheo](https://rheo.ohrg.org) Typst packages: atomic, +interlinked, transcludable notes (`core`), fuzzy search over them (`search`), +a dated lifecycle log (`timeline`), todos/epics/a dependency DAG (`todos`), +dated meeting notes (`meetings`), a BibTeX reader and `#citation` note +constructor (`bibtex`), an endlessly scrolling presentation over notes +(`slipshow`), a board of draggable cards for arranging notes by hand +(`pinboard`), and venues with their calls and deadlines (`cfps`). Each package +lives in `//` (e.g. `search/0.1.0/`) +and mirrors the same layout: `typst.toml`, `src/`, and a `Justfile`, plus a +`flake.nix` where the package pins a toolchain of its own (`search` for +node/pnpm, `slipshow` likewise). Four of them (`search`, `todos`, `slipshow`, +`pinboard`) also ship JS via `package.json`/vite — see "Pure-Typst packages" +below for the five that don't. + +This repo was split out of `rheo-packages` (`freecomputinglab/rheo-packages`) +on 2026-08-30, once `@rookery` needed a repository URL of its own to resolve +as a git-backed namespace — see that repo's `CLAUDE.md` and its +`rheo-packages-prerelease-coords` decision record for why coordinates never +change and a namespace is instead backed by a git ref declared in a +project's `rheo.toml`. + +`search` (fuzzy search over a rookery — ranking, a JSON index, an embeddable +search bar, and an overlay search modal) imports `core` for its `ideas()` and +`idea-href()` primitives. It is built like every other JS package here; core +is not. A project using it must import BOTH in its own `.typ` files — see +that package's readme for why. + +`todos` then imports `search` in ONE file, `table.typ`, which builds +`#todo-table` atop that package's `#panel`, with pills that know the todo +graph. That edge was forbidden until it was needed: `ready` and `blocked` are +derived in `todos` and nowhere else, so a panel that cannot press them is the +one thing every consuming site hand-rolls. `#todos-search` still reaches for +nothing in `search` — see that package's `search.typ` for which half of the +old rule still holds. + +Those two edges are not the whole graph. Every package imports `core`, and +three also import `timeline`: + +``` +core -> nothing +search -> core +bibtex -> core +slipshow -> core +timeline -> core +meetings -> core, timeline +todos -> core, timeline, search, slipshow +pinboard -> core +cfps -> core, timeline, todos +``` + +`timeline` is `todos`'s heaviest edge — six files import it: +`src/skin.typ:19-20`, `src/views.typ:21`, +`src/graph.typ:8`, `src/table.typ:32,37`, `src/todo.typ:4`, `src/tags.typ:37`. +Its `slipshow` edge is one file, `src/deck.typ:27`, and its `search` edge is +the one file described above, `src/table.typ:30`. `meetings`'s `timeline` edge +is `src/lib.typ:22`. + +`cfps` is the only package importing `todos`, and it does so because a cfp IS a +todo: `#cfp` mints through `todo(..)` rather than hand-rolling a closing flag, +so closedness, priority and the dated log all come from the packages that +already define them. Both of its source files carry all three edges — +`src/cfp.typ:31-33` and `src/panel.typ:16-18`. + +## Build + +- Per package: `cd / && just build` (copies/bundles `src/` into + `dist/`, where applicable). `dist/` is gitignored — it is a build artifact. +- All packages: `just build` at the repo root (walks every nested `Justfile`). +- There is no separate linter. "Lint" = the package builds and its demo/test + project compiles with `rheo compile`. + +## Comment style + +A comment here describes the code as it stands. Not how it got that way, not +what it replaced, not which version moved it — a reader arriving cold needs the +current shape and the reasons it holds, and everything else is a diff they can +read in `jj log` if they want it. + +- **Describe the present.** Say what the code is and why it is that way. Never + what it used to be, what moved where, which release changed it, or that + something "is gone". "The scorer lives in `score.typ`" is worth a line; "the + scorer used to live in `tagquery.typ`" is worth none. +- **No issue ids.** Never name a bird, a bookmark or a branch. The + argument for a line has to stand on its own, because most readers of it have + no access to the tracker and no interest in one. +- **Keep the measurement, drop the lab notebook.** A number that justifies a + constant stays, and says what it buys: 48 terms, a 40% document-frequency + ceiling, 12 keyword chips. The machine it was measured on, the date, the + baseline it beat and the alternatives that lost do not — unless a future + reader would otherwise retune the number, and then one sentence, not a + paragraph. +- **One header per file, no interior banners.** The first comment block says + what the file is. A `// ---- Some Section ----` divider restating it is + forbidden: the module is the section, and a file needing dividers is a file + wanting to be two. +- **Comment the non-obvious.** A comment restating the line under it earns + nothing. What earns its place: a constraint the code cannot express (a + stylesheet rule the script depends on, an import order a closure makes + load-bearing), a contract a caller would otherwise get wrong, a rule shared + with another language. +- **Declarative and concise.** Present tense. Emphasis capitals are for the one + claim in a block that carries it, not for every second clause, and a comment + should not argue with mistakes its reader has not made yet. Aim for files + where comments are a minority of the lines. + +The one exception is **parity**. A comment may name its counterpart in the other +language — `score.typ` naming `src/score.js`, `tagquery.typ` naming +`src/tagquery.js`, either naming `just parity` as what pins them — because that +is a present-tense fact about how the code is arranged, and the two halves +cannot be changed apart. + +## Local development against a live rheo project + +`@rookery/` resolves from the Typst package cache +(`~/.cache/typst/packages/rookery//`). As in `rheo-packages`, +this is a REAL directory that can hold either a symlink into a checkout or a +downloaded copy of a published release — check which one is there before +trusting an edit to show up: + +```sh +ls -la ~/.cache/typst/packages/rookery// # symlink, or a real directory? +mkdir -p ~/.cache/typst/packages/rookery/ +ln -s "$PWD//" ~/.cache/typst/packages/rookery// +``` + +Note the link path ends in the VERSION and must not exist yet. `ln -sfn +TARGET DIR` where `DIR` already exists as a directory writes the link +*inside* it, leaving a self-referential nested symlink — `rm -rf` the stale +entry first rather than trying to overwrite it, then confirm with `jj status` +that nothing landed in the tree. + +Then `just build` the package (skip this for `core`/`timeline`/`meetings`/ +`bibtex`/`cfps`, the five dist-less pure-Typst packages — see "Pure-Typst +packages" below) and `rheo +compile` a test project that imports it. No per-package devShell needed +either for most work: this repo's own root `flake.nix`/`.envrc` provide +`just` and `typst`, and direnv finds them by walking up from anywhere under +the repo. `search/0.1.0` and `slipshow/0.1.0` each carry their own `flake.nix` +on top of that, for `node`/`pnpm` pinned to that package specifically. + +## Pattern: consuming the injected `rheo-context` + +Core rheo injects per-file build context that a package cannot read +implicitly — a Typst function captures its definition scope, not the call +site. There are two valid patterns, depending on what the package needs. + +### Pattern A — template packages: explicit `ctx:` + +A package that needs the CURRENT FILE's own handle (for a per-page template, +nav, etc.) takes it as an explicit parameter, because only the call site (the +vertebra itself) has `rheo-context` in scope: + +```typ +#import "@rookery/:x.y.z": template +#show: template.with(ctx: rheo-context()) +``` + +**Guard requirement (do this in every package using this pattern):** assert +that `ctx` is a valid rheo-context and fail with a message pointing to rheo. +Put the assert at the top of the template, before any use of `ctx`: + +```typ +#let template(ctx: none, doc) = { + assert( + type(ctx) == dictionary and "handle" in ctx, + message: "@rookery/: the template needs the per-file `rheo-context` " + + "injected by Rheo. Apply it as `#show: template.with(ctx: rheo-context())` " + + "and compile the project with Rheo (https://rheo.ohrg.org), not native Typst.", + ) + // ... +} +``` + +This catches the common misuses: `ctx` omitted, passed as `none`, or not +rheo-context-shaped. + +**Detect a rheo build before calling `rheo-context()`** rather than letting +pure native `typst compile` hard-error on an unbound variable: + +```typ +#let ctx = if "rheo-context" in sys.inputs { rheo-context() } else { + panic("@rookery/: compile this project with Rheo (https://rheo.ohrg.org), not native Typst.") +} +``` + +`sys.inputs` is global to the bundle compile, so it is readable even where +the per-vertebra `rheo-context()` binding is not — this gives a native-Typst +build the friendly panic message instead of Typst's own +`unknown variable: rheo-context`. Still true, and still worth the warning: +**any in-file fallback binding of `rheo-context` clobbers rheo's real +injection.** An `#import "@rookery/": rheo-context` sentinel, or a +top-level `#let rheo-context = ...`, both shadow the injected value under +rheo (the injection is prepended, so a later import/let wins). So do NOT +ship a package-level `rheo-context` fallback binding — it breaks the rheo +build; use the `sys.inputs` guard above instead. + +### Pattern B — packages that work without rheo: feature-detect + +A package that doesn't need the CURRENT FILE's handle — only the shared +spine-wide data, or nothing rheo-specific at all — should instead detect +rheo's presence rather than require it. This is the pattern for a package +whose primary supported mode is running under plain `typst compile` with no +rheo present at all. + +- Shared spine data (title, spine tree, etc.) is reachable from ANY scope via + `sys.inputs`, not just the injected per-file binding: + + ```typ + #let rheo-context() = sys.inputs.at("rheo-context", default: (spine-flat: ())) + ``` + + Falls back to an empty/absent spine when built without rheo — no assert, + no error, because running without rheo is the primary supported mode for + this kind of package. For the exact keys `sys.inputs.rheo-context` carries + and their stability, see rheo core's `docs/contract.md` rather than + re-deriving the field list here. + **`sys.inputs.rheo-context` carries no `handle`** (it is bundle-global, + not per-file) — a package needing the CURRENT file's own handle still + needs Pattern A's `rheo-context()` or `state("rheo-handle")` below. +- The CURRENT OUTPUT PAGE's own handle (if needed) is `state("rheo-handle")`, + which rheo publishes per page in `rheo-page-init` (rheo core's + `crates/core/src/typ/rheo.typ`; the checkout is at `/home/lox/code/_fcl/rheo` + on this machine) — readable from package scope without any `ctx:` + parameter. +- `@rookery/core` uses this pattern throughout and takes NO `ctx` parameter at + all: see `core/0.1.0/src/lib.typ`'s `_rheo-ctx()`/`_target()` helpers. + +DO NOT assert or panic when rheo is absent under this pattern — that's +Pattern A's job for packages that genuinely can't function without rheo. +Pick the pattern by whether the package's primary mode is "always under +rheo" (A) or "works standalone, rheo optionally enhances it" (B). + +## Pure-Typst packages + +`core`, `timeline`, `meetings`, `bibtex` and `cfps` are pure Typst (+ CSS) — no +`package.json`, no `pnpm-lock.yaml`, no build step at all: `typst.toml`'s +`entrypoint` and `css_stylesheet` point straight at `src/` — editing `src/` +takes effect immediately, nothing to rebuild or forget to re-run. + +`search`, `todos`, `slipshow` and `pinboard` are ORDINARY built packages — +`package.json` + vite. `search` shares core's name-space and hard-imports it, and is +nonetheless built because search is only worth having with JavaScript; core +ships none. Splitting kept that true instead of trading it away. + +The built shape is narrower than "everything lives in `dist/`", though: a +built package's `entrypoint` and `css_stylesheet` point at `src/` — vite only +copies those files into `dist/` byte-identically, so the manifest names the +originals — and `dist/` holds ONLY the built JavaScript bundle +(`dist/lib.js`). Each also declares a `[tool.rheo.source.html]` block listing +its unbundled `src/*.js` files as ES modules, dependency-first, for when the +package is resolved from a git ref rather than a release: see +`rheo-packages`' `rheo-packages-prerelease-coords` decision record for why +that split exists. This is what lets a package be consumed straight off a +git ref with no build step at all: `src/` is checked in and carries a +working entrypoint, stylesheet AND (via the source block) JavaScript on its +own; only the optimized bundle is genuinely missing outside a release. + +`.github/workflows/publish-packages.yml` handles two cases per package: a +`package.json` present means `pnpm install && pnpm run build`; its absence +means no build step at all. The release archive step tars `src/` always, and +ADDS `dist/` on top of it when the build produced one — so +`core`/`timeline`/`meetings`/`bibtex`/`cfps` ship their `src/` directly, and +`search`/`todos`/`slipshow`/`pinboard` ship both `src/` +(entrypoint, stylesheet, source-mode scripts) and `dist/` (the optimized JS +bundle). + +## Cross-package data without an import: the beacon protocol + +A package can contribute data to another package it has no import +relationship with, in either direction, by emitting a `#metadata((..)) +