---
name: koi-pond-demo
description: Build, run, debug, test, and deploy the koi pond showcase — a host pond that composites independently-implemented framework koi apps (React/Vue/Svelte/Solid/Preact/Lit/Angular/vanilla) into one 3D scene over @hyperfrontend/features. Use when working in apps/demos/koi-pond, touching the shared koi lib or its three.js renderer, editing or debugging a fish app, changing the pond host/relay/depth/ripple/roster logic, the pond↔fish or gallery↔pond contracts, the vendored lib/shell tarballs, the docs-site koi gallery entry, or the koi pond Railway deploy.
---

# Koi Pond Demo

Eleven Nx projects in `apps/demos/koi-pond/`. A **host** pond opens one **fish** feature app per koi (eight framework apps to draw from), each rendering one 3D koi into its own transparent iframe; the host composites them into one scene, owns the pointer, and relays neighbours. The pond is itself a **hostee** the docs-site gallery mounts. A shared **lib** (packed to a tarball) carries the model, contracts, the steering brain, the frame loop, geometry, the renderer-free 3D koi, and the three.js adapter. A **workbench** develops the koi model in isolation.

## Projects → dirs (Nx name = commit scope)

| Nx project           | Dir          | Role                                                                                     |
| -------------------- | ------------ | ---------------------------------------------------------------------------------------- |
| `demo-koi-lib`       | `lib/`       | model, contracts+wire, motion brain, runtime loop, geometry, `koi3d/`, `three/`, `solo/` |
| `demo-koi-pond`      | `host/`      | pond bed, water, pointer, depth, relay, curtain, shoal panel, overlay; hostee shell      |
| `demo-koi-fish-<fw>` | `fish-<fw>/` | one koi per framework: vanilla react vue svelte solid preact lit angular                 |
| `demo-koi-workbench` | `workbench/` | koi-model dev env, port 4283, aliased to `lib/src` (HMR, no repack)                      |
| —                    | `vendor/`    | one committed `demo-koi-lib` tarball; consumers `file:`-install it                       |
| —                    | `tools/`     | `refresh-lib.mjs` — repack + reinstall lib into every consumer                           |

## The thesis (what any change must not break)

The lib owns the **whole simulation**: body, physics, steering brain, the frame loop, the wire plumbing, the three.js stage, `fish.css`. Each fish app owns only its framework's **mounting, rendering, and lifecycle idiom** (`src/state/koi.ts` composing `createKoiRuntime`, plus one renderer/component module). The brain is configurable, not copied: `KoiMotionOptions.onDecision` watches what it commits to and `desire` biases the ladder; `createKoiRuntime` takes a `motionFactory` and a `rendererFactory`. Never re-fork per-fish brains, and never let a fish app grow simulation logic.

## How it connects

- **Two contracts.** Inner `lib/src/contract/koi-fish.contract.ts` **v0.8.0** (pond host ↔ each koi): host sends `pond`/`identity`/`neighbors`/`disturbance`/`depth`/`hover`/`sleep`/`pause` (press-to-hold; `pause {paused, resting}` holds position, keeps the scull and suppresses ALL held chrome) /`place` (drag a held koi to a pond point); koi emits `outline`/`depth-request`/`ripple-request`/`settled`. `identity` carries a required `instance` ordinal (0 = canonical fish, 1..n = duplicates) and the host-dealt `seed`; `outline` optionally carries `path` (up to 20 points of predicted advancement, capped by the producer). `outline`+`neighbors` are **schema-less** (hot path); the outline ordinarily carries `intent: KoiIntent {kind travel|avoid|depth-change, heading, gain, target, direction?, reachPx, clearancePx}`, suppressed while held; while HELD it instead carries `card: KoiCardPanel {frame, app, site, source}` and the host floats three real `<a target=_blank>` anchors (`.koi-card-link`) plus an inert `.koi-card-shield` (all stopPropagation on pointerdown; fish frames are pointer-transparent so fish-side links can never be clicked). Outer `host/koi-pond.contract.ts` **v0.2.0** (gallery ↔ pond): gallery sends `set-scene` (`card`/`full`) /`disturb`; pond emits `shoal`/`sequence-complete`/`close-request {source: button|escape}`. Escape inside the pond releases a held koi first, else emits `close-request` (`escape-close.ts` + `featureUi.requestClose(source)`, latch re-armed by `set-scene`) — the host page can never hear keydown once focus is in the pond's document. The pond re-emits `shoal` every 10s as a liveness roll call; the docs-site embed declares a demo offline after 30 quiet seconds. The gallery is never told how many apps are behind the scene.
- **The scene is decided once, before anything opens.** `wireSceneBoot(feature, scene, { hosted: feature.hosted })` (`host/src/feature/wire-contract.ts`): unhosted opens `full` in the same tick; hosted holds the water empty until the first `set-scene`, a non-`embedded` presentation (dialog/popup/host-opened tab reads as full immediately), or `SCENE_FALLBACK_MS` (1s). **An instance never morphs**: a contradicting `set-scene` later is diagnosed `scene:ignored` and changes nothing. `hosted` is the SDK handle's synchronous flag (features ≥0.8.0); the pond uses no URL params and no `window.parent`. Never describe `hosted: false` as "standalone" — every DisplayMode is hosted; say "top-level" or "a direct visit".
- **Two profiles.** `openProfile` in `pond.ts`: **card** = one koi, `KOI_FRAMEWORKS[bootHour % 8]` ordinal 0, world from `describePondForFrame(root.clientWidth, root.clientHeight)` (the card's own frame, not the screen through a keyhole), held `resting` from open and `place`d at the pond centre, all chrome hidden by `#pond[data-scene='card']`. **full** = the hour-anchored koi and the frameworks after it in list order (all ordinal 0), screen-derived world, growable by hand; how many is `openingShoal(root.clientWidth, root.clientHeight, device.cap)` in `runtime/device-tier.ts` — bands on the frame's geometric mean `sqrt(w*h)`: ≥1000px → 8, ≥640 → 5, ≥320 → 3, else 1, with an unmeasured frame standing in 800×600. Card is always 1 whatever it measures. Both clamp to `device.cap`. The centre `place` is not decoration: `entryStation` lays out the whole canonical shoal and relaxes it for separation, so on card-sized water every station is pushed to the reach cap and the lone koi hangs half outside the frame.
- **Every session is an instance.** `KoiInstanceId` is the branded string `framework:ordinal` (`host/src/scene/instance-id.ts`: `koiInstanceId`, `instanceFramework`, `instanceOrdinal`, `nextOrdinal`); the wire never sees it, the host converts at the channel boundary. Layers carry `data-fish` (framework) and `data-instance` (id). Relay self-exclusion, depth slots, retries, resurrection budgets, sequence tracking and held chrome are all per instance — that is what makes twins avoid each other. `koiVariantSeed(framework, ordinal)` deals a duplicate its own body; the runtime's `adopt()` rebuilds profile, brain and renderer around a dealt seed that differs from its canonical assumption.
- **The dynamic shoal.** `PondSceneHandle`: `addKoi(framework)` → new id or `null` at the cap (`onDiagnostic(null,'shoal:refused','<tier>-tier device seats <cap>')`); `removeKoi(id)` → `false` for unknown or the last fish (`'the pond is never empty'`), closes politely then tears the layer down on close or after a 4s grace, ordinal stays spoken for until the layer is gone, pending revives/retries die with it; `shoalState()` → `{tier, cap, roster}`. Device profile read once at construction (`runtime/device-tier.ts`, `navigator.deviceMemory`+`hardwareConcurrency`, no UA sniffing): **low** ≤2GB or ≤2 cores → cap 4; **high** ≥8GB and ≥8 cores → cap 12; **middle** (and anything withholding a signal, so Safari/Firefox) → cap 8. The low gate is narrow ON PURPOSE (it was ≤4 cores and put ordinary four-core laptops on a 4-koi shoal): what a device holds at once is the opening band, the cap only bounds hand-stocking. Eight frameworks, so duplicates are only reachable above 8 — the cap is the only duplicate gate.
- **The shoal panel** (`host/src/scene/roster.ts` `createShoalPanel`, `nav.koi-shoal` z-index 165) is the control surface and contains its own pointer/click events. Every framework keeps a row (presence dot, name linked to that app, count badge hidden at zero, add control, nested per-koi remove controls). Focus is the keyboard's hover: a framework name/add lights all its answering koi, a remove control lights one. A control stranded by a rewrite hands focus onward (row add → newest koi's control → row name). The cap is _stated_: at the ceiling every add disables and names the tier, else the note counts the room left. "View interactions" and the "Source on GitHub" link live in the panel. Under `PILL_BELOW_PX` (680) it collapses to a pill rather than hiding, so a phone in the full scene keeps every gesture.
- **World vs view.** `PondEnvironment.width/height` is the **virtual pond** (from `window.screen`, clamped 800×600..3840×2400, or from the card's frame); `pond.view {x,y,width,height}` is the visible window, recomputed on every resize. Simulation/spawn/steering read the world; camera/canvas/culling/pointer read the view. Resize must NEVER rebuild the world. A fish app always builds a screen-derived world at construction so it can swim unhosted, so `runtime.setPond` **re-stations** the koi whenever the announced world's dimensions differ from the one it guessed (`entryStation` in the world it was actually given, brain rebuilt). Without that a card koi keeps a screen-world station, sits far off card-sized water, and its renderer culls it forever: empty water behind an open curtain.
- **Mounting = embedded.** Host makes one `position:absolute;inset:0` layer per koi. z-index **is** the depth model (`depthZIndex`, seven levels): floor 0, fish z1..7, water z100, `#interactions` z120, card shield z158 / links z160, shoal panel z165, curtain z200, dialog close z210, vitals z220. Owning the layers is also what lets the host stand one down: `present` (a Set in `pond.ts`, driven by `open`/`close`/`error`/watchdog `status`) is the single source for `setLayerPresent`, the panel and the shoal count, because a frame the browser has killed keeps painting an **opaque crash placeholder**. `scene/resurrection.ts` re-opens a session whose `error: unresponsive` verdict outlives a 4s grace (backoff 4/12/36s, 3 attempts per episode, budget restored after 60s present; deferred while hidden; `forget(id)` when the roster drops a koi).
- **Interaction overlay.** The panel's "View interactions" flips `scene.setInteractions(on)`; the frame loop feeds `relay.latest` outlines to `scene/interactions.ts`. **One ink (`OVERLAY_INK`, white) at varying alpha — no code path produces a colour.** Field (`paintField`): the koi's REAL encounter window, not an approximation — a band `intent.clearancePx` either side of the heading running `intent.reachPx` ahead, which is exactly the pair `resolveEncounter` gates a crossing on (CPA within `ENCOUNTER_HORIZON_S` 2.4s and inside `max(len)*0.55 + both girths`). Hung from the reported NOSE (the point the narrow phase judges from) and opened `FIELD_STANDOFF_BODIES` 0.12 body ahead of it, so it can never paint over the koi at any build — the whole body lies behind that point. Drawn as ONE fill: `save/translate(nose+heading*mid)/rotate(heading)/scale(halfReach, clearance)` then a unit-circle arc under a radial gradient focused at `-FIELD_SOURCE` 0.74 (`FIELD_ALPHA` 0.26 → `FIELD_SHOULDER` 0.55 at `FIELD_SHOULDER_SHARE` 0.34 → 0 at the rim). The ellipse's axes ARE the reported reach and clearance; only the corridor's corners are given up, where the ink had already run out. Never go back to stacked shapes for the falloff (9 shells banded visibly and cost 9 fills); canvas 2D has no 2D gradient, so the koi's own frame is what makes one smooth in both directions. It cannot express the depth gate, and the sim has no view angle at all — the region is a swept corridor, not a cone. Pearls (`scene/pearl-trace.ts`): drawn from the outline's own `path` (the host predicts nothing), spacing `PEARL_SPACING_BODIES` 0.1, `PEARL_MAX` 10 alight, 5–6px, alpha 0.8 at the nose → 0.1 at the horizon; a pearl never moves once placed — `advanceTrace` consumes what the nose passed, cuts from the first pearl >`PEARL_TOLERANCE_PX` (4px) off the fresh path, mints from the last survivor. Caret (`scene/sliding-caret.ts`): a drawn nested double chevron (outer + a 0.5-scale inner set back inside its arms), sized from the reporting body (`CARET_ARM_BODIES` 0.028, clamped 3.2–8.5px) and orbiting `headCentre` at `beamOf(girth) + 0.12 body + CARET_ORBIT_BODIES 0.08 body` (~0.30–0.33 body, phenotype-derived), sliding `CARET_SLIDE_RAD_S` 2 rad/s toward `intent.heading`, which outruns any helm the brain has. Weight = `commitment(intent.gain)` (0.12 drift → alpha 0.3, ≥0.55 committed → 0.9); the inner chevron FILLS when `intent.kind !== 'travel'`, which is the avoidance cue. A held koi reports no intent and draws nothing. The painter keeps one chain and one caret per instance and drops both when that koi leaves.
- **Pointer.** Every layer + iframe is `pointer-events:none`; host runs one normalized stream, hit-tests against fish-reported outlines, notifies the winner. HOVER ≠ SELECTION: hover only says selectable (SOFT silhouette `setOutline(0.35)`, cursor pointer/grab — no card); press = hold (`pause`; FULL silhouette `setOutline(1)` + card until release; picking a DIFFERENT koi releases the held one first); drag past slop (6px/12px touch) = carry (`place` streamed once per painted frame, grab-offset preserved, card chrome hidden while carrying); drop = release + resume; tap a held fish = release in place. The silhouette is renderer-native (inverted-hull `koi-outline-skin`/`koi-outline-fins` in `lib/src/three/koi.ts` sharing the live spine uniforms) — NEVER reintroduce a host-drawn rectangle. Mobile: `touch-action: manipulation` on `#pond`; a non-passive `touchmove` listener preventDefaults ONLY while a fish press is active.
- **Rendering.** Each fish owns its own transparent `WebGLRenderer` but renders ONLY its own frame box: `koiFrameBox` (lib) → square canvas ~1.7 body lengths, slid by CSS transform, camera narrowed via `PondView.frame()` (`setViewOffset`), buffer sized by `fitPondRenderer` (DPR≤2, 1280px cap), hidden + skipped entirely when outside the view. NEVER go back to viewport-sized fish canvases, and for the same reason `createPondRenderer` asks for `antialias` only below DPR 2. **Release GL on hidden**: `sleep {paused:true}` disposes the renderer after the loop stops; wake rebuilds it, staggered `WAKE_STAGGER_MS` 60 per ordinal so a returning shoal never creates every context in one frame — so every renderer factory must be re-invokable and its `dispose` must clear everything it mounted. The koi is anchored by `PondView.placeKoi` (nose → pond point), never by `place`. Host paints the bed canvas-2D (resize-only) and the moving surface on ONE WebGL context (`water-gl.ts`, caustics + crests + veil + card edge fade, ~0.72× resolution) with the 2D painter (`surface-canvas.ts`) as automatic fallback; every 2D surface caps at `MAX_CANVAS_DPR` 2 (`scene/pixel-ratio.ts`). **The water context WILL be reclaimed from a backgrounded tab** (measured: lost on return, and it is the shoal's own wake rebuilding 8 contexts that costs it). Recovery is three parts and needs all three: `webglcontextlost` → `preventDefault()` (a browser restores nothing you did not ask for) + rebuild on `webglcontextrestored` (`buildScene`, everything belonged to the dead context, and reset `sizedTo`); `onLost` tells the pond, which waits `WATER_RESTORE_GRACE_MS` 1500 and then calls `reseatSurface(stage)` — the canvas ELEMENT must go, since `getContext` on a lost-and-unrestored canvas returns the same dead context. Never check `lost()` only at the visible transition: the loss lands seconds later. A fish that loses its context outside a sleep/wake has the same hole (verified: stays LOST); its wake rebuild is what covers the ordinary case. All fish build the same camera from `POND_VIEW` (tilt 10°/fov 26°/exposure 1.15) via `createPondView`.
- **Behaviour** (all in `lib/src/motion/` + `lib/src/geometry/`). Scheduled, not noisy: `createPaceSchedule` (loaf/brisk/burst, exclusive), `createItinerary` (seeded waypoints, ~10% forced through the visible view), `slipsAway`/`wrapAcross`/`SHORE_ABSENT_S` (one boundary approach in five slips out, 5s absence, toroidal re-entry), `createEncounterMemory` (latches the manoeuvre KIND ≥0.9s + turn tail-off — the anti-oscillation core). Brains keep their own clock (`advance(dt)`), decide at 10Hz, anchor evasions as absolute headings, and bound ordinary turns with seeded cooldowns. **Costed manoeuvres**: `chooseTurnTier` picks the least effort predicted to clear (`evasionTiers` subtle π/10 gain 0.55, normal π/5 gain 1, hard π/3 gain 1.6) permitted by `tierWindowS` (hard <0.8s, normal <1.6s to closest approach); the chosen break is anchored ONCE as an absolute heading and held until the ladder asks for a strictly heavier tier (`EVASION_EFFORT`), so the koi actually arrives on it — never re-anchor it off the live heading each beat, which turns the arc into an unbounded spiral the caret can never converge on, and fire `onDecision` on each fresh anchor; `flankCrowding` picks the side from the water, and at `|crowding| ≤ sideEvidence` (0.02) a seeded draw breaks right with `rightBias` 0.7 so head-on pairs mutually avoid; `turnBrake` 0.35 charges the commanded manoeuvre against cruise (never double-charged with `turnSpeedTax`). Turn dynamics: the heading's rate is a wound `turnVelocity` under `TURN_ACCEL` 2.2 rad/s² (never a step), ceilings from `turnRate {0.35,0.8}`×gain taxed by speed over cruise, `TURN_APPROACH` ramps every turn out; speed capped at `MAX_SPEED_BL_S` 3.4 with `ACCEL_LIMIT_BL_S2` 2.6. 3D side: `turnBend` saturates at 1.4 rad centred 0.4 (torso, NOT the 0.32 shoulder that read as a broken neck), `bodyFlexibility` keeps the skull near-rigid, the amplitude surge term is clamped (also why every renderer refreshes `lastSpeed` in its offscreen-skip branch).
- **A koi opened on its own.** `lib/src/solo/` (`solo-page.ts` + `framework-mark.ts`): `createKoiRuntime` mounts it ONLY when the app passes `hosted: false` explicitly (silence reads as hosted — painted water inside a frame blanks every koi below it), and `setPond`/`dispose` take it straight back down. It prepends a `.koi-solo-sky` to `body` (DOM order, no z-index, so the koi's own canvas paints over it), appends a `.koi-solo` panel, and injects its own `<style>` (lit ships a different `fish.css`, so the sheet cannot be relied on). Contents come from what the lib already knows: `profile.label`, `palette.pattern`, `palette.accent` tinting a per-framework simplified mark, a `FRAMEWORK_NOTES` sentence on how that framework mounts THIS app, `KOI_CONTRACT_VERSION`, and links to `FRAMEWORK_SITES` / `koiSourceUrl` / the pond one path segment up (omitted when the app is its own origin root). Top-level ALSO changes the world: `describePondForFrame(innerWidth, innerHeight)` instead of the screen snapshot, so the fish stays in the window a visitor actually has open.
- **Angular's host element.** `createComponent(KoiFish, {hostElement})` — Angular REMOVES that element on `ApplicationRef.destroy()`. Never hand it the app root: the runtime disposes and rebuilds the renderer on every wake and on every `adopt` with a dealt seed, so `#app` left after the first dispose, every rebuild mounted into a detached node, and the koi went invisible-but-still-reported (a hoverable ghost) — one backgrounded tab killed it, and every duplicate was born dead. `fish-angular/src/koi/koi-render.ts` gives it a `display: contents` div it makes itself.
- **Predicted path.** `motion.predictPath(steps, dtStep)` integrates with `stepFlight`, the same integrator the brain's own advance uses, so absent a new decision the realized trajectory passes within tolerance of every predicted point (mutation-proven parity spec). It deliberately ignores future decisions; when the brain commits mid-horizon the next emitted path simply disagrees from the divergence point and the overlay cuts the stale suffix. No extra signal is emitted.
- **Vitals.** `?vitals=1` (remembered per origin until `?vitals=0`) mounts `components/vitals.ts` (z220): one probe row per living koi keyed and labelled by instance (rows follow `.koi-layer[data-instance]`, arriving and leaving with churn), a boot record `cores=… memory=…GB|unreported tier=… cap=…`, and session/roster/visibility log lines through the `PondHooks.onDiagnostic` seam, probing every fish frame at 5s (same-origin classification incl. shadow-root canvases, buffer sizes, heap, `webglcontextlost`), debounce-persisted to localStorage so it survives a page death. `added`/`removed` name the instance and the roster size (`4 of 4 koi`); `shoal:refused` names the tier. The panel title reads `vitals · visible|hidden` from the visibility watch. While armed the scene handle also sits on `window.koiPond`. **The card scene hides `.pond-vitals`** (a card carries no chrome); the card instance still records, and the persisted log is restored by whatever instance comes next.
- **Visibility.** `createVisibilityWatch` (`scene/visibility.ts`) settles state from an event, a 2s poll, and a probe frame armed only while hidden; silence never puts the pond to sleep. It stops the loop, sleeps every session, and calls `resurrection.pageVisible()` on return. `dispose()` releases listener, poll and probe frame.
- **Protocol.** Inner channels run as explicitly **open shells** (`protocol: 'none'` in each fish feature.config, packed with `--allow-open`) — v1's per-message PBKDF2 is the F-011 collapse; transport stays origin-pinned. Outer gallery↔pond channel keeps `protocol: 'v1'`.
- **Embedding.** Each service ships `public/hf-serve.config.json` carrying `frame-ancestors` header rules for `hf serve`. `frame-ancestors` is checked against EVERY ancestor, so the koi value must name the pond AND the docs site (chain is docs-site → pond → koi); naming only the pond blanks the shoal. In the pond's file the koi override is a later `"prefix": "/fish-"` rule (later wins per header). All nine configs also send COOP `same-origin` + COEP `require-corp` + CORP `cross-origin`, so the standalone pond origin is crossOriginIsolated (the card's memory line measures there; embedded under the non-isolated docs-site it honestly stays unavailable). `hf dev` applies no header rules; to see isolation locally, `hf serve --root` the built site.

## Commands (from repo root)

```bash
# lib: build + pack + reinstall into ALL consumers (ALWAYS run after editing lib/src)
npx nx run demo-koi-lib:refresh
npx nx run demo-koi-lib:verify          # fail loudly when tarball/consumer lock drifted
npx nx run demo-koi-lib:build           # tsc only; refresh does build+pack+install

# fish shells: repack all eight + reinstall into host (after inner-contract or fish feature.config changes)
npx nx run demo-koi-pond:refresh-fish-shells

# the whole family
npx nx run-many -t test build lint typecheck -p demo-koi-lib demo-koi-pond demo-koi-fish-*
npx nx run-many -t build -p demo-koi-*  # composed site → dist/apps/demos/koi-pond/site

# per project (only demo-koi-lib and demo-koi-pond carry a `test` target)
npx nx test demo-koi-lib
npx nx test demo-koi-pond
npx nx run demo-koi-fish-vanilla:build
```

**Editing `lib/src` without `:refresh` ships stale code.** A bare `npm install` no-ops on a same-version repack (warm cache reports "up to date"); `refresh` installs by explicit path, the only invocation that re-reads the tarball. After `:refresh`, run `npm install` inside a fish only if adding a _new_ dep.

## Run & debug

```bash
# composed pond on one origin (host + fish sub-paths), SDK dev server
npx nx run demo-koi-pond:dev-hosted     # app :4282, debug UI :4290
npx nx run demo-koi-pond:dev            # vite, host alone (fish frames 404 — use dev-hosted)

# serve the BUILT composed site (plain static server — NEVER serve -s)
npx nx run-many -t build -p demo-koi-*
npx http-server dist/apps/demos/koi-pond/site -p 4288
```

**Never `serve -s` / any SPA rewrite** — each missing `/fish-*/` becomes a nested copy of the pond host.

**Browser-verify the 3D shoal** (headless, software WebGL — the docs-site has no dev script, so the composed site is the only live check):

```js
// node script; full chromium (SwiftShader), not the headless_shell
import pkg from '/home/vscode/.npm/_npx/06476e4372e0b5ee/node_modules/playwright-core/index.js'
const { chromium } = pkg
const browser = await chromium.launch({
  executablePath: '/home/vscode/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome',
  args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader', '--ignore-gpu-blocklist'],
})
// goto http://localhost:4288/ — a direct visit is unhosted, so the frame's whole shoal opens at once;
// mouse.click(640,400) → ripple + scatter; sweep until [data-hovered="true"] → identity card;
// drive the shoal from the console via window.koiPond (needs ?vitals=1).
```

Panel rows carry `data-connected`/`data-hovered`; `.koi-layer[data-instance]` z-indexes prove the depth spread; curtain is `[data-open]`.

## Build & deploy

- **Composed site**: host builds to `dist/apps/demos/koi-pond/site/` (`emptyOutDir:false`, base `/`); each fish to `.../site/fish-<fw>/` (`emptyOutDir:true`, base `/fish-<fw>/`). Dev origin (`dev-hosted`) matches prod exactly.
- **Deploy**: one Railway service, GitHub-integration auto-deploy on merge to `main` (dashboard-owned — never propose in-repo deploy CI). `host/project.json` `metadata.deploy`: service `hyperfrontend-demo-koi-pond`, origin `https://demo-koi-pond-production.up.railway.app`, `publishDir: dist/apps/demos/koi-pond/site`. Build root must be at/above `apps/demos/koi-pond` so `../vendor` is in context; host must **not** SPA-rewrite. Origin also in `host/feature.config.ts` `url` and docs-site `demo-manifest.ts` fallback — all three move together.
- **Gallery shell** (only needed if the _outer contract_ or `feature.config.ts` changes — not for host scene edits):

```bash
npx nx run demo-koi-pond:pack-shell     # hf build → dist/apps/demos/koi-pond/shell/*.tgz
npx nx run docs-site:refresh-shell      # packs all demo shells + copies to docs-site/vendor + explicit-path install
```

Vendored shell + touched `package.json` + `package-lock.json` land **together** (a tarball without its locks fails `npm ci` EINTEGRITY on a cold cache). The koi shell name must stay in docs-site `eslint.config.cjs` `ignoredDependencies` or lint `--fix` deletes the dep.

### Contract bump pipeline (the order that works)

1. `demo-koi-lib:refresh` then `:verify` (shells bake the contract from the installed tarball).
2. Bump every fish `feature.config.ts` `version` to match the contract.
3. `demo-koi-pond:refresh-fish-shells` — packs all eight and copies to `host/vendor/`, but **never prunes**.
4. Hand-delete the stale-version `.tgz` from **both** `host/vendor/` and `dist/apps/demos/koi-pond/fish-shell/*/`.
5. Re-run `node host/scripts/install-vendored-shells.mjs` (the explicit-path install is the only invocation that re-reads a same-name tarball).
6. Commit tarballs, `package.json` and the lockfile together. All builds serial; **never overlap two builds of one project** (that is what produced the "nested vendor corruption" that turned out to be a race).

## Key files

| Concern              | File                                                                                                                                                                                                                                                                                 |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Camera contract      | `lib/src/model/pond-view.ts` (numbers), `lib/src/three/pond-view.ts` (builder)                                                                                                                                                                                                       |
| 3D koi surface       | `lib/src/three/koi.ts` (`createKoi`), `lib/src/three/koi-stage.ts` (`createKoiStage`), `lib/src/koi3d/`                                                                                                                                                                              |
| Steering brain       | `lib/src/motion/koi-motion.ts`, `motion/manoeuvre.ts` (tiers, flanks), `motion/predict.ts` (`stepFlight`, `predictFlight`)                                                                                                                                                           |
| Fish frame loop      | `lib/src/runtime/koi-runtime.ts` (`createKoiRuntime`, `adopt`, sleep/wake), `runtime/browsing-context.ts`                                                                                                                                                                            |
| Wire plumbing        | `lib/src/contract/wire.ts` (`wireKoiContract`, SDK-free structural `FeatureLink`)                                                                                                                                                                                                    |
| Depth model          | `lib/src/model/depth.ts` (`depthZIndex`/`swimDepth`/`mayRipple`)                                                                                                                                                                                                                     |
| Host assembly        | `host/src/scene/pond.ts` (loop, pointer, fan-out, `openProfile`, `addKoi`/`removeKoi`/`setScale`)                                                                                                                                                                                    |
| Instance keys        | `host/src/scene/instance-id.ts`                                                                                                                                                                                                                                                      |
| Boot decision        | `host/src/feature/wire-contract.ts` (`wireSceneBoot`, `wirePondContract`)                                                                                                                                                                                                            |
| Shoal panel          | `host/src/scene/roster.ts` (`createShoalPanel`)                                                                                                                                                                                                                                      |
| Overlay              | `host/src/scene/interactions.ts`, `pearl-trace.ts`, `sliding-caret.ts`                                                                                                                                                                                                               |
| Relay + dead-reckon  | `host/src/scene/relay.ts` (`record`/`neighborsFor`/`pick` take `now`)                                                                                                                                                                                                                |
| Frame-death recovery | `host/src/scene/resurrection.ts`, `host/src/components/vitals.ts`                                                                                                                                                                                                                    |
| Shell sessions       | `host/src/scene/koi-sessions.ts` (`openInstance`; `COMPOSED_DEPLOYMENT` URL seam)                                                                                                                                                                                                    |
| Fish renderer seam   | `fish-<fw>/src/koi/koi-render*` (the only browser-facing module in a fish app)                                                                                                                                                                                                       |
| Fish composition     | `fish-<fw>/src/state/koi.ts` (`createKoiRuntime({framework, root, link: feature, rendererFactory, hosted: feature.hosted})`)                                                                                                                                                         |
| Docs-site gallery    | `apps/docs-site/src/lib/demo-manifest.ts`, `components/demos/demo-wiring.ts` + `demo-console-actions.tsx` (`KoiPondConsoleActions`) + `use-expanded-embed.tsx` + `expand-choreography.ts` + `cover-flow.tsx` + `demo-showcase.tsx` + `expanded-chrome.tsx` + `embed-resurrection.ts` |

Cadence/budget constants: `intentHorizonS=2` (the seconds a koi answers for its own heading over, which bounds `intent.heading` to the arc its helm can actually carry it through), `OUTLINE_INTERVAL_MS=100` + `MAX_FRAME_S=0.1` + `WAKE_STAGGER_MS=60` (lib runtime), `RELAY_INTERVAL_MS=120`, `RELAY_REACH=3.4`, `HOVER_SLACK=0.14` (`TOUCH_SLACK_SCALE=2.6`), `DEAD_RECKON_MAX_S=0.6`, `STALE_REPORT_S=3`, `SHOAL_PULSE_MS=10_000`, `OPEN_TIMEOUT_MS=20_000`, `OPEN_RETRIES=2` + `OPEN_RETRY_DELAY_MS=4000`, `CURTAIN_DEADLINE_MS=5000`, `SEQUENCE_DEADLINE_MS=14_000`, `SCENE_FALLBACK_MS=1000`.

## Edit a fish (mirror `fish-vanilla`)

A fish app is four files: `hyperfrontend.feature.ts` (the SDK handle), `state/koi.ts` (composes `createKoiRuntime`), `koi/koi-render*` (the renderer, injectable so lib specs run headless), and `main.ts`. Renderer contract: `createKoiRenderer(root, profile, url, pond, createGl?)` returns `{ koi, draw(state,dt), setPond, setHovered, placeCard, dispose }`; it must be rebuildable from scratch on every wake and `dispose` must clear everything it mounted. Scene = koi group (`koi-shadow`/`koi-skin`/`koi-fins`/`koi-eyes`) + `createLighting(POND_VIEW.lighting)` via `createKoiStage`. Each fish declares `three ^0.185.1` + `@types/three ^0.185.4` (the lib peer is optional).

**Adding a whole framework** (angular precedent): `KoiFramework` union + `KOI_FRAMEWORKS` are **append-only** — list position is the koi's seed, reordering re-rolls every fish. Touch: palette (LABELS/BRAND/VARIETIES), HEFT, FRAMEWORK_SITES, `tools/refresh-lib.mjs` CONSUMERS, host `koi-sessions.ts` (shell import + factory map), host `project.json` refresh-fish-shells (dependsOn + cp line), count prose everywhere, docs-site copy. Trait/body draw offsets are append-only too (body band next free slot: 14 — 13 took `caudalSpread`; avoidance-side draws sit at band 640).

**Angular specifics** (fish-angular): AOT via `@analogjs/vite-plugin-angular` pointed at `tsconfig.angular.json` (extends the app config with `noEmit: false` — the default no-emit config makes every module compile to NOTHING with no error). Never set `resolve.mainFields`. Zoneless, no TestBed, no zone.js. Seam: `createApplication` → `createComponent(KoiFish, { hostElement: root })` → `setInput` → `attachView` → `tick()`; `dispose()` must `root.replaceChildren()` after `app.destroy()`. Lint: `@angular-eslint/eslint-plugin` alone.

## Gallery presentation

The docs-site card is a **card-profile pond instance**; expanding **swaps instances**. `demo-manifest.ts` marks the koi entry `reopensOnExpand: true`; `use-expanded-embed.tsx` holds a `StagedSession {scene, generation}` (`expand-choreography.ts`), and the surfaces mount `<DemoEmbed key={sessionKey}>`, so a scene change destroys the running session and opens a fresh one into the new container — never two live pond sessions. A session is told its scene on the shell's `open` event (the last moment the pond can still decide what to build); demos that carry one session across the scenes are told each change directly instead. `CoverFlowCard` restyles into `fixed inset-0` (the deck drops its `perspective` while expanded — perspective is a fixed-position containing block) with shared `ExpandedChrome` ✕/Escape/next-demo controls (the ← → pair is `hidden sm:flex`); the neighbour buttons hand the stage over without opening a replacement. `DemosGallery` switches the host console to its `overlaid` widget while expanded. The landing `DemoShowcase` expands the same way (its frame container drops `backdrop-blur-sm` **and its `z-10`** while expanded — a z-index makes it a stacking context, which trapped the overlay under the site header). Both surfaces subscribe `shell.on('close-request', collapse)`. The deck caption restyles into a dark plaque under the translucent scene. The generic console "Open as dialog" opens a second session — dev tool only, never the product expand path.

Fish sizing: `FISH_LENGTH_RATIO` 0.36 of the world's shorter axis (clamp 130..560), margin `1.05` fishLengths; `describePondForFrame` bypasses `MIN_POND` so a 288px card yields a 288-scale world, with `MIN_FISH_LENGTH` 130 the governing floor. The caudal blade carries a lateral dihedral (`caudal.spread`, default 0.2, trait band 0.20–0.28) so the fork reads from straight above — a flat vertical blade vanishes edge-on on narrow portrait viewports (the S24-Ultra stub-tail bug); **never zero it**, and the band has to clear the bank (`bank = turn*0.28`, up to 0.36 rad). The blade also refuses the body's curvature: `finFlexibility` in `spine-pose.ts` fades curvature to `CAUDAL_FLEX` past `CAUDAL_ROOT`, and `BLADE_DRIVE` in `swim-state.ts` pays the lost tip reach back into the body wave (`pose.spec.ts` guards it). Phenotypes: `koiPhenotype`/`koiTrim`/heft in `lib/src/model/traits.ts`; varieties in `palette.ts`. The held card is a live inspector rendered by the FISH from shared `describeKoiCard` strings; memory uses `performance.measureUserAgentSpecificMemory` filtered to the fish's own attribution and honestly shows `unavailable` without cross-origin isolation; samplers run ONLY while held (card rows 500ms, memory 10s) and tear down on release.

## Decision log

- **2026-08-23 — expand swaps instances** (supersedes the 2026-08-09 "expand keeps one session, the card is a window onto the same scene" decision). A card-sized pond and a full-viewport pond are different worlds, not different windows onto one; stretching the card world over a viewport shows a scene nothing would have built there. Expand destroys the card session and cold-opens a full session behind its own curtain; collapse does the reverse, and the recreated card re-picks its hourly fish. Scoped by the manifest flag: clock and heartbeat keep single-session expand, because destroying a heartbeat session would throw away the rhythm it measured.
- **2026-08-23 — the lib owns the simulation** (supersedes "the lib is a vocabulary, never a simulation engine; each fish composes its own brain"). Eight hand-maintained brains drifted and cost far more than they proved; the proof visitors can actually see is the framework seam, so the brain moved into the lib behind configuration hooks and the fish apps kept only their idiom.
- **Card carries no chrome.** No panel, no vitals, no toggle: the card is an invitation to expand. Diagnostics still record and persist.
- **Duplicates are gated by the tier cap alone.** No separate duplicate switch exists, and none should be added.

## Gotchas

- **Framing**: a koi is mounted ONLY from its directory URL `fish-<fw>/` — its assets are relative, so a host that rewrites `…/index.html` to an extensionless path drops the document a directory up and every asset 404s (this blanked the shoal in production once). Needs features ≥0.6.0 for directory URLs; the `hosted` flag needs ≥0.8.0.
- **F-011**: v1 message collapse across concurrent channels (root-caused; inner channels drop v1) — still open in `roadmap/showcase/findings/`. F-010/F-015/F-016 shipped in features 0.7.1; F-018/F-019 (gallery-side revival) and F-020/F-021 (`hosted`, visibility latch) are answered in the docs-site embed and features 0.8.0. The koi host pins the debug UI with `debug.port: 4290` in `hf-dev.config.ts`.
- **Per-fish three.js** (~180 kB gzip each): inherent to independent apps; a shared chunk breaks the isolation the demo proves. Curtain covers the load. Not a bug.
- **Unit tests live in `demo-koi-lib` and `demo-koi-pond` only.** The eight `fish-*` apps carry no vitest, no `test` target, and no test devDeps by design. Do not add unit tests back to a fish app. Keep the lib's specs mutation-proven (disable avoidance → boundary specs fail) across all seeds, and never write `?.foo()).not.toBe(null)` (passes on absent nodes). Canvas grammar is asserted through `scene/__tests__/overlay-recorder.ts`, a recording 2D context.
- **Devcontainer**: ten `npm install`s at `parallel:1`; full `nx lint docs-site` can SIGKILL — lint targeted file lists (`npx eslint <files>`).

## Checklist

- [ ] Edited `lib/src`? → `demo-koi-lib:refresh` then `:verify`
- [ ] Changed the inner contract or a fish `feature.config.ts`? → the bump pipeline above, tarballs + package.json + lock together
- [ ] Scope = Nx project name; one project per commit; no version/changelog/tag files
- [ ] Comments never cite finding IDs / roadmap docs; shipped prose is present-state and em-dash-free in READMEs and JSDoc
- [ ] `npx nx run-many -t test build lint typecheck -p demo-koi-*` green
- [ ] Serving built site by hand → `http-server`, never `serve -s`
