ADR-0002 — Persistent background runtime across navigation
Status: Accepted (documents the existing decision, commit fbb781d)
2026-10: No longer applies to the site. The black hole left the site shell in the civic redesign (DS-0) and is parked in
packages/black-hole(plan §6). Still describes the runtime for when it returns as its own project.
Context
The black-hole background is the site’s identity. Astro view transitions swap the DOM
on navigation, unmounting Vue islands — a naïvely mounted Three.js scene would be
destroyed and rebuilt (300k-particle buffers, shader compile, context creation) on
every route change, producing a visible blink and wasted work. Additionally, the site
preserves scrollY across swaps (Layout.astro) so the camera choreography reads as
one continuous space.
Problem
Keep one living WebGL scene — clock, camera, GPU buffers, canvas — alive across page navigations, while Vue components mount/unmount around it.
Decision
A module-level registry (Map<string, PersistentEntry> in
composables/blackHole/engine.ts) owns runtimes keyed by name. The runtime owns the
renderer and its <canvas>; Vue components borrow it: on mount they attach() the
existing canvas into their container and subscribe; on unmount they detach() and
release(). A subscriber count stops the rAF loop at zero and resumes on next mount.
Scene state is never serialized because the simulation is stateless-GPU (ADR-0004) —
“state” is the clock plus camera position, which persist in the module.
Alternatives considered
- Recreate per page: simplest; unacceptable blink + rebuild cost; breaks continuity, which is the product.
transition:persiston the island: Astro can persist DOM subtrees across swaps. Attractive, but couples the canvas’s survival to Astro’s persistence semantics and to the island staying structurally identical across layouts; the module registry keeps survival independent of the router. Worth re-testing periodically as Astro matures.- Fully headless singleton (no Vue involvement): mount the canvas once in
Layout.astrooutside the swap region and never detach. Fewer moving parts, but loses per-page control (which page shows/hides/configures the background) and Vue-side reactivity to scroll/size. - Serialize state + rebuild: unnecessary given statelessness; strictly worse.
Tradeoffs
- (+) Zero-cost navigation; the effect nobody else’s portfolio has.
- (+) Subscriber counting stops rendering when nothing displays it.
- (−) Module-level mutable state: invisible to Vue devtools, survives HMR oddly in dev.
- (−) The renderer is intentionally never disposed — a deliberate leak of one context.
- (−) Failure modes (context loss, attach to detached DOM) are the app’s to handle; context loss is currently unhandled (rendering.md R2).
Consequences
- Anything needing cross-page continuity should join this pattern (registry key per concern) rather than inventing another.
- The Vue adapter (
usePersistentBlackHole) is the single gathering point for site state → frame input; engine API v2 (rendering.md R3) formalizes it. - Never introduce a full page reload on internal navigation; it resets the world.
Future evolution
- Engine API v2 (
FrameInputobject,setQuality,setPalette) — planned. - If a second persistent runtime ever appears (e.g., audio), extract the registry mechanics into a tiny shared helper; until then it stays local.